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.

  1. Build the contract

    {
      "entity": {
        "domain": "example-issuer.com"
      },
      "initial_issuer_pubkey": "03566ecb682f1a49ccad62a6dbc5b4045d96d65912bbbb177b7121c27c01d91e6e",
      "name": "Example Dollar",
      "precision": 8,
      "ticker": "EXUSD",
      "version": 2
    }
    FieldRule (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_pubkey isn't allowed in version 2 contracts (422 extra_forbidden): the key goes only in initial_issuer_pubkey.
  2. Compute the contract hash and issue

    The contract hash is SHA256(canonical JSON of the contract), byte-reversed, in hex. Pass it to Elements issueasset. For the contract above it's d0d6126a9574aaf930fead73d0859da1c807cc0756ae6ca77392fd14418c6f9e.

    // 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-reversed
    package 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 d0d6126a9574aaf930fead73d0859da1c807cc0756ae6ca77392fd14418c6f9e

    Legacy (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 issueasset as above.

  3. Publish a domain proof

    An HTTP file, a DNS TXT record, or a pubkey-bound proof. Domain verification.

  4. 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 fileDNS TXTPubkey-bound (v2)
CoversOne assetOne assetEvery asset whose contract uses the key
Published athttps://<domain>/.well-known/liquid-asset-proof-<asset_id>TXT on <domain>Either location
ContentAuthorize 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_methodhttp (default)dnshttp 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.

ErrorCause
401 Asset-Registry-Signature header is required for pubkey-bound domain proofProof found, header missing
400 …pubkey does not match the registration initial issuer pubkeyProof delegates to another key
401 …must cover the normalized contract JSON, not the registration request bodyYou signed the whole body
400 domain_verification_failedProof 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 versionversion: 0version: 2
Issuer key fieldissuer_pubkeyinitial_issuer_pubkey (required). issuer_pubkey isn't allowed in version 2 contracts (422 extra_forbidden): the key goes only in initial_issuer_pubkey.
Contract hashSame algorithm: sha256(canonical JSON), byte-reversed. The JSON differs, so the hash differs: choose the version before issuing.
EndpointPOST /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

  1. Contract model: add version 2 with initial_issuer_pubkey and the version 2 field rules. Keep version 0 for users who still want legacy registration.
  2. Hashing: reuse your canonical JSON and contract-hash code; check it against the worked example (d0d6126a9574aaf930fead73d0859da1c807cc0756ae6ca77392fd14418c6f9e).
  3. Registration call: send version 2 contracts to POST /v2/assets and version 0 to POST /. Parse JSON errors; treat 503 as retryable, and don't blindly retry a timeout (registration isn't idempotent, so look the asset up first).
  4. Issuer key: make initial_issuer_pubkey a 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.
  5. 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.
  6. 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.
  7. Reading: if you read GET /:asset_id or GET /v2/assets/:asset_id, accept version 2 contracts and treat issuance_txin and issuance_prevout as optional. They are null for some v2 assets, so if you re-derive the asset ID from them, show the metadata as unverified instead of failing.
  8. Test on testnet: https://assets-testnet.blockstream.info runs 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.


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.