feat(aegis): 0.19.0 — three chips: Address, Assets, Certificates

Assets was one list holding two different kinds of thing. A fungible balance
answers "how much do I have"; a non-fungible one answers "which ones do I
hold", and they want different rows — an amount against a symbol, versus a
commitment and a capability. On a real chipnet wallet that meant 46 rows
where 32 of them were only ever going to say "1 NFT".

Split by what each category actually holds, so a category carrying both
appears in both — which is honest, because it really does hold both. Assets
counts categories; Certificates counts individual certificates, since "3"
should mean three things you hold rather than three groups.

Certificate rows carry the commitment, because it is the only thing that
distinguishes two certificates of the same category, and a MINTING or MUTABLE
tag, because "can still issue others" versus "is fixed" is worth seeing
without opening anything. A `none` capability is left unlabelled rather than
adding noise to every row.

"Certificates", not "NFTs": these are membership, licence and record tokens,
and NFT carries collectible-market baggage that misdescribes them. Ids and
data keys stay `nft` — that is the CashTokens protocol field, so it is
protocol name inside and product name on screen.

Two things caught while building it. `draw({})` was overwriting the "no
assets" empty state with an empty string, which is now the common case since
purely-non-fungible categories no longer appear there. And certificate rows
printed the category twice, as the title and again on the right — the right
column now only carries it when the row has a real name to lead with.

Metadata is looked up for every category, not just the fungible ones, so
naming a token also names its certificates.
This commit is contained in:
Local Dev 2026-09-29 00:40:46 +02:00
parent ab1451d6b1
commit 9bb9086ff5
3 changed files with 107 additions and 13 deletions

View file

