feat(bcmr): CHIP-2023-04 metadata registry client for MBAs

Two new modules the Potidaea client uses to emit BCMR sidecars alongside
every MBA genesis + Katalogos publish. CHIP-2023-04 is the interop standard
every general-purpose BCH wallet already reads (Cauldron, Selene, Zapit,
Bitcoin.com, Cashonize, Paytaca); emitting it means our MBAs show up with
correct ticker/name/icon in those wallets without any coordination.

bcmr.ts
  - BcmrRegistry JSON schema (v2 subset — tokens, uris.icon, extensions)
  - buildBcmrJson(): canonical identity payload per MBA, with our DVMB
    template params stashed in extensions.deviant.dvmb so our own stack
    can cross-check the covenant identity through both channels
  - canonicalize() + serializeBcmr(): JCS-style ordered serializer so
    the content hash is reproducible across publishers
  - bcmrContentHash(): single SHA-256 of canonical bytes (per CHIP spec)
  - bcmrPublicUrl(): per-network URL at silentmode.st/bcmr/
  - bcmrOpReturnChunks(): the [magic, hash, url] tuple cashscript's
    tb.addOpReturnOutput() takes directly

bcmr-upload.ts
  - uploadBcmr(): POST canonical bytes to navigate.st/api/bcmr-upload/.
    Mirrors the icon-upload contract (content-addressed by categoryId,
    gateway-side shape check, idempotent). Best-effort — the on-chain
    OP_RETURN commits the URL+hash regardless of upload success, so a
    later retry of the same canonical bytes is a no-op.

Wire format verified against the CHIP: OP_RETURN <'BCMR'> <sha256(json)>
<url>. Single SHA-256 (not double). URLs utf-8, multiple pushes allowed.

The genesis.ts / publish.ts integration hooks that CALL these modules are
tangled with the broadcast-fanout + network-awareness work from this
session (same lines, same imports) and will land together in a follow-up
commit. The two modules here are self-contained and build cleanly as-is.

Server-side bundle (bcmr-gateway.mjs sidecar + systemd unit + nginx snippet)
lives in the session scratchpad for deployment to silentmode.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-10-08 22:29:45 +02:00
parent 11e83e2501
commit f7ec956d6e
2 changed files with 286 additions and 0 deletions

View file

@ -0,0 +1,77 @@
// BCMR JSON upload — POST canonical bytes to the navigate.st gateway.
//
// Mirrors the icon-upload.ts contract: content is keyed by a stable path so
// re-uploads (e.g. later BCMR revisions) overwrite themselves at the same URL.
// Unlike icon-upload the key is the categoryId (32-byte hex) — a wallet
// knows the URL from the on-chain BCMR OP_RETURN + the network, so the
// upload path must match `bcmrPublicUrl()`.
//
// Client → server contract:
// POST /api/bcmr-upload/<network>/<categoryId>.json
// Content-Type: application/json
// body: canonical JSON bytes (must hash to the value in the BCMR OP_RETURN)
// → { url, bytes, sha256 }
// The gateway verifies:
// - categoryId is 64 lowercase hex
// - network is 'chipnet' or 'mainnet'
// - body ≤ 32 KB
// - body is valid UTF-8 JSON
// (No signature check — the on-chain BCMR OP_RETURN is the trust root; a
// wrong-hash body will simply be ignored by every wallet.)
import type { Network } from './network';
const UPLOAD_ENDPOINT = 'https://navigate.st/api/bcmr-upload';
const MAX_BYTES = 32 * 1024;
export class BcmrUploadError extends Error {
constructor(message: string, readonly code:
| 'too-large' | 'server-refused' | 'network-error' | 'invalid',
) {
super(message);
this.name = 'BcmrUploadError';
}
}
export interface BcmrUploadResult {
url: string;
sha256Hex: string;
bytes: number;
}
/** POST the canonical BCMR JSON bytes to the gateway. Returns the final URL
* the wallet should GET — the caller passes this to `bcmrOpReturnChunks()`
* when building the tx. Upload BEFORE broadcast so wallets that fetch the
* URL right after the tx confirms find the content. */
export async function uploadBcmr(
categoryId: string,
network: Network,
canonicalJson: Uint8Array,
): Promise<BcmrUploadResult> {
if (!/^[0-9a-f]{64}$/.test(categoryId.toLowerCase())) {
throw new BcmrUploadError('categoryId must be 64 hex chars', 'invalid');
}
if (canonicalJson.byteLength > MAX_BYTES) {
throw new BcmrUploadError(`json too large: ${canonicalJson.byteLength} > ${MAX_BYTES}`, 'too-large');
}
const url = `${UPLOAD_ENDPOINT}/${network}/${categoryId.toLowerCase()}.json`;
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: canonicalJson,
});
} catch (e) {
throw new BcmrUploadError(
'upload endpoint unreachable: ' + (e instanceof Error ? e.message : String(e)),
'network-error',
);
}
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new BcmrUploadError(`upload refused (${res.status}): ${detail.slice(0, 200)}`, 'server-refused');
}
const body = await res.json() as { url: string; sha256: string; bytes: number };
return { url: body.url, sha256Hex: body.sha256, bytes: body.bytes };
}

