feat(bcmr): variadic multi-URL + Sia mirror helper (ADR-0057 step 2)

Encoder changes to prepare the BCMR OP_RETURN to carry two URLs so a
silentmode.st outage no longer takes our token metadata with it. Client
side is ready; the server-side Sia mirror (POST to /api/bcmr-upload
also writing the Sia bucket bns/bcmr/<network>/) still TODO — the opt-
in flag below keeps current deploys unchanged until that lands.

- design/potidaea/src/bcmr.ts
  • bcmrOpReturnChunks(bcmr, ...urls): now variadic. Returns [marker,
    hashHex, ...urls]. Throws when called with zero URLs.
  • bcmrSiaUrl(categoryId, net): forward-compatible Sia-mirror URL
    (https://navigate.st/bns/bcmr.deviant.x/<network>/<catId>.json).
    Resolves whichever bytes the gateway's s3 record returns from the
    Sia bucket. Harmless to emit now: a wallet that fetches before the
    mirror is live sees a 404 and falls through to the primary URL —
    the ADR-0057 step 3 consumer we just shipped handles multi-URL
    fallback cleanly.

- design/potidaea/src/genesis.ts
  • Reads VITE_BCMR_SIA_MIRROR=1 at build time; when set, pushes BOTH
    the primary and the Sia URL into the OP_RETURN. Default off so no
    existing deploy's behaviour changes.

- wiki/decisions.md
  • ADR-0057 "Deferred" section updated: step 2 marked client-ready;
    server-side mirror hook called out as the remaining task.

Runtime sanity-check with the real encoder:
  single-url: [BCMR, 0x…, primaryUrl]                      (3 chunks)
  two-url:    [BCMR, 0x…, primaryUrl, siaUrl]              (4 chunks)
  three-url:  variadic works end-to-end
  zero-url:   correctly refused with explanatory message

Potidaea tsc clean. No regressions in callers (bcmrOpReturnChunks's
old (bcmr, url) call site is a strict subset of the new (bcmr, ...urls)
signature).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Local Dev 2026-10-22 21:30:55 +03:00
parent 6f9794693f
commit 8bd292d3a1
3 changed files with 60 additions and 13 deletions

View file

@ -171,11 +171,30 @@ const BCMR_BASE_URL: Record<Network, string> = {
};
/** 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. */
* Kept stable per network — this is what goes into the OP_RETURN as the
* PRIMARY URL. */
export function bcmrPublicUrl(categoryId: string, net: Network = currentNetwork()): string {
return BCMR_BASE_URL[net] + categoryId.toLowerCase() + '.json';
}
/**
* ADR-0057 step 2 — Sia-mirror URL. The BNS gateway resolves `bcmr.deviant.x`
* via an `s3` record pointing at the Sia bucket `bns/bcmr/<network>/`. Any
* wallet with Theseus or Ariadne hits the same bytes via a different host +
* storage backend, so a silentmode.st outage no longer takes the metadata
* with it.
*
* Forward-compatible: adding this URL to the on-chain push today is harmless
* even before the server-side mirror lands. Wallets try URLs in order; the
* primary resolves first in the happy path, and the Sia URL becomes active
* the moment the mirror writes bytes whose SHA-256 matches the on-chain
* anchor. A wallet seeing an inactive Sia URL simply moves to the next.
*/
export function bcmrSiaUrl(categoryId: string, net: Network = currentNetwork()): string {
const sub = net === 'chipnet' ? 'chipnet/' : '';
return `https://navigate.st/bns/bcmr.deviant.x/${sub}${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
@ -199,11 +218,21 @@ function bytesToHex(b: Uint8Array): string {
}
/** Produce the chunk array to hand to `tb.addOpReturnOutput()`. Pair it with
* the JSON bytes from `serializeBcmr()` — the caller uploads those to the
* the JSON bytes from `serializeBcmr()` — the caller uploads those to each
* 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] {
* that fetch any URL right away find the content.
*
* Variadic in `urls` per ADR-0057 step 2: CHIP-2023-04 allows multiple URL
* pushes and wallets try them in order. Supply the primary first and any
* mirrors after. At least one URL is required.
*
* Returns `[marker, hashHex, ...urls]`. All string chunks are UTF-8 pushed
* except the hash (hex-prefixed → raw bytes). */
export function bcmrOpReturnChunks(bcmr: BcmrRegistry, ...urls: string[]): string[] {
if (urls.length === 0) {
throw new Error('bcmrOpReturnChunks: at least one URL is required');
}
const hash = bcmrContentHash(bcmr);
return ['BCMR', '0x' + bytesToHex(hash), url];
return ['BCMR', '0x' + bytesToHex(hash), ...urls];
}

View file

@ -27,7 +27,7 @@ import type { Signer } from './signer-store';
import type { KnownMba } from './orbital';
import { fanoutBroadcast } from './broadcast';
import { cashscriptNetwork, currentNetwork } from './network';
import { buildBcmrJson, serializeBcmr, bcmrPublicUrl, bcmrOpReturnChunks } from './bcmr';
import { buildBcmrJson, serializeBcmr, bcmrPublicUrl, bcmrSiaUrl, bcmrOpReturnChunks } from './bcmr';
import { uploadBcmr } from './bcmr-upload';
const ART = artifact as unknown as Artifact;
@ -49,7 +49,7 @@ const DEFAULT_RESERVOIR_SUPPLY = 10n ** 18n; // just under CashTokens' 2^63-1
async function buildAndUploadBcmr(
categoryId: string,
args: { ticker: string; name?: string; iconUrl?: string; dvmbExtension?: Record<string, unknown> },
): Promise<[string, string, string] | null> {
): Promise<string[] | null> {
if (!args.ticker) return null;
const net = currentNetwork();
const json = buildBcmrJson({
@ -60,10 +60,20 @@ async function buildAndUploadBcmr(
dvmbExtension: args.dvmbExtension,
});
const canonical = serializeBcmr(json);
const url = bcmrPublicUrl(categoryId, net);
const primaryUrl = bcmrPublicUrl(categoryId, net);
try { await uploadBcmr(categoryId, net, canonical); }
catch (e) { console.warn('[bcmr] upload failed (on-chain OP_RETURN still committed):', e instanceof Error ? e.message : e); }
return bcmrOpReturnChunks(json, url);
// ADR-0057 step 2 — emit the Sia-mirror URL alongside the primary when the
// mirror is enabled. Opt-in via VITE_BCMR_SIA_MIRROR so existing deploys
// don't start pushing an unresolvable URL until the server-side mirror is
// writing bytes whose SHA-256 matches the on-chain anchor. Any wallet
// reading the OP_RETURN tries URLs in order (CHIP-2023-04) so this is
// forward-compatible: a wallet that fetches the Sia URL before the mirror
// is live simply falls through to the primary.
const siaEnabled = (import.meta as unknown as { env?: Record<string, string | undefined> }).env?.VITE_BCMR_SIA_MIRROR === '1';
const urls = siaEnabled ? [primaryUrl, bcmrSiaUrl(categoryId, net)] : [primaryUrl];
return bcmrOpReturnChunks(json, ...urls);
}
function hexToBin(h: string): Uint8Array {

View file

@ -3291,10 +3291,18 @@ same minting NFT.
### Deferred to follow-on sessions
**Step 2 — Sia mirror in the BCMR OP_RETURN.** CHIP-2023-04 allows multiple URL
pushes; wallets try them in order. Upload the same canonical BCMR JSON to Sia
at `s3://bns/bcmr/<network>/<categoryId>.json` and emit both URLs on-chain.
~90 extra bytes on-chain, zero ongoing cost, host-outage resilience.
**Step 2 — Sia mirror in the BCMR OP_RETURN.** ⚠ **Client-side ready; server-side
mirror still TODO.** Encoder now accepts multiple URLs variadically
(`bcmrOpReturnChunks(bcmr, ...urls)`) and `bcmrSiaUrl(categoryId, net)` yields
the forward-compatible BNS-gateway URL
(`https://navigate.st/bns/bcmr.deviant.x/<network>/<categoryId>.json`). The
genesis emitter emits both URLs when `VITE_BCMR_SIA_MIRROR=1` is set at build;
default (unset) keeps the current single-URL behaviour so no live deploy flips
behaviour unexpectedly. Still owed: a server-side hook that mirrors every POST
to `/api/bcmr-upload/<network>/<categoryId>.json` into Sia bucket
`bns/bcmr/<network>/<categoryId>.json`, keyed so the SHA-256 of the stored
bytes matches the on-chain anchor. Once that mirror writes, flip the env flag
and every new genesis commits both URLs on-chain.
**Step 3 — BCMR consumer in `shells/app`.** Biggest UX win. Scan each unknown
token's genesis tx for the BCMR OP_RETURN → fetch the referenced JSON (fall