Admin Endpoints

Registry operators: admins and permissions, moderation, icon review and migration. Signed with admin keys.

NetworkBase URL
Liquidhttps://assets.blockstream.info/v2
Liquid Testnethttps://assets-testnet.blockstream.info/v2

Examples use the Liquid (mainnet) base URL. For testnet, swap in the testnet base URL. Write examples come from a local instance with throwaway keys. Use your own asset ID and key, and never send writes to production just to test.

Endpoints

MethodEndpointPath
POSTAdd an admin/v2/admin/actions
POSTUpdate admin permissions/v2/admin/actions
POSTRename an admin/v2/admin/actions
POSTRemove an admin/v2/admin/actions
PUTUpdate annotations/v2/admin/assets/:asset_id/annotations
POSTForce-delist an asset/v2/admin/assets/:asset_id/actions
POSTForce-relist an asset/v2/admin/assets/:asset_id/actions
POSTList pending icons/v2/admin/icon-proposals/search
POSTApprove an icon/v2/admin/assets/:asset_id/actions
POSTReject an icon/v2/admin/assets/:asset_id/actions
PUTUpload and publish an icon/v2/admin/assets/:asset_id/icon
POSTMigrate a legacy asset/v2/assets/:asset_id/migrate

Admin permissions

PermissionAllows
rootEverything, including granting root
manage_adminsAdd, update and remove non-root admins
annotate_assetsUpdate annotations
delist_assetsForce-delist and relist
review_iconsList pending icons, approve, reject
manage_iconsUpload and publish icons
migrate_assetsMigrate legacy assets

There's no endpoint that lists admins. Rebuild the list from the public log: GET /v2/audit?actor=admin and replay add_admin, update_admin_permissions, update_admin_name and remove_admin in order.

Admins

Add an admin

POST /v2/admin/actions ADMIN-SIGNED · operation add_admin

Creates an admin (or reactivates a removed one) with a display name and permissions. Granting root requires root; otherwise manage_admins.

Parameters

NameIn · typeDescription
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"add_admin"
admin_pubkey requiredbody · pubkeyThe new admin's key.
friendly_name requiredbody · string1–255 chars.
permissions requiredbody · arraySee permissions.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.
📘

Admin endpoints are for registry operators. Every call is signed by an admin key with the right permission; a missing permission returns 403 forbidden. Permissions.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","admin_pubkey":"03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc","friendly_name":"Ops reviewer","nonce":"a75cc516-c459-4ab8-bd8f-c8d8f6dd2275","operation":"add_admin","permissions":["annotate_assets"],"signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.760000Z"}'
SIG='TPobYKvYlGq3bOaPe4smnHLrl+laALETSAS9weNmIuVcoqCtftYjiLbCLkAww5eSeEgaIPd1d6fAQS72peE87A=='

curl -X POST "https://assets.blockstream.info/v2/admin/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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 PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  admin_pubkey: "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
  friendly_name: "Ops reviewer",
  nonce: randomUUID(),
  operation: "add_admin",
  permissions: ["annotate_assets"],
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "admin_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
    "friendly_name": "Ops reviewer",
    "nonce": str(uuid.uuid4()),
    "operation": "add_admin",
    "permissions": ["annotate_assets"],
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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"
	endpoint := baseURL + "/v2/admin/actions"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["admin_pubkey"] = "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc"
	action["friendly_name"] = "Ops reviewer"
	action["nonce"] = newNonce()
	action["operation"] = "add_admin"
	action["permissions"] = []any{"annotate_assets"}
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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": 14,
    "server_received_at": "2026-11-03T22:47:34.761058Z",
    "actor": "admin",
    "verified_pubkey": "03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee",
    "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
    "action": {
      "nonce": "a75cc516-c459-4ab8-bd8f-c8d8f6dd2275",
      "operation": "add_admin",
      "timestamp": "2026-11-03T22:47:34.760000Z",
      "permissions": [
        "annotate_assets"
      ],
      "actor_pubkey": "03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee",
      "admin_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
      "friendly_name": "Ops reviewer",
      "signing_context": "liquid-asset-registry-admin-action-v1"
    },
    "action_hash": null,
    "signature": "TPobYKvYlGq3bOaPe4smnHLrl+laALETSAS9weNmIuVcoqCtftYjiLbCLkAww5eSeEgaIPd1d6fAQS72peE87A=="
  }
}

Update admin permissions

POST /v2/admin/actions ADMIN-SIGNED · operation update_admin_permissions

Replaces an admin's permission list.

Parameters

