// 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. Static bundled fallback (registries/) for a handful of well-known // tokens (Cauldron, Fex.cash, TapSwap, ParyonUSD). Ships in the // addon so brand-new users see names on the first launch even // before they configure a live registry. // // 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. const DEFAULT_REGISTRIES = [ { id: "cashonize", label: "Cashonize registry", url: "https://raw.githubusercontent.com/cashonize/registry/main/bcmr.json" }, { id: "salemkode", label: "SalemKode registry", url: "https://bcmr.salemkode.com/registry.json" }, ]; module.exports = function makeBcmr({ storage, log = () => {} }) { function registryList() { const custom = storage.get("bcmr/registries", null); if (Array.isArray(custom) && custom.length) return custom; return DEFAULT_REGISTRIES.slice(); } function setRegistries(list) { 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); } // 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). function metadataOf(entry) { if (!entry || !entry.snapshot) return null; const s = entry.snapshot; const t = s.token || {}; return { name: s.name || t.name || null, symbol: s.token?.symbol || s.symbol || null, description: s.description || null, decimals: Number.isFinite(Number(t.decimals)) ? Number(t.decimals) : 0, // Icon URIs live under s.uris.icon per schema; older files use s.icon. iconUri: s.uris?.icon || s.icon || null, source: entry.source || null, }; } return { lookup, lookupMany, cached, metadataOf, registryList, setRegistries, DEFAULT_REGISTRIES }; };