@ -1,7 +1,7 @@
{ {
"id": "aegis", "id": "aegis",
"name": "Aegis Wallet", "name": "Aegis Wallet",
"version": "0.18.0", "version": "0.19.0",
"category": "plugin", "category": "plugin",
"description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.", "description": "Multi-chain wallet (BCH, BTC, TRX, ETH, SOL, SC, DGB) derived from your Theseus vault. Dapps get window.bitcoincash on .x sites; window.tronWeb / window.tronLink / window.ethereum / window.solana on any https page.",
"author": "Silent Mode", "author": "Silent Mode",

View file

@ -780,9 +780,23 @@
Chips give one shape for every coin and a visible answer either Chips give one shape for every coin and a visible answer either
way. (0.9.2 had already reordered the stack for the same reason — way. (0.9.2 had already reordered the stack for the same reason —
this replaces that workaround.) --> this replaces that workaround.) -->
<!-- 0.19.0: Assets split into Assets and Certificates. A fungible
balance and a non-fungible one answer different questions ("how
much do I have" vs "which ones do I hold") and want different rows
— an amount against a symbol, versus a commitment and a
capability. One list mixing them showed 46 categories where 32
were only ever going to say "1 NFT". Split by what the category
actually holds, so one carrying both appears in both.
"Certificates", not "NFTs", in the UI: these are membership,
licence and record tokens, and NFT carries collectible-market
baggage that misdescribes them. The ids and data keys below stay
`nft` because that is the CashTokens protocol field — protocol
name inside, product name on screen. -->
<div id="rcvViewToggle" class="modetoggle"> <div id="rcvViewToggle" class="modetoggle">
<button data-rcv-view="address" class="on" type="button">Address &amp; QR</button> <button data-rcv-view="address" class="on" type="button">Address</button>
<button data-rcv-view="assets" type="button">Assets <span id="rcvAssetsCount" class="hint"></span></button> <button data-rcv-view="assets" type="button">Assets <span id="rcvAssetsCount" class="hint"></span></button>
<button data-rcv-view="nfts" type="button">Certificates <span id="rcvNftsCount" class="hint"></span></button>
</div> </div>
<div id="rcvAddressPane"> <div id="rcvAddressPane">
<div class="card"> <div class="card">
@ -812,6 +826,13 @@
<div class="hint" id="tokensHint">Tokens held by this wallet.</div> <div class="hint" id="tokensHint">Tokens held by this wallet.</div>
</div> </div>
</div> </div>
<div id="rcvNftsPane" hidden>
<div class="card" id="nftsCard">
<div class="lbl">Certificates <span class="hint" id="nftsCount"></span></div>
<div id="nftsList" class="kv"></div>
<div class="hint" id="nftsHint">Non-fungible CashTokens held by this wallet — one row per certificate.</div>
</div>
</div>
</div><!-- /rcvNormal --> </div><!-- /rcvNormal -->
<!-- Consolidate view (inline, replaces Receive body when active). --> <!-- Consolidate view (inline, replaces Receive body when active). -->
<div id="rcvConsolidate" hidden> <div id="rcvConsolidate" hidden>

View file

@ -2140,15 +2140,20 @@ function paintSendMode() {
// across re-renders and across wallet switches, because someone comparing // across re-renders and across wallet switches, because someone comparing
// token balances between wallets should not be knocked back to the QR on // token balances between wallets should not be knocked back to the QR on
// every click. // every click.
const RCV_VIEWS = ["address", "assets", "nfts"];
let rcvView = "address"; let rcvView = "address";
try { rcvView = localStorage.getItem("aegis/rcvView") === "assets" ? "assets" : "address"; } catch (_e) {} try {
const saved = localStorage.getItem("aegis/rcvView");
if (RCV_VIEWS.includes(saved)) rcvView = saved;
} catch (_e) {}
function paintRcvView() { function paintRcvView() {
const addr = $("rcvAddressPane"), assets = $("rcvAssetsPane"); const addr = $("rcvAddressPane"), assets = $("rcvAssetsPane"), nfts = $("rcvNftsPane");
if (!addr || !assets) return; if (!addr || !assets || !nfts) return;
document.querySelectorAll("[data-rcv-view]").forEach((b) => document.querySelectorAll("[data-rcv-view]").forEach((b) =>
b.classList.toggle("on", b.dataset.rcvView === rcvView)); b.classList.toggle("on", b.dataset.rcvView === rcvView));
addr.hidden = rcvView !== "address"; addr.hidden = rcvView !== "address";
assets.hidden = rcvView !== "assets"; assets.hidden = rcvView !== "assets";
nfts.hidden = rcvView !== "nfts";
// The QR's backing store is sized from its rendered box, so one drawn // The QR's backing store is sized from its rendered box, so one drawn
// while its pane was hidden comes out blank. Redraw on reveal — and note // while its pane was hidden comes out blank. Redraw on reveal — and note
// renderReceive only redraws when the address CHANGES, so switching back // renderReceive only redraws when the address CHANGES, so switching back
@ -3397,6 +3402,63 @@ function setAssetsCount(n) {
if (cnt) cnt.textContent = ""; if (cnt) cnt.textContent = "";
} }
} }
// Certificates chip. Counts individual certificates, not categories — "3"
// should mean three things you hold, which is the question the chip is
// answering. A chain with no non-fungible concept lands here with 0 and says
// so, rather than the chip vanishing on some coins and not others.
function setCertsCount(n) {
const chip = $("rcvNftsCount");
if (chip) chip.textContent = n ? String(n) : "";
const card = $("nftsCard");
if (card) card.hidden = false;
if (!n) {
const el = $("nftsList");
if (el) el.innerHTML = `<div class="hint" style="padding:4px 2px">No certificates held by this wallet.</div>`;
const cnt = $("nftsCount");
if (cnt) cnt.textContent = "";
}
}
// One row per certificate: which category it belongs to, its commitment (the
// payload that makes it specific), and its capability — minting / mutable /
// none is the difference between a certificate that can still issue others
// and one that is fixed, which is worth seeing at a glance.
function renderCerts(balances, metaMap) {
const rows = [];
for (const cat of Object.keys(balances || {})) {
const b = balances[cat];
const list = Array.isArray(b?.nfts) ? b.nfts : [];
for (const n of list) rows.push({ cat, ...n });
}
setCertsCount(rows.length);
if (!rows.length) return;
const el = $("nftsList");
if (!el) return;
const cntEl = $("nftsCount");
if (cntEl) cntEl.textContent = `· ${rows.length}`;
el.innerHTML = rows.map((r) => {
const meta = metaMap?.[r.cat] || null;
const named = meta?.name || meta?.symbol || null;
const title = named || (r.cat.slice(0, 10) + "…" + r.cat.slice(-6));
const cap = String(r.capabilityLabel || r.capability || "none");
const capTag = cap && cap !== "none"
? ` <span class="ttag" title="This certificate can ${cap === "minting" ? "issue more" : "be altered"}">${esc(cap.toUpperCase())}</span>`
: "";
// A commitment is arbitrary bytes; it is the only thing distinguishing
// two certificates of the same category, so show it rather than hide it.
const commit = String(r.commitmentHex || "");
const commitTxt = commit
? (commit.length > 24 ? commit.slice(0, 24) + "…" : commit)
: "no commitment";
return `<div class="tx" style="grid-template-columns:1fr auto;cursor:default;align-items:center">
<div>
<div>${esc(title)}${capTag}</div>
<div class="hint mono">${esc(commitTxt)}</div>
</div>
<div class="hint mono" style="align-self:center;text-align:right">${named ? esc(r.cat.slice(0, 8)) + "…" : ""}</div>
</div>`;
}).join("");
}
// Name a token yourself. Self-minted categories publish nothing — no // Name a token yourself. Self-minted categories publish nothing — no
// registry entry, and usually not even an OP_RETURN in the genesis — so // registry entry, and usually not even an OP_RETURN in the genesis — so
@ -3467,6 +3529,9 @@ function renderTokens() {
const s = sel(); const s = sel();
const card = $("tokensCard"); const card = $("tokensCard");
const el = $("tokensList"); const el = $("tokensList");
// Certificates are BCH-only today; every other chain lands on 0 and says
// so. Overwritten below for BCH.
if (chain() !== "bch") setCertsCount(0);
// SOL wallets: SPL tokens with a Send button (existing flow). // SOL wallets: SPL tokens with a Send button (existing flow).
if (chain() === "sol") { if (chain() === "sol") {
const tokens = s?.tokens || []; const tokens = s?.tokens || [];
@ -3495,27 +3560,33 @@ function renderTokens() {
// come from BCMR, fetched async; the first paint uses raw category hex. // come from BCMR, fetched async; the first paint uses raw category hex.
if (chain() === "bch") { if (chain() === "bch") {
const balances = s?.tokenBalances || {}; const balances = s?.tokenBalances || {};
const cats = Object.keys(balances); const allCats = Object.keys(balances);
// Assets = categories carrying a fungible balance. Certificates live in
// their own pane, so a category that is purely non-fungible does not
// take up a row here saying "1 NFT".
const cats = allCats.filter((c) => String(balances[c]?.fungible || "0") !== "0");
setAssetsCount(cats.length); setAssetsCount(cats.length);
const hintEl = $("tokensHint"); const hintEl = $("tokensHint");
if (hintEl) hintEl.textContent = "CashTokens held by this wallet. Names come from BCMR — configure custom registries in Settings."; if (hintEl) hintEl.textContent = "Fungible CashTokens held by this wallet. Names come from BCMR — configure custom registries in Settings.";
if (!cats.length) return;
const draw = (metaMap) => { const draw = (metaMap) => {
// setAssetsCount already wrote the "no assets" line when there are no
// fungible categories; joining an empty list would wipe it. Common
// case now that purely-non-fungible categories live in the other pane.
if (!cats.length) { renderCerts(balances, metaMap); return; }
el.innerHTML = cats.map((cat) => { el.innerHTML = cats.map((cat) => {
const b = balances[cat]; const b = balances[cat];
const meta = metaMap?.[cat] || null; const meta = metaMap?.[cat] || null;
const decimals = meta && Number.isFinite(meta.decimals) ? meta.decimals : 0; const decimals = meta && Number.isFinite(meta.decimals) ? meta.decimals : 0;
const fungibleRaw = String(b.fungible || "0"); const fungibleRaw = String(b.fungible || "0");
const showFungible = fungibleRaw !== "0"; const showFungible = fungibleRaw !== "0";
const nftCount = Array.isArray(b.nfts) ? b.nfts.length : 0;
const name = meta?.name || meta?.symbol || null; const name = meta?.name || meta?.symbol || null;
const symbol = meta?.symbol || ""; const symbol = meta?.symbol || "";
const icon = meta?.iconUri || ""; const icon = meta?.iconUri || "";
const iconHtml = icon ? `<img src="${esc(icon)}" alt="" style="width:18px;height:18px;border-radius:4px;vertical-align:middle;margin-right:6px" onerror="this.style.display='none'">` : ""; const iconHtml = icon ? `<img src="${esc(icon)}" alt="" style="width:18px;height:18px;border-radius:4px;vertical-align:middle;margin-right:6px" onerror="this.style.display='none'">` : "";
const catShort = cat.slice(0, 10) + "…" + cat.slice(-6); const catShort = cat.slice(0, 10) + "…" + cat.slice(-6);
const fungibleTxt = showFungible ? fmtTokenAmount(fungibleRaw, decimals) + (symbol ? " " + symbol : "") : ""; const fungibleTxt = showFungible ? fmtTokenAmount(fungibleRaw, decimals) + (symbol ? " " + symbol : "") : "";
const nftTxt = nftCount ? `${nftCount} NFT${nftCount === 1 ? "" : "s"}` : ""; const amountLine = fungibleTxt || "—";
const amountLine = [fungibleTxt, nftTxt].filter(Boolean).join(" · ") || "—";
const localTag = meta?.local ? ` <span class="ttag" title="A name you set — no registry knows this token">YOURS</span>` : ""; const localTag = meta?.local ? ` <span class="ttag" title="A name you set — no registry knows this token">YOURS</span>` : "";
// Named: name on top, category beneath. Unnamed: the category IS the // Named: name on top, category beneath. Unnamed: the category IS the
// only identity, so it goes on top and the sub-line says what it is // only identity, so it goes on top and the sub-line says what it is
@ -3539,12 +3610,14 @@ function renderTokens() {
el.querySelectorAll("[data-tokname]").forEach((b) => b.addEventListener("click", () => { el.querySelectorAll("[data-tokname]").forEach((b) => b.addEventListener("click", () => {
openTokenLabelModal(b.dataset.tokname, metaMap?.[b.dataset.tokname] || null); openTokenLabelModal(b.dataset.tokname, metaMap?.[b.dataset.tokname] || null);
})); }));
renderCerts(balances, metaMap);
}; };
// Paint immediately with whatever we know synchronously so the row // Paint immediately with whatever we know synchronously so the row
// set doesn't wait for the network. Then run the async lookup and // set doesn't wait for the network. Then run the async lookup and
// redraw with names + icons. // redraw with names + icons.
draw({}); draw({});
S.invoke("tokenMetadata", { categories: cats }).then((res) => { if (!allCats.length) return;
S.invoke("tokenMetadata", { categories: allCats }).then((res) => {
if (chain() !== "bch") return; // user switched away mid-fetch if (chain() !== "bch") return; // user switched away mid-fetch
// 0.17.0: the reply carries the map under .meta plus whether any // 0.17.0: the reply carries the map under .meta plus whether any
// registry actually answered. The old shape was the bare map and the // registry actually answered. The old shape was the bare map and the
@ -3983,7 +4056,7 @@ if ($("rcvConsolidateBack")) $("rcvConsolidateBack").addEventListener("click", (
}); });
// Address & QR / Assets — the persistent pair inside the Receive body. // Address & QR / Assets — the persistent pair inside the Receive body.
document.querySelectorAll("[data-rcv-view]").forEach((b) => b.addEventListener("click", () => { document.querySelectorAll("[data-rcv-view]").forEach((b) => b.addEventListener("click", () => {
rcvView = b.dataset.rcvView === "assets" ? "assets" : "address"; rcvView = RCV_VIEWS.includes(b.dataset.rcvView) ? b.dataset.rcvView : "address";
try { localStorage.setItem("aegis/rcvView", rcvView); } catch (_e) {} try { localStorage.setItem("aegis/rcvView", rcvView); } catch (_e) {}
paintRcvView(); paintRcvView();
})); }));