Start of the next batch; 0.30.0 is published.
- Tron panel sends signed whatever /wallet/createtransaction returned while
the approval showed the local request. The returned bytes are now decoded
and must be one transfer from this wallet, to that address, for that
amount, with a matching txID - checked at plan time and again at signing.
- Importing a Solana wallet from a seed phrase threw on every attempt (a
mis-parenthesised `new require("crypto").createHmac` plus a bare require
of an ESM-only subpath). SLIP-0010 now uses Node's HMAC, as chain-sol does.
- Imported BCH wallets put token-bearing UTXOs into coin selection. They are
excluded, as in the HD wallet, and the balance counts what can be spent.
- WizardConnect dropped the token from each spent output before signing, so
under SIGHASH_UTXOS every signature of a token transaction was invalid.
- A wallet disposed while a refresh was in flight re-armed its poll timer.
- BCMR registry content is bounded before it reaches the panel: control and
bidi characters stripped, lengths capped, decimals 0-18, icons https/ipfs
only, registries https only.
212 lines
9.7 KiB
JavaScript
212 lines
9.7 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() {
|
|
const custom = storage.get("bcmr/registries", null);
|
|
if (Array.isArray(custom) && custom.length) return custom;
|
|
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. Built from code
|
|
// points so no invisible character has to live in this source file.
|
|
const rng = (a, b) => String.fromCharCode(a) + "-" + String.fromCharCode(b);
|
|
const INVISIBLE = new RegExp("[" + rng(0x00, 0x1f) + rng(0x7f, 0x9f) + rng(0x200b, 0x200f) + rng(0x202a, 0x202e) + rng(0x2060, 0x2069) + rng(0xfeff, 0xfeff) + "]", "g");
|
|
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 };
|
|
};
|