NameIn · typeDescription
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"update_admin_permissions"
admin_pubkey requiredbody · pubkeyAdmin to update.
permissions requiredbody · arrayNew list.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","admin_pubkey":"03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc","nonce":"f7cb461a-ab2a-4218-b278-67241bbee147","operation":"update_admin_permissions","permissions":["annotate_assets","review_icons"],"signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.776000Z"}'
SIG='qk7YBy8tRww835+UWpiE+EdHkCC7dJ4EfXLQ6qALqDZgkJpiavDY4UcnmsoMpV9xNkUX0IKQ7JExmxlEB7d3sA=='

curl -X POST "https://assets.blockstream.info/v2/admin/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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 PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  admin_pubkey: "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
  nonce: randomUUID(),
  operation: "update_admin_permissions",
  permissions: ["annotate_assets", "review_icons"],
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "admin_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
    "nonce": str(uuid.uuid4()),
    "operation": "update_admin_permissions",
    "permissions": ["annotate_assets", "review_icons"],
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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"
	endpoint := baseURL + "/v2/admin/actions"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["admin_pubkey"] = "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc"
	action["nonce"] = newNonce()
	action["operation"] = "update_admin_permissions"
	action["permissions"] = []any{"annotate_assets", "review_icons"}
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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": 15,
    "server_received_at": "2026-11-03T22:47:34.777509Z",
    "actor": "admin",
    "verified_pubkey": "03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee",
    "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
    "action": {
      "nonce": "f7cb461a-ab2a-4218-b278-67241bbee147",
      "operation": "update_admin_permissions",
      "timestamp": "2026-11-03T22:47:34.776000Z",
      "permissions": [
        "annotate_assets",
        "review_icons"
      ],
      "actor_pubkey": "03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee",
      "admin_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
      "signing_context": "liquid-asset-registry-admin-action-v1"
    },
    "action_hash": null,
    "signature": "qk7YBy8tRww835+UWpiE+EdHkCC7dJ4EfXLQ6qALqDZgkJpiavDY4UcnmsoMpV9xNkUX0IKQ7JExmxlEB7d3sA=="
  }
}

Rename an admin

POST /v2/admin/actions ADMIN-SIGNED · operation update_admin_name

Changes an admin's display name.

Parameters

NameIn · typeDescription
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"update_admin_name"
admin_pubkey requiredbody · pubkeyAdmin to update.
friendly_name requiredbody · string1–255 chars.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","admin_pubkey":"03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc","friendly_name":"Ops reviewer (EU)","nonce":"4e3fc4db-fb14-41c5-85a2-f0f963ef4e74","operation":"update_admin_name","signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.786000Z"}'
SIG='8ie0UB4UU7XKmdCGUdi8r/AfP8l/Zk3REcTxYeyBWchL+Cja8YJjqiIVQad+SD1GMnla0vXTHdJ03XoakgXF3A=='

curl -X POST "https://assets.blockstream.info/v2/admin/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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 PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  admin_pubkey: "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
  friendly_name: "Ops reviewer (EU)",
  nonce: randomUUID(),
  operation: "update_admin_name",
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "admin_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
    "friendly_name": "Ops reviewer (EU)",
    "nonce": str(uuid.uuid4()),
    "operation": "update_admin_name",
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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"
	endpoint := baseURL + "/v2/admin/actions"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["admin_pubkey"] = "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc"
	action["friendly_name"] = "Ops reviewer (EU)"
	action["nonce"] = newNonce()
	action["operation"] = "update_admin_name"
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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": 16,
    "server_received_at": "2026-11-03T22:47:34.786989Z",
    "actor": "admin",
    "verified_pubkey": "03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee",
    "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
    "action": {
      "nonce": "4e3fc4db-fb14-41c5-85a2-f0f963ef4e74",
      "operation": "update_admin_name",
      "timestamp": "2026-11-03T22:47:34.786000Z",
      "actor_pubkey": "03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee",
      "admin_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
      "friendly_name": "Ops reviewer (EU)",
      "signing_context": "liquid-asset-registry-admin-action-v1"
    },
    "action_hash": null,
    "signature": "8ie0UB4UU7XKmdCGUdi8r/AfP8l/Zk3REcTxYeyBWchL+Cja8YJjqiIVQad+SD1GMnla0vXTHdJ03XoakgXF3A=="
  }
}

Remove an admin

POST /v2/admin/actions ADMIN-SIGNED · operation remove_admin

Deactivates an admin. The last root admin can't be removed (last_root_admin).

Parameters

NameIn · typeDescription
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"remove_admin"
admin_pubkey requiredbody · pubkeyAdmin to remove.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","admin_pubkey":"03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc","nonce":"14e35488-9505-48d8-af2b-7f24d26e682c","operation":"remove_admin","signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.801000Z"}'
SIG='qFHfMSf7byNRP5Cd9LTZkaRFHkhFhUaUHC37gCYBYHgtUz1swPO368KS2o5Ofc9D0AO2HFEtkxC4zoEc4IPDUQ=='

