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
| Part | Fields | Who changes it | How |
|---|---|---|---|
| Issuer metadata | mutable.category_tags, mutable.trading_venues, mutable.custom (your own key/value fields, e.g. an ISIN or website) | Issuer | Replace tags, replace venues, set or delete a custom field, replace all custom fields |
| Issuer key | current_issuer_pubkey (history in issuer_pubkey_history) | Issuer | Rotate the issuer key. A wrong key locks the asset permanently |
| Icon | icon | Issuer proposes, admin approves | Adding an icon |
| Admin annotations | admin: featured, delisted, malicious, asset type | Registry admins | Admin endpoints |
| Registration | Whether the asset is listed at all | Issuer | Deregister 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:
- The contract hash is the SHA-256 of the contract's canonical JSON (Register an asset).
- 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). - 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.
- GET …/actions/latest →
action_hash. - Build the action with that hash as
prev_action_hash, a new nonce and a fresh timestamp. - Sign and POST. The response's
audit_entry.action_hashis the nextprev_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 / code | Meaning | Do |
|---|---|---|
409 prev_action_hash_mismatch | Another action landed first; details.expected_prev_action_hash has the new one | Rebuild, re-sign, resend |
409 nonce_conflict | Nonce reused with different content | New nonce |
400 non_canonical_payload | Body isn't canonical JSON; details.canonical_payload shows the expected bytes | Send exactly the bytes you signed |
401 invalid_signature | Doesn't verify against current_issuer_pubkey | Check the key (rotated?) and the signing format |
400 stale_timestamp | Outside ±5 min, or older than your previous action | Fix the clock; fresh timestamp |
400 no_op_action | Nothing would change | Nothing to do |
400 mutable_schema_version_mismatch | Wrong mutable_schema_version | Use 1 |
404 asset_not_found | Unknown, deregistered, or legacy and not migrated | Check 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"
}
}Updated about 4 hours ago