feat(bcmr): Sia-backed primary URL — ADR-0057 step 2 fully shipped

Server-side mirror hook on bns-gateway landed, so the "primary vs mirror"
split from the previous commit collapses: the primary URL is now itself
Sia-backed via the BNS gateway. One URL in the OP_RETURN, zero HTTPS
single-point-of-failure, every genesis's metadata survives a silentmode.st
outage because any bns-gateway (Theseus, Ariadne, self-hosted) resolves
the same bytes from Sia and the on-chain SHA-256 confirms integrity.

Server (deployed to silentmode:/opt/bns-gateway/public-gateway.mjs,
service restarted + active):
- NEW /api/bcmr-upload/<network>/<categoryId>.json handler modelled on
  the existing /api/icon-upload sibling. POST only, 32 KB cap, strict
  path validation (chipnet|mainnet + 64 lowercase hex), parse-and-
  discard JSON validity check, PUT to Sia at
  bns/deviant.x/bcmr/<network>/<categoryId>.json.
- Returns { url, sha256, bytes, path }. No auth: on-chain sha256 is the
  trust root, so a wrong-hash upload is simply ignored by every reader.
- Backup written: public-gateway.mjs.bak-bcmr-<timestamp>.

End-to-end verified (browser → gateway → Sia → gateway → browser):
- POST 200, Sia PUT success, response carries the public URL
- GET from the public URL returns 200, 483 bytes
- Client SHA-256 == response.sha256 == fetched-bytes SHA-256
- Negative paths: wrong network/short/upper-case categoryId → 400,
  GET → 405, invalid JSON → 400 with parser detail

Client (design/potidaea/src/bcmr.ts + genesis.ts):
- bcmrPublicUrl now returns
  https://navigate.st/bns/deviant.x/bcmr/<network>/<categoryId>.json —
  the Sia-backed primary. Replaces the never-working silentmode.st/bcmr
  URL that was 404'ing in production.
- bcmrSiaUrl kept as an alias of bcmrPublicUrl so step 2's earlier
  opt-in callers still compile.
- Genesis emitter: dropped the VITE_BCMR_SIA_MIRROR flag — the primary
  is Sia-backed now, no gating needed. Encoder stays variadic so future
  mirrors (IPFS pin, alternate gateway) can be added without API churn.

ADR-0057 updated: step 2 marked SHIPPED with the gateway + Sia +
end-to-end verification notes.

Potidaea tsc clean.

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

View file

@ -164,35 +164,30 @@ export function bcmrContentHash(bcmr: BcmrRegistry): Uint8Array {
}
// ── 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/',
};
//
// ADR-0057 step 2 is now LIVE on the gateway (POST → bns-gateway at
// `/api/bcmr-upload/<network>/<categoryId>.json` writes to Sia bucket
// `bns/deviant.x/bcmr/<network>/<categoryId>.json`; the same path is served
// at `navigate.st/bns/deviant.x/bcmr/<network>/<categoryId>.json` through
// the BNS gateway, which reads it back from Sia). The primary URL below IS
// the Sia-backed one — no mirror flag needed; the gateway + Sia storage are
// the resilience layer. If silentmode.st goes dark, any public bns-gateway
// (Theseus, Ariadne, a self-hosted one) still resolves the same bytes from
// Sia, and the on-chain sha256 anchor confirms integrity.
/** 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 as the
* PRIMARY URL. */
* 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';
return `https://navigate.st/bns/deviant.x/bcmr/${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.
*/
/** Legacy alias of `bcmrPublicUrl`. The ADR-0057 step 2 "primary + Sia
* mirror" distinction collapsed once the primary itself became Sia-backed
* (the gateway stores to Sia on upload). Kept as a no-op alias so any
* caller that opted-in to the mirror URL still compiles. Prefer
* `bcmrPublicUrl` for new code. */
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`;
return bcmrPublicUrl(categoryId, net);
}
// ── OP_RETURN encoder ──────────────────────────────────────────────────────

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, bcmrSiaUrl, bcmrOpReturnChunks } from './bcmr';
import { buildBcmrJson, serializeBcmr, bcmrPublicUrl, bcmrOpReturnChunks } from './bcmr';
import { uploadBcmr } from './bcmr-upload';
const ART = artifact as unknown as Artifact;
@ -60,20 +60,17 @@ async function buildAndUploadBcmr(
dvmbExtension: args.dvmbExtension,
});
const canonical = serializeBcmr(json);
const primaryUrl = bcmrPublicUrl(categoryId, net);
// ADR-0057 step 2 — the primary URL is now Sia-backed via the BNS gateway
// (`navigate.st/bns/deviant.x/bcmr/<network>/...`). The gateway's
// /api/bcmr-upload handler writes to the Sia bucket and the same path
// reads back from it, so a silentmode.st outage no longer takes the
// metadata with it — any bns-gateway (Theseus, Ariadne, self-hosted)
// resolves the same bytes. Encoder stays variadic so additional mirrors
// (IPFS pin, alternate gateway) can be added later without API churn.
const url = 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); }
// 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);
return bcmrOpReturnChunks(json, url);
}
function hexToBin(h: string): Uint8Array {

View file

@ -3291,18 +3291,23 @@ same minting NFT.
### Deferred to follow-on sessions
**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 2 — Sia-backed BCMR primary URL.** ✅ **SHIPPED.** The bns-gateway at
`silentmode:/opt/bns-gateway/public-gateway.mjs` now handles
`POST /api/bcmr-upload/<network>/<categoryId>.json` — body must be valid
UTF-8 JSON ≤32 KB, path strictly validated — and PUTs the bytes to Sia at
`bns/deviant.x/bcmr/<network>/<categoryId>.json`. The same path reads back
through the BNS gateway at
`https://navigate.st/bns/deviant.x/bcmr/<network>/<categoryId>.json`,
which is now the ONE URL the client emits into the on-chain anchor. End-to-end
round-trip verified: client SHA-256 == response SHA-256 == fetched-bytes
SHA-256. The "primary + mirror" distinction collapsed once the primary
itself became Sia-backed: silentmode.st could go dark and any bns-gateway
(Theseus, Ariadne, self-hosted) still resolves the same bytes from the Sia
network, and the on-chain hash anchor confirms integrity. `bcmrSiaUrl()`
remains as an alias of `bcmrPublicUrl()` so earlier callers still compile;
the opt-in `VITE_BCMR_SIA_MIRROR` flag is no longer needed and was dropped.
Encoder stays variadic (`bcmrOpReturnChunks(bcmr, ...urls)`) so additional
mirrors (IPFS pin, alternate gateway) can be added later without API churn.
**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