147 lines
6.1 KiB
JavaScript
147 lines
6.1 KiB
JavaScript
|
|
// 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/<categoryHex>" =
|
||
|
|
// { 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 { <timestamp>: 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 { <categoryHex>: 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 };
|
||
|
|
};
|