Updating asset metadata

A registered asset has two parts: the contract, fixed forever because the on-chain asset commits to it, and metadata that the issuer changes with signed actions.

What can change

PartFieldsWho changes itHow
Issuer metadatamutable.category_tags, mutable.trading_venues, mutable.custom (your own key/value fields, e.g. an ISIN or website)IssuerReplace tags, replace venues, set or delete a custom field, replace all custom fields
Issuer keycurrent_issuer_pubkey (history in issuer_pubkey_history)IssuerRotate the issuer key. A wrong key locks the asset permanently
IconiconIssuer proposes, admin approvesAdding an icon
Admin annotationsadmin: featured, delisted, malicious, asset typeRegistry adminsAdmin endpoints
RegistrationWhether the asset is listed at allIssuerDeregister an asset

What can't change

The contract: name, ticker, precision, entity.domain, version and initial_issuer_pubkey. It's the provable link between the registry entry and the asset on chain:

  1. The contract hash is the SHA-256 of the contract's canonical JSON (Register an asset).
  2. The issuance transaction commits to that hash, and the asset ID is derived from it together with the outpoint the issuance input spends (issuance_prevout).
  3. Anyone can recompute the hash from the registry's contract, combine it with issuance_prevout, and check that the result is the asset ID.

Changing any contract field would change the hash, and the contract would no longer match the asset on chain. To change the name, ticker, precision or domain, issue and register a new asset.

Example: add an ISIN

Set the custom field isin on an asset with Set a custom field. The program reads the latest action hash, builds the action, signs its canonical JSON with the current issuer key and posts it (Signing requests explains the signing helpers).

# Template only: sign a fresh BODY (new nonce, timestamp, prev_action_hash). See Signing requests.
BODY='{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","custom_key":"isin","mutable_schema_version":1,"nonce":"c48b8e4c-7bc3-4d4c-b859-0450c28e9b8a","operation":"set_custom_field","prev_action_hash":"e0f86da7419b1594bdfdae4bb264468e423d063ed59b47a39ec35536c3984c5b","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.385000Z","value":"US0000000001"}'
SIG='t00oLGIy+xeNcwQfuA13UoY4JLFO+DocEsGcL3Xx+71hULlYf9w3h63S3Iis5lXUIOWjaC2kF1JZ97BC3c9Uzg=='

curl -X POST "https://assets.blockstream.info/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Signature: $SIG" \
  --data-raw "$BODY"
// Node.js 18+ (ES module: save as .mjs or set "type": "module")
import { createECDH, createHash, createPrivateKey, randomUUID, sign } from "node:crypto";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d";
const PRIVATE_KEY = process.env.ISSUER_PRIVATE_KEY; // 32-byte hex: the asset's current issuer key