curl -X POST "https://assets.blockstream.info/v2/admin/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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 PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  admin_pubkey: "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
  nonce: randomUUID(),
  operation: "remove_admin",
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "admin_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
    "nonce": str(uuid.uuid4()),
    "operation": "remove_admin",
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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"
	endpoint := baseURL + "/v2/admin/actions"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["admin_pubkey"] = "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc"
	action["nonce"] = newNonce()
	action["operation"] = "remove_admin"
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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": 17,
    "server_received_at": "2026-11-03T22:47:34.802365Z",
    "actor": "admin",
    "verified_pubkey": "03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee",
    "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
    "action": {
      "nonce": "14e35488-9505-48d8-af2b-7f24d26e682c",
      "operation": "remove_admin",
      "timestamp": "2026-11-03T22:47:34.801000Z",
      "actor_pubkey": "03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee",
      "admin_pubkey": "03480ed5d4f1a64659f03a41f86ede472a5c01cf7db1aaaea25281bdcf022f2fbc",
      "signing_context": "liquid-asset-registry-admin-action-v1"
    },
    "action_hash": null,
    "signature": "qFHfMSf7byNRP5Cd9LTZkaRFHkhFhUaUHC37gCYBYHgtUz1swPO368KS2o5Ofc9D0AO2HFEtkxC4zoEc4IPDUQ=="
  }
}

Moderation

Update annotations

PUT /v2/admin/assets/:asset_id/annotations ADMIN-SIGNED · operation update_admin_annotations

Sets registry-operated flags on an asset. Only the fields in changes change. Needs annotate_assets.

Returns an asset object.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"update_admin_annotations"
asset_id requiredbody · hex (64)Must match the URL.
changes requiredbody · objectAny of featured, malicious, delisted (booleans), asset_type, admin_notes (≤ 4096 chars).
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","changes":{"admin_notes":"Reviewed","asset_type":"stablecoin","featured":true},"nonce":"8b147a88-eb5f-4578-979a-5793cdbb6b5c","operation":"update_admin_annotations","signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.813000Z"}'
SIG='chHOt/RsrPpTEkPE5OJ8Bc7yMv2fjBa90Sq49fvif5kp+yHzfoRJGgfoRKEWAAp57r9cIg9GxfgHzxn3nCmWpw=='

