diff --git a/design/potidaea/src/bcmr-upload.ts b/design/potidaea/src/bcmr-upload.ts new file mode 100644 index 0000000..2ab2b90 --- /dev/null +++ b/design/potidaea/src/bcmr-upload.ts @@ -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//.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 { + 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 }; +} diff --git a/design/potidaea/src/bcmr.ts b/design/potidaea/src/bcmr.ts new file mode 100644 index 0000000..0d04c0b --- /dev/null +++ b/design/potidaea/src/bcmr.ts @@ -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'> +// 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; // e.g. { icon: 'https://…', web: 'https://…' } + tags?: string[]; + extensions?: Record; +} + +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; // keyed by authbase txid (= our categoryId at genesis) + license?: string; + extensions?: Record; +} + +// ── 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 = { + 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; +} + +/** 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).sort(); + const inner = keys.map((k) => JSON.stringify(k) + ':' + canonicalize((v as Record)[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 = { + 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'> +// +// 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]; +} +