Signing requests

Reads are public. Every write is authorized by a secp256k1 signature over the exact request body: there are no API keys, and the registry never holds your private key.

Who signs what

RequestKeyHeaderSigned bytes
Issuer actions, issuer queriesAsset's current issuer keyAsset-Registry-SignatureThe request body (canonical JSON)
Icon proposalCurrent issuer keyAsset-Registry-SignatureCanonical JSON of the action member
Registration, pubkey-bound proofContract's initial_issuer_pubkeyAsset-Registry-SignatureCanonical JSON of contract
Admin actions and queriesAdmin key in actor_pubkeyAsset-Registry-Admin-SignatureThe request body (canonical JSON)
Legacy DELETECurrent issuer keynone (body field)remove <asset_id> from registry

How to sign

  1. Build the JSON object.
  2. Serialize it as canonical JSON: object keys sorted recursively, no whitespace, UTF-8, no NaN/Infinity. Use integers or strings for numbers: non-integer numbers (1.5, 1e-7) must be formatted exactly as Python does (1e-07), which JavaScript doesn't, so the registry rejects them as non_canonical_payload.
  3. Sign the bytes in Bitcoin Signed Message format: ECDSA over SHA256(SHA256(0x18 ‖ "Bitcoin Signed Message:\n" ‖ varint(len) ‖ message)).
  4. Base64-encode the 64-byte compact signature R‖S (a 65-byte recoverable signature is also accepted).
  5. Send the same bytes as the body, with the signature in the header.

A real signed request (local instance, throwaway key):

POST /v2/assets/5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d/actions
Content-Type: application/json
Asset-Registry-Signature: Fa1R9CBMVbDLBcltLo1IRkF87XT4pUDgE68SzhcJ6XJoJzv4zyt0yE2Ka+PYXFR83Ab6bX6Frv9aEMDeXDjksg==

{"asset_id":"5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d","category_tags":["stablecoin","tokenized"],"mutable_schema_version":1,"nonce":"391bad7c-f582-436e-a026-29eea333bff9","operation":"replace_category_tags","prev_action_hash":"3c19a3bef43921f4ce0dfa865da2c08f153e9285fcef31db4f54b97455f89547","signing_context":"liquid-asset-registry-action-v1","timestamp":"2026-11-03T22:47:34.323000Z"}
🚧

Use microsecond timestamps, e.g. 2026-10-16T08:59:44.123000Z (JavaScript: new Date().toISOString().replace("Z", "000Z")). Icon uploads fail with millisecond timestamps, and microseconds let anyone re-verify your signatures from the audit log.

Envelope fields

Signed actions carry replay protection. The server rejects anything stale, reused or out of order:

  • signing_context: fixed per request type, so a signature can't be reused elsewhere.
  • nonce: unique per asset (issuer) or per admin. Reusing it with identical content is a safe retry; with different content it's a 409.
  • timestamp: within ±5 minutes of server time, and not older than your previous action.
  • prev_action_hash (issuer actions): chains each action to the previous one. The action chain.

Signing code

Using TypeScript? The SDK builds and signs requests for you, so you don't need this code.

The helpers below are a reference implementation of the steps above, tested against the registry; every signed endpoint's examples use them. Any secp256k1 ECDSA library works. JavaScript needs only Node.js's built-in crypto. Python uses cryptography and Go uses github.com/decred/dcrd/dcrec/secp256k1/v4. Blockstream does not maintain these libraries. The install lines pin the versions we tested (50.0.2 and v4.4.1).

import { createECDH, createHash, createPrivateKey, sign } from "node:crypto";

// --- 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 cryptography==50.0.2
import base64
import hashlib
import json

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

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()
// go get github.com/decred/dcrd/dcrec/secp256k1/[email protected]
// Helper file: add it to your main package (rename the package) or import it.
package registrysign

import (
	"bytes"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"

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

// 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())
}

Common mistakes

  • Sending a re-serialized body. Most HTTP clients re-serialize objects. Send the exact string you signed.
  • Go's JSON escaping. encoding/json escapes &, < and > (e.g. in URLs). Use an encoder with SetEscapeHTML(false).
  • High-S signatures from your own signer. The registry accepts them, but many verifiers don't. Normalize to low-S.

Keys

Generate a key with the TypeScript SDK, or use one you already have: any secp256k1 key works, from a wallet, HSM or KMS. The public key goes in your contract (initial_issuer_pubkey); keep the private key secret.

import { AssetRegistryClient } from "liquid-asset-registry-sdk";

const { privateKey, pubkey } = await AssetRegistryClient.generateKeyPair();
console.log("public key:", pubkey); // compressed, 66 hex chars
// privateKey is 32 bytes: store it in your secrets manager, never log it

The SDK also accepts any external signer, so keys can stay in an HSM or a remote signing service.

🚧

Keep private keys server-side. Never put an issuer or admin key in front-end code.

TypeScript SDK

The repository ships a TypeScript SDK for Node.js 18.18+ and modern browsers: typed clients for v2, legacy and admin endpoints, canonical JSON and signing helpers, retries, and pluggable signers.

🚧

The SDK isn't on npm yet (liquid-asset-registry-sdk returns 404). Build it from the repository.

git clone https://github.com/Blockstream/asset_registry_v2
cd asset_registry_v2/sdk
npm ci && npm run build
npm pack            # then: npm install /path/to/liquid-asset-registry-sdk-0.1.0.tgz
import { AssetRegistryClient, AdminClient, Signer } from "liquid-asset-registry-sdk";

const registry = new AssetRegistryClient({
  baseUrl: "https://assets.blockstream.info",
  issuerPrivateKey: process.env.ISSUER_PRIVATE_KEY, // or signer: any { signData(), getPubkey() }
});

const asset = await registry.v2.get("aa909f1b77451e409fe95fe1d3638ad017ab3325c6d4f00301af6d582d0f2034");
await registry.v2.replaceCategoryTags(assetId, ["stablecoin"]);
await registry.v2.setCustomField(assetId, "isin", "US0000000001");
await registry.v2.rotateIssuerKey(assetId, newPubkey);
// Icons: pass a microsecond timestamp (the default fails; see Known issues)
await registry.v2.proposeIcon(assetId, pngBytes, { timestamp: new Date().toISOString().replace("Z", "000Z") });

const audit = await registry.v2.getAssetAudit(assetId, { order: "asc" });
const { privateKey, pubkey } = await AssetRegistryClient.generateKeyPair();
  • Issuer helpers fetch prev_action_hash, fill nonce and timestamp, sign once and reuse the same body on retries.
  • External signers: implement signData(canonicalJson): Promise<base64>; the repo has a remote-signer example (sdk/examples/remote-signer).
  • registry.v2.register() isn't retried (registration isn't idempotent).

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

© 2026 Liquid Network
All rights reserved.

Feedback and Requests

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

Simplicity Forum

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

Liquid Dev Community

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