curl -X PUT "https://assets.blockstream.info/v2/admin/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/annotations" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  asset_id: ASSET_ID,
  changes: { admin_notes: "Reviewed", asset_type: "stablecoin", featured: true },
  nonce: randomUUID(),
  operation: "update_admin_annotations",
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/assets/${ASSET_ID}/annotations`, {
  method: "PUT",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "asset_id": ASSET_ID,
    "changes": {"admin_notes": "Reviewed", "asset_type": "stablecoin", "featured": True},
    "nonce": str(uuid.uuid4()),
    "operation": "update_admin_annotations",
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.put(
    f"{BASE_URL}/v2/admin/assets/{ASSET_ID}/annotations",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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/admin/assets/" + assetID + "/annotations"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["asset_id"] = assetID
	action["changes"] = map[string]any{"admin_notes": "Reviewed", "asset_type": "stablecoin", "featured": true}
	action["nonce"] = newNonce()
	action["operation"] = "update_admin_annotations"
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	payload := canonicalJSON(action) // send exactly the signed bytes
	sig := signMessage(payload, privateKey)
	req, err := http.NewRequest("PUT", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Admin-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 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)
{
  "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": {
      "attestation": "https://example-issuer.com/reserves",
      "series": "A",
      "website": "https://example-issuer.com"
    }
  },
  "admin": {
    "asset_type": "stablecoin",
    "featured": true,
    "malicious": false,
    "delisted": false,
    "admin_notes": "Reviewed",
    "last_admin_action": {
      "action": "update_admin_annotations",
      "field": null,
      "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
      "timestamp": "2026-11-03T22:47:34.814033Z"
    }
  },
  "icon": {
    "href": "/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/icon/4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225.png"
  },
  "status": "active",
  "created_at": "2026-11-03T22:47:34.213879Z",
  "updated_at": "2026-11-03T22:47:34.819402Z"
}

Force-delist an asset

POST /v2/admin/assets/:asset_id/actions ADMIN-SIGNED · operation force_delist_asset

Sets admin.delisted and stores the reason in admin_notes. The asset stays listed; clients decide what to do with the flag. Needs delist_assets.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"force_delist_asset"
asset_id requiredbody · hex (64)Must match the URL.
reasonbody · string≤ 4096 chars.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","asset_id":"6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e","nonce":"0f39fecd-373b-43ec-bb06-23da4104b965","operation":"force_delist_asset","reason":"Policy review","signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.832000Z"}'
SIG='6n2je5Ud9U40oDP769GoQCN87d4et13zAGW3qgVaIlFB5lCkqb4xyxwbUdbRHygyFiQRRMeU4YJurO15dyarnQ=='

curl -X POST "https://assets.blockstream.info/v2/admin/assets/6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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 = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e";
const PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  asset_id: ASSET_ID,
  nonce: randomUUID(),
  operation: "force_delist_asset",
  reason: "Policy review",
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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 = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "asset_id": ASSET_ID,
    "nonce": str(uuid.uuid4()),
    "operation": "force_delist_asset",
    "reason": "Policy review",
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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 := "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
	endpoint := baseURL + "/v2/admin/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["asset_id"] = assetID
	action["nonce"] = newNonce()
	action["operation"] = "force_delist_asset"
	action["reason"] = "Policy review"
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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)
{
  "asset_id": "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
  "contract": {
    "entity": {
      "domain": "example-issuer.com"
    },
    "name": "Example Bond 2030",
    "precision": 2,
    "ticker": "EXB30",
    "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": 2,
      "valid_until_audit_id": null
    }
  ],
  "mutable": {
    "trading_venues": [],
    "category_tags": [],
    "custom": {}
  },
  "admin": {
    "asset_type": null,
    "featured": false,
    "malicious": false,
    "delisted": true,
    "admin_notes": "Policy review",
    "last_admin_action": {
      "action": "force_delist_asset",
      "field": null,
      "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
      "timestamp": "2026-11-03T22:47:34.833055Z"
    }
  },
  "icon": {
    "href": "/v2/assets/6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e/icon/dca810c9461b2a35e584a5e41474f373c7c73ca8e2821fdec9058489964025cf.png"
  },
  "status": "active",
  "created_at": "2026-11-03T22:47:34.287665Z",
  "updated_at": "2026-11-03T22:47:34.838800Z"
}
Error: admin lacks the permission: 403 (local instance · application/json)
{
  "error": "forbidden",
  "message": "admin permission is required: delist_assets"
}

Force-relist an asset

POST /v2/admin/assets/:asset_id/actions ADMIN-SIGNED · operation force_relist_asset

Clears admin.delisted. Needs delist_assets.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"force_relist_asset"
asset_id requiredbody · hex (64)Must match the URL.
reasonbody · string≤ 4096 chars.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.
🚧

Delist and relist overwrite admin_notes with their reason; earlier notes are lost.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","asset_id":"6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e","nonce":"e55a6eab-0b3a-4cd6-bca8-c8d00e50201e","operation":"force_relist_asset","reason":"Review resolved","signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.851000Z"}'
SIG='xE0TkvjqTNDAc3+w+nI98zZfFZ1l5AhA43LpR4TMEbsH4YbfuKwj9kW9DjkAR6Smp3Amicj6aJ/j6QM7wwNoqQ=='

curl -X POST "https://assets.blockstream.info/v2/admin/assets/6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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 = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e";
const PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  asset_id: ASSET_ID,
  nonce: randomUUID(),
  operation: "force_relist_asset",
  reason: "Review resolved",
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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 = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "asset_id": ASSET_ID,
    "nonce": str(uuid.uuid4()),
    "operation": "force_relist_asset",
    "reason": "Review resolved",
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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 := "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
	endpoint := baseURL + "/v2/admin/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["asset_id"] = assetID
	action["nonce"] = newNonce()
	action["operation"] = "force_relist_asset"
	action["reason"] = "Review resolved"
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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)
{
  "asset_id": "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
  "contract": {
    "entity": {
      "domain": "example-issuer.com"
    },
    "name": "Example Bond 2030",
    "precision": 2,
    "ticker": "EXB30",
    "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": 2,
      "valid_until_audit_id": null
    }
  ],
  "mutable": {
    "trading_venues": [],
    "category_tags": [],
    "custom": {}
  },
  "admin": {
    "asset_type": null,
    "featured": false,
    "malicious": false,
    "delisted": false,
    "admin_notes": "Review resolved",
    "last_admin_action": {
      "action": "force_relist_asset",
      "field": null,
      "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
      "timestamp": "2026-11-03T22:47:34.852169Z"
    }
  },
  "icon": {
    "href": "/v2/assets/6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e/icon/dca810c9461b2a35e584a5e41474f373c7c73ca8e2821fdec9058489964025cf.png"
  },
  "status": "active",
  "created_at": "2026-11-03T22:47:34.287665Z",
  "updated_at": "2026-11-03T22:47:34.858123Z"
}

Icon review

List pending icons

POST /v2/admin/icon-proposals/search ADMIN-SIGNED · operation list_pending_icon_proposals

Pending proposals across all assets, including the Base64 PNG to review. Needs review_icons.

Parameters

NameIn · typeDescription
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-query-v1"
actor_pubkey requiredbody · pubkeyYour admin key.
operation requiredbody · string"list_pending_icon_proposals"
timestamp requiredbody · RFC 3339Within ±5 min.
pagebody · integerDefault 1.
page_sizebody · integer1–50. Default 20.
orderbody · asc | descDefault asc (oldest first).

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","operation":"list_pending_icon_proposals","order":"asc","page":1,"page_size":20,"signing_context":"liquid-asset-registry-admin-query-v1","timestamp":"2026-11-03T22:47:34.634000Z"}'
SIG='3bElMfh/Idli7tLvx+MIntvG/N7CehQOAqF9ihGGVYh/S2d3J4mSQS38CExThKqK7WpRaHWjGvFdS34R9sT3xA=='

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

const BASE_URL = "https://assets.blockstream.info";
const PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const query = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  operation: "list_pending_icon_proposals",
  order: "asc",
  page: 1,
  page_size: 20,
  signing_context: "liquid-asset-registry-admin-query-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(query); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/icon-proposals/search`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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
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"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

query = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "operation": "list_pending_icon_proposals",
    "order": "asc",
    "page": 1,
    "page_size": 20,
    "signing_context": "liquid-asset-registry-admin-query-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(query)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/icon-proposals/search",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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/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"
	endpoint := baseURL + "/v2/admin/icon-proposals/search"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	query := map[string]any{}
	query["actor_pubkey"] = publicKeyOf(privateKey)
	query["operation"] = "list_pending_icon_proposals"
	query["order"] = "asc"
	query["page"] = 1
	query["page_size"] = 20
	query["signing_context"] = "liquid-asset-registry-admin-query-v1"
	query["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	payload := canonicalJSON(query) // 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-Admin-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())
}
Response: 200 (local instance · application/json)
{
  "items": [
    {
      "proposal_id": "352faf04-ab7c-47d5-98db-405e7431ce57",
      "asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
      "icon_hash": "4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225",
      "icon": "iVBORw0KGgoAAAANSUhEUgAAAfQAAAH0CAYA…",
      "proposed_at": "2026-11-03T22:47:34.580380Z"
    }
  ],
  "page": 1,
  "page_size": 20,
  "total_count": 1,
  "total_pages": 1
}

Approve an icon

POST /v2/admin/assets/:asset_id/actions ADMIN-SIGNED · operation approve_icon

Publishes a pending proposal. Needs review_icons.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"approve_icon"
asset_id requiredbody · hex (64)Must match the URL.
icon_hash requiredbody · hex (64)The pending proposal's hash.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","icon_hash":"4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225","nonce":"3749ba3c-248f-43ea-a507-435b556449ae","operation":"approve_icon","signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.651000Z"}'
SIG='xkm8mAg7PekMud6b6Y22MGLx1GqYXPoxm3gNxEfB7MoOLpT1iEr6edJBz0muFb3ZlrMPeQ+reGzq2IVoP7ZyXQ=='

curl -X POST "https://assets.blockstream.info/v2/admin/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  asset_id: ASSET_ID,
  icon_hash: "4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225",
  nonce: randomUUID(),
  operation: "approve_icon",
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "asset_id": ASSET_ID,
    "icon_hash": "4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225",
    "nonce": str(uuid.uuid4()),
    "operation": "approve_icon",
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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/admin/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["asset_id"] = assetID
	action["icon_hash"] = "4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225"
	action["nonce"] = newNonce()
	action["operation"] = "approve_icon"
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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)
{
  "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": {
      "attestation": "https://example-issuer.com/reserves",
      "series": "A",
      "website": "https://example-issuer.com"
    }
  },
  "admin": {
    "asset_type": null,
    "featured": false,
    "malicious": false,
    "delisted": false,
    "admin_notes": null,
    "last_admin_action": {
      "action": "approve_icon",
      "field": null,
      "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
      "timestamp": "2026-11-03T22:47:34.652804Z"
    }
  },
  "icon": {
    "href": "/v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/icon/4575a54494982204ffa6991b391259e63cf800d954cafbf5eea80984cb73b225.png"
  },
  "status": "active",
  "created_at": "2026-11-03T22:47:34.213879Z",
  "updated_at": "2026-11-03T22:47:34.665823Z"
}

