theseus/bundled-addons/aegis/lib/bcmr.js
Local Dev 916a748889 Aegis: sends name their wallet, amounts with spaces are refused, BCMR cleanup
- send, sendToken and consolidate now require the wallet id the panel
  reviewed them for; a missing id used to skip the check, and the Solana
  token send sent none, so a selection change under the PIN pad sent from
  another wallet.
- "1 0" was read as 10: a space inside an amount is now refused.
- A BCMR registry list saved before https was enforced is filtered on read,
  and names lose the soft hyphen, Arabic letter mark, Mongolian vowel
  separator, line/paragraph separators and Unicode tag characters too.
2026-10-04 02:26:15 +02:00

218 lines
10 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. A local name the user sets themselves, stored under
// "bcmr/local/<categoryHex>" 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/<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.
// 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 { <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);
}
// ---- 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 };
};