Register an asset
Registration publishes the contract of an asset that's already issued. The contract hash is committed on chain at issuance, so build the contract first.
-
Build the contract
{ "entity": { "domain": "example-issuer.com" }, "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e", "name": "Example Dollar", "precision": 8, "ticker": "EXUSD", "version": 2 }Field Rule (version 2) entity.domainLower-case ASCII/punycode, at least two labels. The domain must authorize the asset (step 3). initial_issuer_pubkeyCompressed secp256k1 key; it signs your future updates. name1–255 ASCII characters, not only whitespace. precision0–8. ticker3–24 of A–Z a–z 0–9 . -. Unique per domain among active assets.versionMust be 2. Unknown fields are rejected.issuer_pubkeyisn't allowed in version 2 contracts (422extra_forbidden): the key goes only ininitial_issuer_pubkey. -
Compute the contract hash and issue
The contract hash is
SHA256(canonical JSON of the contract), byte-reversed, in hex. Pass it to Elementsissueasset. For the contract above it'sd0d6126a9574aaf930fead73d0859da1c807cc0756ae6ca77392fd14418c6f9e.// Node.js 18+ (ES module). canonicalJson() is in Signing requests. import { createHash } from "node:crypto"; const contract = {"entity":{"domain":"example-issuer.com"},"initial_issuer_pubkey":"03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e","name":"Example Dollar","precision":8,"ticker":"EXUSD","version":2}; const contractHash = createHash("sha256").update(canonicalJson(contract)).digest().reverse().toString("hex"); console.log(contractHash);import hashlib import json contract = { "entity": { "domain": "example-issuer.com" }, "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e", "name": "Example Dollar", "precision": 8, "ticker": "EXUSD", "version": 2 } canonical = json.dumps(contract, sort_keys=True, separators=(",", ":"), ensure_ascii=False) print(hashlib.sha256(canonical.encode("utf-8")).digest()[::-1].hex()) # byte-reversedpackage main import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "slices" ) func main() { contract := map[string]any{} contract["entity"] = map[string]any{"domain": "example-issuer.com"} contract["initial_issuer_pubkey"] = "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e" contract["name"] = "Example Dollar" contract["precision"] = 8 contract["ticker"] = "EXUSD" contract["version"] = 2 var buf bytes.Buffer enc := json.NewEncoder(&buf) // canonical: sorted keys, no whitespace, no HTML escaping enc.SetEscapeHTML(false) if err := enc.Encode(contract); err != nil { panic(err) } sum := sha256.Sum256(bytes.TrimRight(buf.Bytes(), "\n")) slices.Reverse(sum[:]) // byte-reversed fmt.Println(hex.EncodeToString(sum[:])) }# issueasset <asset_amount> <token_amount> <blind> <contract_hash> elements-cli issueasset 1000 1 false d0d6126a9574aaf930fead73d0859da1c807cc0756ae6ca77392fd14418c6f9eLegacy (version 0) contracts use the same hash, and POST /contract/validate checks them for you (version 0 only). LWK can't issue version 2 contracts; use Elements
issueassetas above. -
Publish a domain proof
An HTTP file, a DNS TXT record, or a pubkey-bound proof. Domain verification.
-
Register
POST /v2/assets with the asset ID, the exact contract, the proof method and optional initial metadata. You get 201 and the full asset.
The contract you register must be byte-for-byte the one you hashed: same fields, same values. Otherwise chain verification fails with
chain_verification_failed.
Domain verification
The registry checks that whoever controls contract.entity.domain authorized the registration. There are three ways to prove it.
| HTTP file | DNS TXT | Pubkey-bound (v2) | |
|---|---|---|---|
| Covers | One asset | One asset | Every asset whose contract uses the key |
| Published at | https://<domain>/.well-known/liquid-asset-proof-<asset_id> | TXT on <domain> | Either location |
| Content | Authorize linking the domain name <domain> to the Liquid asset <asset_id> | liquid-asset-verification=<asset_id>,<ticker> | context=liquid-asset-registry-v2,pubkey=<key> |
| Also needs | — | — | Signature header over the contract |
domain_verification_method | http (default) | dns | http or dns |
HTTP file
URL: https://example-issuer.com/.well-known/liquid-asset-proof-5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d
Body: Authorize linking the domain name example-issuer.com to the Liquid asset 5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d- HTTPS only (plain HTTP only for
.onion). Redirects aren't followed. - The domain must resolve to a public IP. Max 10 KB; trailing whitespace is ignored.
DNS TXT
example-issuer.com. TXT "liquid-asset-verification=5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d,EXUSD"- On the domain itself, not a subdomain. Legacy contracts without a ticker keep the trailing comma.
- Checked over DNS-over-HTTPS (Google by default); allow for propagation.
Pubkey-bound proof
Publish once (DNS is the reusable form; the HTTP path still contains the asset ID):
example-issuer.com. TXT "context=liquid-asset-registry-v2,pubkey=03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e"Then sign each registration's normalized contract (canonical JSON, nulls removed) and send it in Asset-Registry-Signature. Endpoint and code. The proof isn't tied to a network: it works on mainnet and testnet.
| Error | Cause |
|---|---|
401 Asset-Registry-Signature header is required for pubkey-bound domain proof | Proof found, header missing |
400 …pubkey does not match the registration initial issuer pubkey | Proof delegates to another key |
401 …must cover the normalized contract JSON, not the registration request body | You signed the whole body |
400 domain_verification_failed | Proof missing, wrong or malformed |
The registry checks the proof once, when it accepts the registration. The audit log records the acceptance, not an independent proof of what the domain served at that moment.
Libraries that register assets
Libraries that issue assets and register them today build a version 0 contract and send it to the legacy POST /. That keeps working. To let users register through v2, and later update their metadata, add a second path for new issuances:
| Legacy registration (today) | v2 registration (to add) | |
|---|---|---|
| Contract version | version: 0 | version: 2 |
| Issuer key field | issuer_pubkey | initial_issuer_pubkey (required). issuer_pubkey isn't allowed in version 2 contracts (422 extra_forbidden): the key goes only in initial_issuer_pubkey. |
| Contract hash | Same algorithm: sha256(canonical JSON), byte-reversed. The JSON differs, so the hash differs: choose the version before issuing. | |
| Endpoint | POST / | POST /v2/assets |
The same asset, as a legacy and as a v2 registration request:
{
"asset_id": "7ce1c2f1f3d7604eaf915c8d4f6ea03f2cbd9e7f50314c6d8eaf2041c6d8ea2f",
"contract": {
"entity": {
"domain": "legacy-issuer.com"
},
"issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
"name": "Example Token",
"precision": 0,
"ticker": "EXTKN",
"version": 0
},
"domain_verification_method": "http"
}{
"asset_id": "5ac9a0f9d1b54e2c8e7f3a6b2d4c8e1f0a9b7c5d3e1f2a4b6c8d0e2f4a6b8c0d",
"contract": {
"entity": {
"domain": "example-issuer.com"
},
"initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
"name": "Example Dollar",
"precision": 8,
"ticker": "EXUSD",
"version": 2
},
"domain_verification_method": "dns"
}Steps for a library
- Contract model: add version 2 with
initial_issuer_pubkeyand the version 2 field rules. Keep version 0 for users who still want legacy registration. - Hashing: reuse your canonical JSON and contract-hash code; check it against the worked example (
d0d6126a9574aaf930fead73d0859da1c807cc0756ae6ca77392fd14418c6f9e). - Registration call: send version 2 contracts to
POST /v2/assetsand version 0 toPOST /. Parse JSON errors; treat 503 as retryable, and don't blindly retry a timeout (registration isn't idempotent, so look the asset up first). - Issuer key: make
initial_issuer_pubkeya key your users can sign with later (for example, from a known derivation path in the wallet), since it now controls every update. Persist which key it was. - Domain proof (optional): support the pubkey-bound proof. The library already holds the issuer key, so it can sign the contract itself, and users publish one DNS record per domain instead of one per asset.
- Updates (optional): expose the issuer actions. They need canonical JSON plus a Bitcoin Signed Message signature (Signing requests). Category tags come from a list registry admins define and can add to, change or remove from: today
stablecoin,bond,fixed-income,tokenized. Don't hard-code it, and surface a 422 for an unknown tag to the user. - Reading: if you read
GET /:asset_idorGET /v2/assets/:asset_id, accept version 2 contracts and treatissuance_txinandissuance_prevoutas optional. They arenullfor some v2 assets, so if you re-derive the asset ID from them, show the metadata as unverified instead of failing. - Test on testnet:
https://assets-testnet.blockstream.inforuns the same service, with real chain and domain checks.
LWK
Liquid Wallet Kit 0.20 builds version 0 contracts only. Its Contract serializes to the registry's canonical form, so the hash it commits on chain is the one the registry verifies. Register those assets through the legacy endpoint; for version 2, issue with Elements issueasset (Register an asset).
LWK's registry client can't read v2 assets yet. It reads GET /:asset_id and requires a version 0 contract with issuer_pubkey, so wallets that use it get no metadata for assets registered through v2.
Assets already issued with a version 0 contract can't be re-registered as version 2: the contract hash on chain wouldn't match. They stay legacy, and reach v2 features through migration.
Updated about 4 hours ago