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:
parent
11e83e2501
commit
f7ec956d6e
2 changed files with 286 additions and 0 deletions
77
design/potidaea/src/bcmr-upload.ts
Normal file
77
design/potidaea/src/bcmr-upload.ts
Normal 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
209
design/potidaea/src/bcmr.ts
Normal 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];
|
||||
}
|
||||
|
||||
Loading…
Add table
Reference in a new issue