// BCMR (Bitcoin Cash Metadata Registry) fetcher + cache. Resolves a // CashTokens category hex to human-readable metadata: name, description, // symbol, decimals, icon URL, and per-NFT metadata when the registry // carries it. // // Registries are plain JSON documents (Bitauth "Bitcoin Cash Metadata // Registries v2" schema). We support two ways to reach a registry today: // // 1. HTTPS URL configured per-user in Settings ("registry endpoints"). // The registry publishes a compact JSON with keyed identities; // lookup by category is O(1). // 2. A local name the user sets themselves, stored under // "bcmr/local/" and taking precedence over any registry. // Self-minted tokens — anything on chipnet, anything from a project // that keeps its own names client-side — publish no metadata at all: // no registry entry, and often not even an OP_RETURN in the genesis // transaction, so there is nowhere for a wallet to look. A local label // is the only thing that can name those. // // (An earlier comment here promised a bundled static fallback for well-known // tokens. There is no such directory and never was; it is not a load path.) // // Cache is on-disk via api.storage under "bcmr/" = // { snapshot, fetchedAt, source }. A metadata refresh runs at most once // per REFRESH_MIN_MS per category to keep the panel snappy on repaint. // No signature verification yet (BCMR v2 spec allows authchain-anchored // signing; adding that is a follow-up once we support arbitrary chain // script parsing). const REFRESH_MIN_MS = 6 * 60 * 60 * 1000; // 6 hours // Well-known registries seeded on first run so a fresh wallet doesn't need // any configuration to see names for the top BCH tokens. Users can add / // remove entries in Settings. // Both of the previous defaults were dead — checked 2026-09-29: // raw.githubusercontent.com/cashonize/registry/main/bcmr.json -> 404 // bcmr.salemkode.com/registry.json -> DNS fail // So NO token ever resolved a name, on any chain, and the panel's // .catch(() => {}) meant the failure was completely silent. Anything added // here should be re-checked rather than trusted; a registry that 404s is // indistinguishable from a token nobody has registered. const DEFAULT_REGISTRIES = [ { id: "otr", label: "OpenTokenRegistry", url: "https://otr.cash/.well-known/bitcoin-cash-metadata-registry.json" }, ]; module.exports = function makeBcmr({ storage, log = () => {} }) { function registryList() { // Filtered on read as well: a list saved before setRegistries enforced // https still carries its http entries. const custom = storage.get("bcmr/registries", null); const clean = Array.isArray(custom) ? custom.filter((r) => r && typeof r.url === "string" && /^https:\/\//i.test(r.url)) : []; if (clean.length) return clean; return DEFAULT_REGISTRIES.slice(); } function setRegistries(list) { // https only: a registry decides what a token is called on the approval // path, and plain http lets anyone on the network rewrite that. const clean = Array.isArray(list) ? list.filter((r) => r && typeof r.url === "string" && /^https:\/\//i.test(r.url)) : []; storage.set("bcmr/registries", clean); } // Registry lookup: index-into-registry by category. BCMR v2 stores // identities keyed by category id (hex). Each identity has a history // array; the newest history[0] entry is the current snapshot. function pickIdentity(regJson, categoryHex) { const identities = regJson?.identities || {}; const identity = identities[categoryHex]; if (!identity) return null; // History is a { : snapshot } map. Newest wins by ISO // string sort — the schema recommends ISO 8601 timestamps and both // registries above emit them, so lexicographic sort matches temporal // sort for anything after 1000 AD. const entries = Object.entries(identity); if (!entries.length) return null; entries.sort((a, b) => (b[0] > a[0] ? 1 : -1)); const [, snap] = entries[0]; return snap; } async function fetchRegistry(url) { const r = await fetch(url, { cache: "no-store" }); if (!r.ok) throw new Error(`bcmr: HTTP ${r.status} from ${url}`); return r.json(); } // Attempt every configured registry in parallel; first identity found // wins. When two registries carry a category, we prefer the one earlier // in the list (user-configured order = priority). async function lookup(categoryHex) { const registries = registryList(); if (!registries.length) return null; // Try cache first. const cached = storage.get(`bcmr/${categoryHex}`, null); if (cached && Date.now() - (cached.fetchedAt || 0) < REFRESH_MIN_MS) return cached; const attempts = await Promise.all(registries.map(async (reg) => { try { const json = await fetchRegistry(reg.url); const identity = pickIdentity(json, categoryHex); return identity ? { identity, source: reg.label || reg.id, url: reg.url } : null; } catch (e) { log(`bcmr: registry "${reg.label || reg.url}" failed:`, e?.message || e); return null; } })); const hit = attempts.find((a) => a); if (!hit) { // Negative cache with a short TTL so a missing category doesn't // hammer every registry on every wallet refresh. const miss = { snapshot: null, fetchedAt: Date.now(), source: null, url: null }; storage.set(`bcmr/${categoryHex}`, miss); return miss; } const entry = { snapshot: hit.identity, fetchedAt: Date.now(), source: hit.source, url: hit.url, }; storage.set(`bcmr/${categoryHex}`, entry); return entry; } // Batch lookup — returns { : cacheEntry }. Reuses individual // lookup() which handles per-category caching + negative caching. async function lookupMany(categoryHexes) { const out = {}; await Promise.all(categoryHexes.map(async (cat) => { try { out[cat] = await lookup(cat); } catch (e) { out[cat] = { snapshot: null, error: e?.message || String(e) }; } })); return out; } // Read-only cached lookup — never hits network. Used for the panel's // synchronous render path so tokens draw immediately with whatever's // in the cache; the async lookup() runs in the background afterwards. function cached(categoryHex) { return storage.get(`bcmr/${categoryHex}`, null); } // ---- local names --------------------------------------------------------- // A name the user typed for a category no registry knows about. Kept // separate from the BCMR cache so a later registry fetch cannot clobber it, // and so clearing it falls back to whatever the registry says. function localName(categoryHex) { const v = storage.get(`bcmr/local/${categoryHex}`, null); return v && (v.name || v.symbol) ? v : null; } function setLocalName(categoryHex, { name, symbol, decimals } = {}) { const key = `bcmr/local/${categoryHex}`; const n = String(name || "").trim().slice(0, 40); const s = String(symbol || "").trim().slice(0, 12); const d = Number(decimals); if (!n && !s) { storage.set(key, null); return null; } const rec = { name: n || null, symbol: s || null }; if (Number.isFinite(d) && d >= 0 && d <= 18) rec.decimals = Math.floor(d); storage.set(key, rec); return rec; } // Compact metadata slice the panel wants: { name, symbol, description, // decimals, iconUri }. Handles both the top-level identity fields and // the token subobject (BCMR v2 puts token-specific data there). // // A local name wins over the registry: the user typed it for this exact // category, which is better evidence than a third-party document. // Control, bidi-override, zero-width and BOM characters, plus the soft // hyphen, the Arabic letter mark, the Mongolian vowel separator, the // line/paragraph separators and the Unicode tag block. Built from code // points so no invisible character has to live in this source file. const rng = (a, b) => String.fromCodePoint(a) + "-" + String.fromCodePoint(b); const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0xad, 0xad) + rng(0x61c, 0x61c) + rng(0x180e, 0x180e) + rng(0x200b, 0x200f) + rng(0x2028, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + rng(0xe0000, 0xe007f) + "]", "gu"); function cleanText(v, max) { if (typeof v !== "string") return null; const s = v.replace(INVISIBLE, "").trim().slice(0, max); return s || null; } function cleanIcon(v) { if (typeof v !== "string" || v.length > 512) return null; return /^(https:\/\/|ipfs:\/\/)[^\s"'<>]+$/i.test(v) ? v : null; } function metadataOf(entry, categoryHex) { const local = categoryHex ? localName(categoryHex) : null; if (!entry || !entry.snapshot) { return local ? { name: local.name, symbol: local.symbol, description: null, decimals: Number.isFinite(local.decimals) ? local.decimals : 0, iconUri: null, source: "local", local: true } : null; } const s = entry.snapshot; const t = s.token || {}; // Registry content is third-party and unauthenticated (no authchain // check), so everything is bounded before it reaches the panel: text is // stripped of control / bidi / zero-width characters and capped, decimals // must be a whole number BCMR allows, and an icon is only ever an https // or ipfs URL — never data:, javascript: or a plain-http tracker. const regDecimals = Number(t.decimals); return { name: local?.name || cleanText(s.name || t.name, 40), symbol: local?.symbol || cleanText(s.token?.symbol || s.symbol, 12), description: cleanText(s.description, 280), decimals: Number.isFinite(local?.decimals) ? local.decimals : (Number.isInteger(regDecimals) && regDecimals >= 0 && regDecimals <= 18 ? regDecimals : 0), // Icon URIs live under s.uris.icon per schema; older files use s.icon. iconUri: cleanIcon(s.uris?.icon || s.icon), source: local ? "local" : (entry.source || null), local: !!local, }; } return { lookup, lookupMany, cached, metadataOf, localName, setLocalName, registryList, setRegistries, DEFAULT_REGISTRIES }; };