209
design/potidaea/src/bcmr.ts Normal file
View file

@ -0,0 +1,209 @@
// BCMR — Bitcoin Cash Metadata Registry emission for MBAs.
//
// CHIP-2023-04 (bitjson/chip-bcmr) is the standard every general-purpose BCH
// wallet reads: Cauldron, Selene, Zapit, Bitcoin.com, Cashonize, Paytaca. Our
// own DVMB OP_RETURN carries strictly more information (template dispatch +
// covenant params) but is bespoke to us. BCMR is the interop layer — we emit
// BOTH sidecars from the same genesis / register tx so external wallets show
// our MBAs by ticker + name + icon without needing our parser.
//
// Wire format (from the CHIP): a second OP_RETURN output alongside the DVMB
// one, shape:
// OP_RETURN <'BCMR'> <sha256(json)> <URL utf-8>
// where the SHA-256 is single (not double), the URL is a UTF-8 push (multiple
// URLs allowed as separate pushes; wallets try them in order). URLs without
// a protocol prefix are assumed HTTPS.
//
// The AUTHCHAIN — how updates work — is the neatest part: whichever tx
// includes the BCMR OP_RETURN is the "authhead". Its output that carries
// forward the authhead identity (in our case, the covenant's reservoir UTXO
// for an MBA, or the registry entry NFT for a KatalogosRegistry publish) is
// the next authchain link. Spending that UTXO with a new BCMR OP_RETURN
// = an update. Nothing outside chain can override; anyone can verify what
// the issuer currently says by tracing the authchain from genesis.
//
// This module ONLY produces the JSON + the OP_RETURN output bytes. The
// caller (genesis.ts / publish.ts) is responsible for (a) uploading the
// JSON to the URL and (b) appending the OP_RETURN output to the tx before
// broadcast.
import { sha256 as laSha256 } from '@bitauth/libauth';
import { currentNetwork, type Network } from './network';
// ── JSON schema (subset of CHIP-2023-04 v2 we emit) ─────────────────────────
// The full schema is much larger — we emit only what wallets actually read
// for a fungible token. Extension fields carry our DVMB template params so
// wallets that DO understand DVMB (i.e., ours) get the covenant identity
// through both channels and can cross-check.
export interface BcmrToken {
category: string; // 64-hex category id (== authbase txid for a genesis-time emission)
symbol: string; // display ticker, e.g. "ASM"
decimals?: number; // integer, defaults to 0 for our MBAs (whole units)
}
export interface BcmrIdentitySnapshot {
name: string;
description?: string;
migrated?: string; // ISO 8601 — set when this snapshot supersedes an earlier one
token: BcmrToken;
uris?: Record<string, string>; // e.g. { icon: 'https://…', web: 'https://…' }
tags?: string[];
extensions?: Record<string, unknown>;
}
export interface BcmrIdentity {
// Per CHIP: identities keyed by ISO 8601 revision timestamp → snapshot.
// The most recent key is the current identity for that authchain.
[revisionTimestamp: string]: BcmrIdentitySnapshot;
}
export interface BcmrRegistry {
$schema?: string;
version: { major: number; minor: number; patch: number };
latestRevision: string; // ISO 8601
registryIdentity: BcmrIdentitySnapshot;
identities: Record<string, BcmrIdentity>; // keyed by authbase txid (= our categoryId at genesis)
license?: string;
extensions?: Record<string, unknown>;
}
// ── Registry identity — describes US, the publisher ─────────────────────────
// Wallet UIs may surface this as "issued via Deviant" alongside per-token
// metadata. Kept constant across all MBAs we publish.
const REGISTRY_NAME = 'Deviant Autoreward Marker registry';
const REGISTRY_DESCRIPTION =
'BCH-native Mathematically Backed Assets (MBAs) — digital reconstruction of sound-money coinage with ' +
'proof-of-work BCH sealed inside the covenant and the covenant bytecode as the on-chain stamp. ' +
'Ticker/name/icon here are the display sidecar for the DVMB OP_RETURN in the same tx.';
const REGISTRY_URIS: Record<string, string> = {
web: 'https://silentmode.st/',
icon: 'https://silentmode.st/apps/deviant/icon.svg',
};
// ── Build the JSON for an MBA at genesis time ───────────────────────────────
export interface BcmrEmissionInputs {
categoryId: string; // 64 hex — the categoryId, == authbase txid at genesis
symbol: string; // 1-8 chars — display ticker
name: string; // ≤32 chars — display name
description?: string; // optional long description
iconUrl?: string; // https:// or ipfs:// — from the icon uploader
webUrl?: string; // optional dApp / info page for THIS asset
decimals?: number; // default 0
/** DVMB template params, stashed in `extensions.deviant.dvmb` so our own
* wallet stack can cross-check the covenant identity — third-party wallets
* ignore it. */
dvmbExtension?: Record<string, unknown>;
}
/** Build the BCMR JSON object for one MBA. Emitted at genesis; a later
* update (e.g., icon rotation) rebuilds it and spends the previous
* authhead UTXO to a new BCMR-carrying output. */
export function buildBcmrJson(inputs: BcmrEmissionInputs): BcmrRegistry {
const now = new Date().toISOString();
const snapshot: BcmrIdentitySnapshot = {
name: inputs.name,
description: inputs.description,
token: {
category: inputs.categoryId.toLowerCase(),
symbol: inputs.symbol,
decimals: inputs.decimals ?? 0,
},
uris: {
...(inputs.iconUrl ? { icon: inputs.iconUrl } : {}),
...(inputs.webUrl ? { web: inputs.webUrl } : {}),
},
extensions: inputs.dvmbExtension ? { deviant: { dvmb: inputs.dvmbExtension } } : undefined,
};
return {
$schema: 'https://cashtokens.org/bcmr-v2.schema.json',
version: { major: 2, minor: 0, patch: 0 },
latestRevision: now,
registryIdentity: {
name: REGISTRY_NAME,
description: REGISTRY_DESCRIPTION,
uris: REGISTRY_URIS,
// The registry has no token of its own; the required token slot in the
// interface is left off for the registry identity (per CHIP, only per-
// authbase identities MUST carry a token block).
} as BcmrIdentitySnapshot,
identities: {
// Keyed by authbase txid — same as our categoryId at genesis.
[inputs.categoryId.toLowerCase()]: { [now]: snapshot },
},
};
}
// ── Deterministic serialization ─────────────────────────────────────────────
// The BCMR OP_RETURN carries a hash of the JSON, so wallets can verify what
// they fetch from the URL matches what the issuer signed. That only works if
// EVERY publisher (and every re-publisher of the same content) produces the
// same bytes. We use a JCS-style ordered serializer — sort object keys
// lexicographically at every depth, no whitespace, standard JSON escaping.
function canonicalize(v: unknown): string {
if (v === null || typeof v !== 'object') return JSON.stringify(v);
if (Array.isArray(v)) return '[' + v.map(canonicalize).join(',') + ']';
const keys = Object.keys(v as Record<string, unknown>).sort();
const inner = keys.map((k) => JSON.stringify(k) + ':' + canonicalize((v as Record<string, unknown>)[k]));
return '{' + inner.join(',') + '}';
}
/** Canonical JSON bytes for `bcmr` — what gets uploaded to the URL AND
* what the BCMR OP_RETURN hash is computed over. */
export function serializeBcmr(bcmr: BcmrRegistry): Uint8Array {
return new TextEncoder().encode(canonicalize(bcmr));
}
/** Single-SHA-256 of the canonical JSON, per CHIP-2023-04. */
export function bcmrContentHash(bcmr: BcmrRegistry): Uint8Array {
return laSha256.hash(serializeBcmr(bcmr));
}
// ── The URL where wallets fetch the JSON ────────────────────────────────────
const BCMR_BASE_URL: Record<Network, string> = {
chipnet: 'https://silentmode.st/bcmr/chipnet/',
mainnet: 'https://silentmode.st/bcmr/',
};
/** The public URL at which a wallet can GET the BCMR JSON for `categoryId`.
* Kept stable per network — this is what goes into the OP_RETURN. */
export function bcmrPublicUrl(categoryId: string, net: Network = currentNetwork()): string {
return BCMR_BASE_URL[net] + categoryId.toLowerCase() + '.json';
}
// ── OP_RETURN encoder ──────────────────────────────────────────────────────
// cashscript's TransactionBuilder.addOpReturnOutput takes an array of chunks
// where a bare string is pushed as UTF-8 bytes and a "0x..." prefixed string
// is pushed as hex bytes. The BCMR CHIP wire shape is:
//
// OP_RETURN <'BCMR'> <sha256(json)> <url utf8>
//
// so the chunks array we return here is exactly:
//
// ['BCMR', '0x' + hash-hex, url-string]
//
// cashscript inserts the right PUSHBYTES / PUSHDATA1 opcode based on each
// chunk's byte length; nothing else to do at the encoder layer.
let hexAlphabet: string | null = null;
function bytesToHex(b: Uint8Array): string {
if (!hexAlphabet) { let s = ''; for (let i = 0; i < 256; i++) s += (i < 16 ? '0' : '') + i.toString(16); hexAlphabet = s; }
let out = '';
for (const x of b) out += hexAlphabet.slice(x * 2, x * 2 + 2);
return out;
}
/** Produce the chunk array to hand to `tb.addOpReturnOutput()`. Pair it with
* the JSON bytes from `serializeBcmr()` — the caller uploads those to the
* URL BEFORE broadcasting the tx that carries this OP_RETURN, so wallets
* that fetch the URL right away find the content. */
export function bcmrOpReturnChunks(bcmr: BcmrRegistry, url: string): [string, string, string] {
const hash = bcmrContentHash(bcmr);
return ['BCMR', '0x' + bytesToHex(hash), url];
}