Audit registry changes

Verify the audit trail

Anyone can check that the registry hasn't dropped, reordered or altered an asset's history: recompute the hashes, follow the chain, rebuild the key history and verify every issuer signature.

What each entry proves

EntryCheck
register, legacy_registercontract_hash = reversed sha256(canonicalJson(contract)); sets the first key (initial_issuer_pubkey, or the legacy issuer_pubkey)
Issuer actions (replace_*, set_/delete_custom_field, propose_icon, rotate_issuer_pubkey, deregister)action_hash = sha256(canonicalJson(action)); prev_action_hash = previous chained hash; signature over canonicalJson(action) by the then-current key
legacy_deregistersignature over action.message (remove <asset_id> from registry)
migrate_contract_metadataChained (action_hash); no signature (system entry)
Admin entriesNot chained; signed admin bodies. verified_pubkey is the admin key

Rebuild the key history from the log itself (deregistered assets have no GET /v2/assets response): start with the registration's key, and switch to new_issuer_pubkey after each rotate_issuer_pubkey entry (that entry is signed by the old key). A re-registration starts over.

Complete verifier

Reads the whole log and checks everything above. It points at a real v2 asset on testnet; set BASE_URL and ASSET_ID for yours.

// Node.js 18+ (ES module: save as .mjs or set "type": "module")
// Verifies one asset's audit trail: action hashes, the prev_action_hash chain,
// the issuer key history (rebuilt from the log) and every issuer signature.
import { ECDH, createHash, createPublicKey, verify } from "node:crypto";

const BASE_URL = "https://assets-testnet.blockstream.info";
const ASSET_ID = "a20caa0e4bff1a884d46e8b65571294569e041e8eee72104afc2269582d37853"; // a v2 asset with issuer actions

// 1. Read the whole log, oldest first (the cursor only moves forward).
const items = [];
for (let since = 0; ; ) {
  const res = await fetch(`${BASE_URL}/v2/assets/${ASSET_ID}/audit?order=asc&limit=1000&since_audit_id=${since}`);
  if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
  const page = await res.json();
  items.push(...page.items);
  if (page.items.length < 1000 || page.next_since_audit_id == null) break;
  since = page.next_since_audit_id;
}

const sha256 = (s) => createHash("sha256").update(s).digest();
const contractHash = (c) => Buffer.from(sha256(canonicalJson(c))).reverse().toString("hex");
let prev = null, key = null;
const problems = [];
const stats = { entries: items.length, hashes: 0, links: 0, signatures: 0, contracts: 0 };

for (const e of items) {
  const a = e.action;
  // 2. Registration entries: the contract hash must match the registered contract.
  const contract = a.contract && a.contract_hash ? a.contract : a.request && a.request.contract;
  if (contract && a.contract_hash) {
    if (contractHash(contract) !== a.contract_hash) problems.push(`${e.audit_id}: contract_hash mismatch`);
    stats.contracts++;
  }
  if (a.operation === "register") key = a.initial_issuer_pubkey;
  if (a.request && a.request.contract) key = a.request.issuer_pubkey || a.request.contract.issuer_pubkey; // legacy_register

  // 3. Hash and chain (issuer-chain entries only).
  if (e.action_hash) {
    if (sha256(canonicalJson(a)).toString("hex") !== e.action_hash) problems.push(`${e.audit_id}: action_hash mismatch`);
    if ("prev_action_hash" in a && a.prev_action_hash !== prev) problems.push(`${e.audit_id}: broken chain`);
    prev = e.action_hash;
    stats.hashes++; stats.links += "prev_action_hash" in a ? 1 : 0;
  }

  // 4. Issuer signatures, made by the key that was current at that point.
  if (e.actor === "issuer" && e.signature) {
    if (e.verified_pubkey !== key) problems.push(`${e.audit_id}: signed by ${e.verified_pubkey}, current key was ${key}`);
    const candidates = a.message ? [a.message] : storedForms(a).map(canonicalJson);
    if (!candidates.some((m) => verifyMessage(m, e.signature, e.verified_pubkey))) problems.push(`${e.audit_id}: bad signature`);
    else stats.signatures++;
    if (a.operation === "rotate_issuer_pubkey") key = a.new_issuer_pubkey; // valid for later entries
  }
}
console.log(stats);
console.log(problems.length ? problems : "audit trail verified");

// The log stores a normalized copy: optional fields may be null and timestamps have
// microseconds. Try the stored form, then without nulls, then with millisecond timestamps.
function storedForms(a) {
  const noNulls = Object.fromEntries(Object.entries(a).filter(([, v]) => v !== null));
  const ms = (o) => ({ ...o, timestamp: String(o.timestamp).replace(/(\.\d{3})000Z$/, "$1Z") });
  return [a, noNulls, ms(a), ms(noNulls)];
}

// --- helpers (see Signing requests) ---
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);
}

// Verifies a Bitcoin Signed Message signature (Base64, 64 or 65 bytes) against a compressed public key.
function verifyMessage(message, signatureB64, pubkeyHex) {
  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 = sha256(Buffer.concat([varint(magic.length), magic, varint(msg.length), msg]));
  let sig = Buffer.from(signatureB64, "base64");
  if (sig.length === 65) sig = sig.subarray(1);
  const pub = ECDH.convertKey(pubkeyHex, "secp256k1", "hex", "buffer", "uncompressed");
  const key = createPublicKey({ format: "jwk", key: { kty: "EC", crv: "secp256k1",
    x: pub.subarray(1, 33).toString("base64url"), y: pub.subarray(33).toString("base64url") } });
  return verify("sha256", firstHash, { key, dsaEncoding: "ieee-p1363" }, sig);
}
🚧

The log stores a normalized copy of each action (microsecond timestamps, added null fields), so the verifier tries each form for signatures. Hashes and chain links verify as stored.

📘

Assets whose custom values were written as non-integer numbers (1.0, 1e-7) can't be re-hashed byte-for-byte in JavaScript, which loses the original number formatting; the verifier reports them as mismatches. Python's json reproduces them.

📘

On mainnet every asset is legacy today: its log has only the imported legacy_register entry (no hashes or signatures), so the only check is the contract hash.


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.