Assets listed but every row read as a hex string. Three separate reasons.
**Both default registries were dead.** Checked 2026-09-29:
raw.githubusercontent.com/cashonize/registry/main/bcmr.json returns 404, and
bcmr.salemkode.com does not resolve. So no token resolved a name on any
chain, mainnet included — and the panel's `.catch(() => {})` meant the
failure was completely silent, indistinguishable from a token nobody has
registered. Replaced with OpenTokenRegistry, which answers and carries
chipnet identities. A dead registry is now reported instead of swallowed:
the reply says whether any registry answered, and the hint says so.
**The tokens in question publish no metadata at all.** Not a wallet problem
and not fixable by any registry list: their genesis transactions carry no
OP_RETURN whatsoever, so there is no BCMR authchain to follow and no
registry entry to find. Their names exist only inside the app that minted
them. So a token can now be named locally, per category, stored under
bcmr/local/<cat> and taking precedence over any registry — with a YOURS tag
so a self-assigned name is never mistaken for a published one. Clearing both
fields removes it and lets a registry entry show through again. Optional
decimals, because a raw fungible amount with no scale is its own kind of
wrong: 15000 became "150 GMX" once told there were two.
**The unnamed row printed the category twice**, once as the name fallback and
once as the sub-line. Unnamed rows now show the category as the identity and
"unnamed token · N UTXOs" beneath it.
The header comment also promised a bundled static registry fallback for
well-known tokens. There is no such directory and no load path for one; the
comment is gone rather than left to mislead.
Verified against the real module: local names beat registry entries, a
category with only a local name resolves instead of returning null, clearing
restores the registry value, out-of-range decimals are dropped, and the new
default registry resolves a real chipnet identity (OTRC, 6 decimals). In the
panel: naming a token repaints it with the tag and rescales the amount, and a
non-numeric decimals entry is refused with the modal left open.
191 lines
8.4 KiB
JavaScript
191 lines
8.4 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) {
|
|
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.
|
|
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 || {};
|
|
return {
|
|
name: local?.name || s.name || t.name || null,
|
|
symbol: local?.symbol || s.token?.symbol || s.symbol || null,
|
|
description: s.description || null,
|
|
decimals: Number.isFinite(local?.decimals) ? local.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: local ? "local" : (entry.source || null),
|
|
local: !!local,
|
|
};
|
|
}
|
|
|
|
return { lookup, lookupMany, cached, metadataOf, localName, setLocalName,
|
|
registryList, setRegistries, DEFAULT_REGISTRIES };
|
|
};
|