// The next action must reference the hash of the latest accepted one
const latest = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions/latest`);
if (!latest.ok) throw new Error(`${latest.status} ${await latest.text()}`);
const { action_hash: prevActionHash } = await latest.json();

const action = {
  asset_id: ASSET_ID,
  custom_key: "isin",
  mutable_schema_version: 1,
  nonce: randomUUID(),
  operation: "set_custom_field",
  prev_action_hash: prevActionHash,
  signing_context: "liquid-asset-registry-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
  value: "US0000000001",
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Signature": signMessage(body, PRIVATE_KEY) },
  body: body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
console.log(JSON.stringify(await res.json(), null, 2)); // full output: plain console.log cuts long strings and deep objects

// --- signing helpers (see Signing requests) ---
// Canonical JSON: keys sorted recursively, no whitespace. Use integers or strings for numbers
// (non-integer numbers are formatted differently by JavaScript and the registry).
function canonicalJson(v) {
  if (Array.isArray(v)) return `[${v.map(canonicalJson).join(",")}]`;
  if (v && typeof v === "object") return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(v[k])}`).join(",")}}`;
  return JSON.stringify(v);
}

// Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message.
function signMessage(message, privateKeyHex) {
  const varint = (n) => (n < 0xfd ? Buffer.from([n]) : Buffer.from([0xfd, n & 0xff, n >> 8]));
  const msg = Buffer.from(message, "utf8");
  const magic = Buffer.from("Bitcoin Signed Message:\n");
  const firstHash = createHash("sha256").update(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg])).digest();
  const d = Buffer.from(privateKeyHex, "hex");
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(d);
  const pub = ecdh.getPublicKey();
  const key = createPrivateKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1", d: d.toString("base64url"),
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  const sig = sign("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }); // ECDSA-SHA256 adds the second hash
  const n = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
  const s = BigInt("0x" + sig.subarray(32).toString("hex"));
  if (s > n / 2n) sig.write((n - s).toString(16).padStart(64, "0"), 32, "hex"); // low-S form
  return sig.toString("base64");
}

// Compressed public key (66 hex) for a private key.
function publicKeyOf(privateKeyHex) {
  const ecdh = createECDH("secp256k1");
  ecdh.setPrivateKey(Buffer.from(privateKeyHex, "hex"));
  return ecdh.getPublicKey("hex", "compressed");
}
# pip install requests cryptography==50.0.2
import base64
import hashlib
import json
import os
import uuid
from datetime import datetime, timezone

import requests
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature

BASE_URL = "https://assets.blockstream.info"
ASSET_ID = "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
PRIVATE_KEY = os.environ["ISSUER_PRIVATE_KEY"]  # 32-byte hex: the asset's current issuer key

def canonical_json(value):
    """Canonical JSON: keys sorted recursively, no whitespace. Prefer integers or strings for numbers."""
    return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)

def sign_message(message, private_key_hex):
    """Bitcoin Signed Message signature (Base64, 64-byte R||S) over the UTF-8 message."""
    msg = message.encode("utf-8")
    varint = lambda n: bytes([n]) if n < 0xFD else b"\xfd" + n.to_bytes(2, "little")
    payload = varint(24) + b"Bitcoin Signed Message:\n" + varint(len(msg)) + msg
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    first_hash = hashlib.sha256(payload).digest()  # ECDSA-SHA256 adds the second hash
    r, s = decode_dss_signature(key.sign(first_hash, ec.ECDSA(hashes.SHA256())))
    n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
    s = min(s, n - s)  # low-S form
    return base64.b64encode(r.to_bytes(32, "big") + s.to_bytes(32, "big")).decode()

def public_key_of(private_key_hex):
    """Compressed public key (66 hex) for a private key."""
    key = ec.derive_private_key(int(private_key_hex, 16), ec.SECP256K1())
    return key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.CompressedPoint).hex()

# The next action must reference the hash of the latest accepted one
latest = requests.get(f"{BASE_URL}/v2/assets/{ASSET_ID}/actions/latest", timeout=30)
if not latest.ok:
    raise RuntimeError(f"{latest.status_code} {latest.text}")
prev_action_hash = latest.json()["action_hash"]

action = {
    "asset_id": ASSET_ID,
    "custom_key": "isin",
    "mutable_schema_version": 1,
    "nonce": str(uuid.uuid4()),
    "operation": "set_custom_field",
    "prev_action_hash": prev_action_hash,
    "signing_context": "liquid-asset-registry-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
    "value": "US0000000001",
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Signature": sign_message(body, PRIVATE_KEY)},
    timeout=30,
)
if not res.ok:
    raise RuntimeError(f"{res.status_code} {res.text}")
print(res.json())
// go mod init example && go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
package main

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"time"

	"github.com/decred/dcrd/dcrec/secp256k1/v4"
	"github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
)

func main() {
	baseURL := "https://assets.blockstream.info"
	assetID := "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d"
	endpoint := baseURL + "/v2/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ISSUER_PRIVATE_KEY") // 32-byte hex: the asset's current issuer key

	// The next action must reference the hash of the latest accepted one
	var latest struct {
		ActionHash string `json:"action_hash"`
	}
	getJSON(baseURL+"/v2/assets/"+assetID+"/actions/latest", &latest)

	action := map[string]any{}
	action["asset_id"] = assetID
	action["custom_key"] = "isin"
	action["mutable_schema_version"] = 1
	action["nonce"] = newNonce()
	action["operation"] = "set_custom_field"
	action["prev_action_hash"] = latest.ActionHash
	action["signing_context"] = "liquid-asset-registry-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	action["value"] = "US0000000001"
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("POST", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Signature", sig)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	body, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, body))
	}
	fmt.Println(string(body))
}

// canonicalJSON: keys sorted (Go sorts map keys), no whitespace, no HTML escaping.
func canonicalJSON(v any) []byte {
	var buf bytes.Buffer
	enc := json.NewEncoder(&buf)
	enc.SetEscapeHTML(false)
	if err := enc.Encode(v); err != nil {
		panic(err)
	}
	return bytes.TrimRight(buf.Bytes(), "\n")
}

// signMessage returns a Bitcoin Signed Message signature (Base64, 64-byte R||S).
func signMessage(message []byte, privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	var payload bytes.Buffer
	writeVarint(&payload, 24)
	payload.WriteString("Bitcoin Signed Message:\n")
	writeVarint(&payload, len(message))
	payload.Write(message)
	first := sha256.Sum256(payload.Bytes())
	digest := sha256.Sum256(first[:])
	sig := ecdsa.SignCompact(secp256k1.PrivKeyFromBytes(key), digest[:], true) // recovery byte + R||S, low-S
	return base64.StdEncoding.EncodeToString(sig[1:])
}

func writeVarint(b *bytes.Buffer, n int) {
	if n < 0xfd {
		b.WriteByte(byte(n))
		return
	}
	b.WriteByte(0xfd)
	b.WriteByte(byte(n))
	b.WriteByte(byte(n >> 8))
}

// publicKeyOf returns the compressed public key (66 hex) for a private key.
func publicKeyOf(privateKeyHex string) string {
	key, err := hex.DecodeString(privateKeyHex)
	if err != nil {
		panic(err)
	}
	return hex.EncodeToString(secp256k1.PrivKeyFromBytes(key).PubKey().SerializeCompressed())
}

func getJSON(endpoint string, v any) {
	res, err := http.Get(endpoint)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()
	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	if res.StatusCode != http.StatusOK {
		panic(fmt.Sprintf("%d %s", res.StatusCode, out))
	}
	if err := json.Unmarshal(out, v); err != nil {
		panic(err)
	}
}

func newNonce() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return hex.EncodeToString(b)
}

Response: 200 (local instance · application/json)

{
  "status": "applied",
  "audit_entry": {
    "audit_id": 5,
    "server_received_at": "2026-11-03T22:47:34.386019Z",
    "actor": "issuer",
    "verified_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "admin_id": null,
    "action": {
      "nonce": "c48b8e4c-7bc3-4d4c-b859-0450c28e9b8a",
      "value": "US0000000001",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "operation": "set_custom_field",
      "timestamp": "2026-11-03T22:47:34.385000Z",
      "custom_key": "isin",
      "signing_context": "liquid-asset-registry-action-v1",
      "prev_action_hash": "e0f86da7419b1594bdfdae4bb264468e423d063ed59b47a39ec35536c3984c5b",
      "mutable_schema_version": 1
    },
    "action_hash": "ecfe0032cb155e21c31e36ba7046b2d110fac9cba6dc47ddccf23edc3d271e3c",
    "signature": "t00oLGIy+xeNcwQfuA13UoY4JLFO+DocEsGcL3Xx+71hULlYf9w3h63S3Iis5lXUIOWjaC2kF1JZ97BC3c9Uzg=="
  },
  "asset": {
    "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
    "contract": {
      "entity": {
        "domain": "example-issuer.com"
      },
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2,
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"
    },
    "issuance_txin": null,
    "issuance_prevout": null,
    "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "initial_issuer_pubkey_source": "contract",
    "current_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
    "issuer_pubkey_history": [
      {
        "pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
        "valid_from_audit_id": 1,
        "valid_until_audit_id": null
      }
    ],
    "mutable": {
      "trading_venues": [
        {
          "venue": "sideswap",
          "url": "https://sideswap.io/"
        },
        {
          "venue": "bitfinex",
          "url": "https://trading.bitfinex.com/t/EXUSD:USD"
        }
      ],
      "category_tags": [
        "stablecoin",
        "tokenized"
      ],
      "custom": {
        "isin": "US0000000001",
        "website": "https://example-issuer.com"
      }
    },
    "admin": {
      "asset_type": null,
      "featured": false,
      "malicious": false,
      "delisted": false,
      "admin_notes": null,
      "last_admin_action": null
    },
    "icon": null,
    "status": "active",
    "created_at": "2026-11-03T22:47:34.213879Z",
    "updated_at": "2026-11-03T22:47:34.391113Z"
  }
}

In the response, asset.mutable.custom now has isin. The contract, and so the asset ID, is unchanged. The audit log gains a set_custom_field entry.

How updates are chained

Every issuer action names the hash of the previous accepted action. That makes the history tamper-evident and gives you optimistic concurrency.

  1. GET …/actions/latest → action_hash.
  2. Build the action with that hash as prev_action_hash, a new nonce and a fresh timestamp.
  3. Sign and POST. The response's audit_entry.action_hash is the next prev_action_hash.

Retries

Re-sending the identical signed body (same nonce) returns 200 with status: "idempotent_retry" and the original audit entry, so retrying after a timeout is safe.

Errors

Status / codeMeaningDo
409 prev_action_hash_mismatchAnother action landed first; details.expected_prev_action_hash has the new oneRebuild, re-sign, resend
409 nonce_conflictNonce reused with different contentNew nonce
400 non_canonical_payloadBody isn't canonical JSON; details.canonical_payload shows the expected bytesSend exactly the bytes you signed
401 invalid_signatureDoesn't verify against current_issuer_pubkeyCheck the key (rotated?) and the signing format
400 stale_timestampOutside ±5 min, or older than your previous actionFix the clock; fresh timestamp
400 no_op_actionNothing would changeNothing to do
400 mutable_schema_version_mismatchWrong mutable_schema_versionUse 1
404 asset_not_foundUnknown, deregistered, or legacy and not migratedCheck the asset; migration

Example: non-canonical body: 400 (local instance · application/json)

{
  "error": "non_canonical_payload",
  "message": "request body is not canonical JSON",
  "details": {
    "canonical_payload": "{\"asset_id\":\"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d\",\"category_tags\":[\"bond\"],\"mutable_schema_version\":1,\"nonce\":\"83c0be4a-04dd-4043-b048-226c315242c9\",\"operation\":\"replace_category_tags\",\"prev_action_hash\":\"2bc27ebf045fec3c123e5ceba8ebf759911762f53693501d675a434c6de56565\",\"signing_context\":\"liquid-asset-registry-action-v1\",\"timestamp\":\"2026-11-03T22:47:34.494000Z\"}",
    "canonical_payload_sha256": "d393404cf28fabcc1f3e08ecf6db3887492e4bd8832226d15e27acedcb37fd41"
  }
}

The Liquid Network is a production Bitcoin sidechain built with Elements, supporting confidential transactions, asset issuance and programmable smart contracts.

© 2026 Liquid Network
All rights reserved.

Feedback and Requests

We'd be happy to hear your suggestions on how to improve this site.

Simplicity Forum

Discuss SimplicityHL, tooling and applications on Liquid at community.simplicity-lang.org.

Liquid Dev Community

Community Telegram group where most of the Liquid developers hang out. Go to t.me/liquid_devel to join.