Reject an icon

POST /v2/admin/assets/:asset_id/actions ADMIN-SIGNED · operation reject_icon

Rejects a pending proposal with an optional reason the issuer can read. Needs review_icons.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"reject_icon"
asset_id requiredbody · hex (64)Must match the URL.
icon_hash requiredbody · hex (64)The pending proposal's hash.
reasonbody · string≤ 4096 chars.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","asset_id":"6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e","icon_hash":"babf32e9d81cfb0476dd6642ff3a81e84e0d2d644413cfc15a3a291258d97085","nonce":"491f5d74-422b-49aa-a09f-f12dbd103381","operation":"reject_icon","reason":"Logo must fill the circular canvas","signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.713000Z"}'
SIG='D/dViuy5yyTcHxMISds54aVsOpsz8LyMFMSQUrNM/u5W/3mjwn/Sui+cBUPd5PUDqcyN0B+Yxe/kDUGZptS0Sw=='

curl -X POST "https://assets.blockstream.info/v2/admin/assets/6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e/actions" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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 = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e";
const PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  asset_id: ASSET_ID,
  icon_hash: "babf32e9d81cfb0476dd6642ff3a81e84e0d2d644413cfc15a3a291258d97085",
  nonce: randomUUID(),
  operation: "reject_icon",
  reason: "Logo must fill the circular canvas",
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/admin/assets/${ASSET_ID}/actions`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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 = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "asset_id": ASSET_ID,
    "icon_hash": "babf32e9d81cfb0476dd6642ff3a81e84e0d2d644413cfc15a3a291258d97085",
    "nonce": str(uuid.uuid4()),
    "operation": "reject_icon",
    "reason": "Logo must fill the circular canvas",
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/admin/assets/{ASSET_ID}/actions",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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 := "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
	endpoint := baseURL + "/v2/admin/assets/" + assetID + "/actions"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["asset_id"] = assetID
	action["icon_hash"] = "babf32e9d81cfb0476dd6642ff3a81e84e0d2d644413cfc15a3a291258d97085"
	action["nonce"] = newNonce()
	action["operation"] = "reject_icon"
	action["reason"] = "Logo must fill the circular canvas"
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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)
{
  "asset_id": "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
  "contract": {
    "entity": {
      "domain": "example-issuer.com"
    },
    "name": "Example Bond 2030",
    "precision": 2,
    "ticker": "EXB30",
    "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": 2,
      "valid_until_audit_id": null
    }
  ],
  "mutable": {
    "trading_venues": [],
    "category_tags": [],
    "custom": {}
  },
  "admin": {
    "asset_type": null,
    "featured": false,
    "malicious": false,
    "delisted": false,
    "admin_notes": null,
    "last_admin_action": {
      "action": "reject_icon",
      "field": null,
      "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
      "timestamp": "2026-11-03T22:47:34.714093Z"
    }
  },
  "icon": null,
  "status": "active",
  "created_at": "2026-11-03T22:47:34.287665Z",
  "updated_at": "2026-11-03T22:47:34.721300Z"
}

Upload and publish an icon

PUT /v2/admin/assets/:asset_id/icon ADMIN-SIGNED · operation set_icon

Publishes an icon immediately, without an issuer proposal. Same envelope and image rules as Propose an icon. Needs manage_icons.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the canonical JSON of action only.
action requiredbody · objectAdmin envelope with operation: "set_icon", asset_id, icon_hash.
icon requiredbody · Base64500×500 RGBA PNG, < 75,000 bytes.
🚧

Use a microsecond timestamp, as for icon proposals.

Example request

# The signature covers only the canonical JSON of "action" (see Signing requests)
BODY='{"action":{"signing_context":"liquid-asset-registry-admin-action-v1","actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","nonce":"5dd4ed55-1d61-4837-b548-e09cc5bd2ae8","timestamp":"2026-11-03T22:47:34.733000Z","operation":"set_icon","asset_id":"6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e","icon_hash":"dca810c9461b2a35e584a5e41474f373c7c73ca8e2821fdec9058489964025cf"},"icon":"iVBORw0KGgoAAAANSUhEUgAAAfQAAAH0CAYA……"}'
SIG='nE6zIRuA2CfGv5xc0YjzR1yrTdMUEptO0tlarO3pi6gbF775CC9heKb4EJFNdv49fljy3k6W5B5RtApIuqifog=='

curl -X PUT "https://assets.blockstream.info/v2/admin/assets/6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e/icon" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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";
import { readFile } from "node:fs/promises";

const BASE_URL = "https://assets.blockstream.info";
const ASSET_ID = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e";
const PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const icon = await readFile("icon.png"); // 500x500 RGBA PNG, < 75,000 bytes
const iconHash = createHash("sha256").update(icon).digest("hex");

const action = {
  signing_context: "liquid-asset-registry-admin-action-v1",
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  nonce: randomUUID(),
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
  operation: "set_icon",
  asset_id: ASSET_ID,
  icon_hash: iconHash,
};
// Sign the canonical action; the PNG travels next to it as Base64
const body = JSON.stringify({ action, icon: icon.toString("base64") });

const res = await fetch(`${BASE_URL}/v2/admin/assets/${ASSET_ID}/icon`, {
  method: "PUT",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-Signature": signMessage(canonicalJson(action), 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 = "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

with open("icon.png", "rb") as f:  # 500x500 RGBA PNG, < 75,000 bytes
    icon = f.read()
icon_hash = hashlib.sha256(icon).hexdigest()

action = {
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "nonce": str(uuid.uuid4()),
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
    "operation": "set_icon",
    "asset_id": ASSET_ID,
    "icon_hash": icon_hash,
}
# Sign the canonical action; the PNG travels next to it as Base64
body = json.dumps({"action": action, "icon": base64.b64encode(icon).decode()})
res = requests.put(
    f"{BASE_URL}/v2/admin/assets/{ASSET_ID}/icon",
    data=body,
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-Signature": sign_message(canonical_json(action), 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 := "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e"
	endpoint := baseURL + "/v2/admin/assets/" + assetID + "/icon"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	icon, err := os.ReadFile("icon.png") // 500x500 RGBA PNG, < 75,000 bytes
	if err != nil {
		panic(err)
	}
	sum := sha256.Sum256(icon)
	iconHash := hex.EncodeToString(sum[:])

	action := map[string]any{}
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["nonce"] = newNonce()
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	action["operation"] = "set_icon"
	action["asset_id"] = assetID
	action["icon_hash"] = iconHash
	// Sign the canonical action; the PNG travels next to it as Base64
	sig := signMessage(canonicalJSON(action), privateKey)
	payload := canonicalJSON(map[string]any{"action": action, "icon": base64.StdEncoding.EncodeToString(icon)})
	req, err := http.NewRequest("PUT", endpoint, bytes.NewReader(payload))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Asset-Registry-Admin-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 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)
{
  "asset_id": "6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e",
  "contract": {
    "entity": {
      "domain": "example-issuer.com"
    },
    "name": "Example Bond 2030",
    "precision": 2,
    "ticker": "EXB30",
    "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": 2,
      "valid_until_audit_id": null
    }
  ],
  "mutable": {
    "trading_venues": [],
    "category_tags": [],
    "custom": {}
  },
  "admin": {
    "asset_type": null,
    "featured": false,
    "malicious": false,
    "delisted": false,
    "admin_notes": null,
    "last_admin_action": {
      "action": "set_icon",
      "field": null,
      "admin_id": "ddb0f797-d0f5-458f-a59b-50cae555adb1",
      "timestamp": "2026-11-03T22:47:34.734160Z"
    }
  },
  "icon": {
    "href": "/v2/assets/6bd0b1e0e2c65f3d9f804b7c3e5d9f2e1bac8d6e4f203b5c7d9e1f305b7c9d1e/icon/dca810c9461b2a35e584a5e41474f373c7c73ca8e2821fdec9058489964025cf.png"
  },
  "status": "active",
  "created_at": "2026-11-03T22:47:34.287665Z",
  "updated_at": "2026-11-03T22:47:34.744381Z"
}

Migration

Migrate a legacy asset

POST /v2/assets/:asset_id/migrate ADMIN-SIGNED · operation migrate_asset

Lets a legacy (version 0) asset use v2 issuer actions. Nothing on chain changes and the contract is untouched; the asset's existing issuer key becomes the key that signs v2 actions. Needs migrate_assets.

Parameters

NameIn · typeDescription
asset_id requiredpath · hex (64)Liquid asset ID. Upper-case hex is accepted and normalized.
Asset-Registry-Admin-Signature requiredheader · Base64Signature of the exact request body with the key in actor_pubkey. How to sign.
signing_context requiredbody · string"liquid-asset-registry-admin-action-v1"
actor_pubkey requiredbody · pubkeyYour admin public key; the signature must verify against it.
operation requiredbody · string"migrate_asset"
asset_id requiredbody · hex (64)Must match the URL.
nonce requiredbody · string (8–128)Unique per admin.
timestamp requiredbody · RFC 3339Within ±5 min of server time.
🚧

Legacy assets without a ticker can't be migrated. Migrating twice returns idempotent_retry.

Example request

# Template only: sign a fresh BODY (new nonce, timestamp). See Signing requests.
BODY='{"actor_pubkey":"03649a9f9284761201b6d5f71277792d3595125fd33b834055b9dc1dd8024c06ee","asset_id":"7ce1c2f1f3d7604eaf915c8d4f6ea03f2cbd9e7f50314c6d8eaf2041c6d8ea2f","nonce":"247305ed-49e2-475d-9c11-6aab550fea57","operation":"migrate_asset","signing_context":"liquid-asset-registry-admin-action-v1","timestamp":"2026-11-03T22:47:34.928000Z"}'
SIG='1sCFRQA79/odhYfUtI/l5rgUaHBnTsPOVkTS51HWpmo87cxc0dLLy0P+PmI+P/x8Vrqg7K/uZFKJF8+x0a+gFA=='

curl -X POST "https://assets.blockstream.info/v2/assets/7ce1c2f1f3d7604eaf915c8d4f6ea03f2cbd9e7f50314c6d8eaf2041c6d8ea2f/migrate" \
  -H "Content-Type: application/json" \
  -H "Asset-Registry-Admin-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 = "7ce1c2f1f3d7604eaf915c8d4f6ea03f2cbd9e7f50314c6d8eaf2041c6d8ea2f";
const PRIVATE_KEY = process.env.ADMIN_PRIVATE_KEY; // 32-byte hex: your admin key

const action = {
  actor_pubkey: publicKeyOf(PRIVATE_KEY),
  asset_id: ASSET_ID,
  nonce: randomUUID(),
  operation: "migrate_asset",
  signing_context: "liquid-asset-registry-admin-action-v1",
  timestamp: new Date().toISOString().replace("Z", "000Z"), // microseconds
};
const body = canonicalJson(action); // send exactly the signed bytes

const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/migrate`, {
  method: "POST",
  headers: { "Content-Type": "application/json", "Asset-Registry-Admin-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 = "7ce1c2f1f3d7604eaf915c8d4f6ea03f2cbd9e7f50314c6d8eaf2041c6d8ea2f"
PRIVATE_KEY = os.environ["ADMIN_PRIVATE_KEY"]  # 32-byte hex: your admin 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()

action = {
    "actor_pubkey": public_key_of(PRIVATE_KEY),
    "asset_id": ASSET_ID,
    "nonce": str(uuid.uuid4()),
    "operation": "migrate_asset",
    "signing_context": "liquid-asset-registry-admin-action-v1",
    "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
body = canonical_json(action)  # send exactly the signed bytes
res = requests.post(
    f"{BASE_URL}/v2/assets/{ASSET_ID}/migrate",
    data=body.encode("utf-8"),
    headers={"Content-Type": "application/json", "Asset-Registry-Admin-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 := "7ce1c2f1f3d7604eaf915c8d4f6ea03f2cbd9e7f50314c6d8eaf2041c6d8ea2f"
	endpoint := baseURL + "/v2/assets/" + assetID + "/migrate"
	privateKey := os.Getenv("ADMIN_PRIVATE_KEY") // 32-byte hex: your admin key

	action := map[string]any{}
	action["actor_pubkey"] = publicKeyOf(privateKey)
	action["asset_id"] = assetID
	action["nonce"] = newNonce()
	action["operation"] = "migrate_asset"
	action["signing_context"] = "liquid-asset-registry-admin-action-v1"
	action["timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000Z")
	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-Admin-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 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": 24,
    "server_received_at": "2026-11-03T22:47:34.929740Z",
    "actor": "system",
    "verified_pubkey": null,
    "admin_id": null,
    "action": {
      "asset_id": "7ce1c2f1f3d7604eaf915c8d4f6ea03f2cbd9e7f50314c6d8eaf2041c6d8ea2f",
      "operation": "migrate_contract_metadata",
      "to_contract_version": 0,
      "from_contract_version": 0,
      "initial_issuer_pubkey_source": "migrated_legacy_record",
      "previous_initial_issuer_pubkey_source": "registry_registration"
    },
    "action_hash": "7c5bdec78ab73ffb183801f3321bf1e085910281cad67b71fc59edb6f35df2a2",
    "signature": null
  },
  "asset": null
}
Then: GET …/actions/latest: 200 (local instance · application/json)
{
  "asset_id": "7ce1c2f1f3d7604eaf915c8d4f6ea03f2cbd9e7f50314c6d8eaf2041c6d8ea2f",
  "action_hash": "7c5bdec78ab73ffb183801f3321bf1e085910281cad67b71fc59edb6f35df2a2",
  "audit_id": 24,
  "operation": "migrate_contract_metadata",
  "server_received_at": "2026-11-03T22:47:34.929740Z"
}

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.