Admin Endpoints
Registry operators: admins and permissions, moderation, icon review and migration. Signed with admin keys.
| Network | Base URL |
|---|---|
| Liquid | https://assets.blockstream.info/v2 |
| Liquid Testnet | https://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
| Method | Endpoint | Path |
|---|---|---|
| POST | Add an admin | /v2/admin/actions |
| POST | Update admin permissions | /v2/admin/actions |
| POST | Rename an admin | /v2/admin/actions |
| POST | Remove an admin | /v2/admin/actions |
| PUT | Update annotations | /v2/admin/assets/:asset_id/annotations |
| POST | Force-delist an asset | /v2/admin/assets/:asset_id/actions |
| POST | Force-relist an asset | /v2/admin/assets/:asset_id/actions |
| POST | List pending icons | /v2/admin/icon-proposals/search |
| POST | Approve an icon | /v2/admin/assets/:asset_id/actions |
| POST | Reject an icon | /v2/admin/assets/:asset_id/actions |
| PUT | Upload and publish an icon | /v2/admin/assets/:asset_id/icon |
| POST | Migrate a legacy asset | /v2/assets/:asset_id/migrate |
Admin permissions
| Permission | Allows |
|---|---|
root | Everything, including granting root |
manage_admins | Add, update and remove non-root admins |
annotate_assets | Update annotations |
delist_assets | Force-delist and relist |
review_icons | List pending icons, approve, reject |
manage_icons | Upload and publish icons |
migrate_assets | Migrate 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
| Name | In · type | Description |
|---|---|---|
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "add_admin" |
admin_pubkey required | body · pubkey | The new admin's key. |
friendly_name required | body · string | 1–255 chars. |
permissions required | body · array | See permissions. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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
| Name | In · type | Description |
|---|---|---|
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "update_admin_permissions" |
admin_pubkey required | body · pubkey | Admin to update. |
permissions required | body · array | New list. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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
| Name | In · type | Description |
|---|---|---|
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "update_admin_name" |
admin_pubkey required | body · pubkey | Admin to update. |
friendly_name required | body · string | 1–255 chars. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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
| Name | In · type | Description |
|---|---|---|
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "remove_admin" |
admin_pubkey required | body · pubkey | Admin to remove. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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
| Name | In · type | Description |
|---|---|---|
asset_id required | path · hex (64) | Liquid asset ID. Upper-case hex is accepted and normalized. |
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "update_admin_annotations" |
asset_id required | body · hex (64) | Must match the URL. |
changes required | body · object | Any of featured, malicious, delisted (booleans), asset_type, admin_notes (≤ 4096 chars). |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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
| Name | In · type | Description |
|---|---|---|
asset_id required | path · hex (64) | Liquid asset ID. Upper-case hex is accepted and normalized. |
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "force_delist_asset" |
asset_id required | body · hex (64) | Must match the URL. |
reason | body · string | ≤ 4096 chars. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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
| Name | In · type | Description |
|---|---|---|
asset_id required | path · hex (64) | Liquid asset ID. Upper-case hex is accepted and normalized. |
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "force_relist_asset" |
asset_id required | body · hex (64) | Must match the URL. |
reason | body · string | ≤ 4096 chars. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±5 min of server time. |
Delist and relist overwrite
admin_noteswith theirreason; 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
| Name | In · type | Description |
|---|---|---|
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-query-v1" |
actor_pubkey required | body · pubkey | Your admin key. |
operation required | body · string | "list_pending_icon_proposals" |
timestamp required | body · RFC 3339 | Within ±5 min. |
page | body · integer | Default 1. |
page_size | body · integer | 1–50. Default 20. |
order | body · asc | desc | Default 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
| Name | In · type | Description |
|---|---|---|
asset_id required | path · hex (64) | Liquid asset ID. Upper-case hex is accepted and normalized. |
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "approve_icon" |
asset_id required | body · hex (64) | Must match the URL. |
icon_hash required | body · hex (64) | The pending proposal's hash. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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
| Name | In · type | Description |
|---|---|---|
asset_id required | path · hex (64) | Liquid asset ID. Upper-case hex is accepted and normalized. |
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "reject_icon" |
asset_id required | body · hex (64) | Must match the URL. |
icon_hash required | body · hex (64) | The pending proposal's hash. |
reason | body · string | ≤ 4096 chars. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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
| Name | In · type | Description |
|---|---|---|
asset_id required | path · hex (64) | Liquid asset ID. Upper-case hex is accepted and normalized. |
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the canonical JSON of action only. |
action required | body · object | Admin envelope with operation: "set_icon", asset_id, icon_hash. |
icon required | body · Base64 | 500×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
| Name | In · type | Description |
|---|---|---|
asset_id required | path · hex (64) | Liquid asset ID. Upper-case hex is accepted and normalized. |
Asset-Registry-Admin-Signature required | header · Base64 | Signature of the exact request body with the key in actor_pubkey. How to sign. |
signing_context required | body · string | "liquid-asset-registry-admin-action-v1" |
actor_pubkey required | body · pubkey | Your admin public key; the signature must verify against it. |
operation required | body · string | "migrate_asset" |
asset_id required | body · hex (64) | Must match the URL. |
nonce required | body · string (8–128) | Unique per admin. |
timestamp required | body · RFC 3339 | Within ±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"
}Updated about 4 hours ago