Merge branch 'claude/sleepy-maxwell-251ee6-b' into HEAD

# Conflicts:
#	TheseusNavigator/addons-host.js
#	TheseusNavigator/main.js
This commit is contained in:
Local Dev 2026-09-23 00:25:36 +02:00
commit 9d9c651b30
21 changed files with 4790 additions and 1301 deletions

View file

@ -47,8 +47,15 @@ const KNOWN_CAPABILITIES = new Set([
// one of its OWN HTML files as a full Theseus tab, with a
// lean preload so the page can keep talking to the add-on
// via window.silentmode.invoke().
// scan-page: api.scanActiveTabForUris({scheme, limit}) — the HOST
// searches the active tab for URIs of one scheme and
// returns only those. The add-on never receives page text,
// markup or form values, so this sits well below
// page-inject or capture-tab on the trust ladder: it can
// learn that a page is offering e.g. a wiz:// pairing
// code, and nothing else about the page.
"vault-derive", "page-inject", "approval-modal", "capture-tab",
"toolbar-menu", "open-tab",
"toolbar-menu", "open-tab", "scan-page",
// context-menu-item: manifest["context-menu-items"] = [{id, label, when, icon?}]
// — chrome merges these into every tab's right-click
// menu, filtered by `when` (selectionText | linkURL |
@ -198,7 +205,7 @@ function validateManifest(raw, folderName) {
// Loader singleton. `discoverAndActivate(opts)` returns a snapshot the rest
// of the app queries via `getActive()` / `getInstalled()`.
class AddonHost {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, checkAndStageUpdates, restartApp, revealSidebar }) {
constructor({ addonsDir, dataDir, isDisabled, logger, setSessionProxy, vaultDerive, vaultImports, approvalModal, emitToPanel, hostRequire, hostImport, openTab, openAddonTab, openSettings, captureTab, saveCapture, scanTabForUris, checkAndStageUpdates, restartApp, revealSidebar }) {
this.addonsDir = addonsDir;
this.dataDir = dataDir;
this.isDisabled = isDisabled || (() => false);
@ -219,6 +226,7 @@ class AddonHost {
? arguments[0].vaultLifecycle : null;
this._approvalModal = typeof approvalModal === "function" ? approvalModal : null;
this._emitToPanel = typeof emitToPanel === "function" ? emitToPanel : null;
this._scanTabForUris = typeof scanTabForUris === "function" ? scanTabForUris : null;
// Add-ons live outside the app's node_modules tree, so a bare require()
// from their folder can't see Theseus's deps (ws, @noble/*, …). Main
// hands us its own require so add-ons can share the bundled tree.
@ -631,6 +639,28 @@ class AddonHost {
if (!this._saveCapture) throw new Error(`saveCapture unavailable (host not wired)`);
return this._saveCapture(opts || {}, manifest.id);
},
// scan-page: pull URIs of ONE scheme out of the active tab.
//
// Deliberately not a "read the page" API. The host does the matching
// and hands back only the URIs that matched, so an add-on with this
// capability still cannot see page text, form values or anything else
// it did not ask for. The scheme is fixed by the caller and validated
// here, and every call is expected to be user-initiated — nothing in
// the host polls a page on an add-on's behalf.
//
// opts.scheme e.g. "wiz" (required, [a-z][a-z0-9+.-]{0,19})
// opts.limit max URIs to return (default 20, hard cap 50)
// Resolves to `{ origin, uris: [string] }`.
scanActiveTabForUris: async (opts) => {
if (!manifest.capabilities.includes("scan-page")) {
throw new Error(`add-on "${manifest.id}" must declare the "scan-page" capability in addon.json`);
}
if (!this._scanTabForUris) throw new Error(`scanActiveTabForUris unavailable (host not wired)`);
const scheme = String(opts?.scheme || "").toLowerCase();
if (!/^[a-z][a-z0-9+.-]{0,19}$/.test(scheme)) throw new Error("invalid scheme");
const limit = Math.min(Math.max(Number(opts?.limit) || 20, 1), 50);
return this._scanTabForUris({ scheme, limit }, manifest.id);
},
};
}

View file

@ -1,14 +1,14 @@
{
"id": "aegis",
"name": "Aegis Wallet",
"version": "0.6.31",
"version": "0.9.0",
"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.",
"author": "Silent Mode",
"icon": "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32' fill='none'%3E%3Cpolygon points='16,2 28,9 28,23 16,30 4,23 4,9' fill='%230a0a0d' stroke='%23D6FF3D' stroke-width='1.6' stroke-linejoin='round'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='none' stroke='%23D6FF3D' stroke-width='1.4'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23D6FF3D'/%3E%3C/svg%3E",
"main": "index.js",
"updateURL": "https://navigate.st/bns/theseus.x/extensions/aegis/updates.json",
"capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal"],
"capabilities": ["sidebar-panel", "vault-derive", "page-inject", "approval-modal", "scan-page"],
"absorbs": ["bchwallet", "siawallet"],
"page-inject": {
"preload": "wallet-inject.js",

View file

@ -91,6 +91,7 @@ async function loadDeps(api) {
// the primary BCH adapter but a single fixed address per wallet.
const importedBchAdapter = require("./lib/chain-bch-imported.js")({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
HDKey, secp256k1, base58check, vaultImports: api.vault && api.vault.imports,
});
// Multi-chain imported adapters. UTXO chains (BTC, DGB) share an electrum-
// based reader; account-model chains (ETH, TRX, SOL) share a JSON-RPC
@ -103,7 +104,7 @@ async function loadDeps(api) {
// chain-native private key). Used by the importWallet handler to compute
// the address client-side before wallet-imports.enc stores the material.
const derive = require("./lib/import-derive.js")({
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256,
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory,
ecpairFactory: ECPairFactory, ecc, bip39, dgbCore,
});
@ -505,8 +506,43 @@ async function mountWallet(entry) {
...commonOpts,
cashaddr: entry.importedCashaddr || entry.importedAddress,
servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
importId: entry.importId,
});
adapter.schedulePoll(20_000);
// Imported BCH wallets were never registered with WizardConnect —
// startForWallet only ran in the vault-derived branch. They showed
// up in the "Sign with" picker (they mount as ready) and then failed
// on pair with "no manager". Register them here too.
//
// WC derives a child-key tree, so this needs a seed: mnemonic/seed
// imports qualify, WIF single-key imports never can. registerWcEligible
// records which is which so the panel can say so up front instead of
// offering a pairing that cannot work.
if (c.wc) {
c.api.vault.imports.signer(entry.importId).then((blob) => {
if (ctx !== c) return;
if (!blob || blob.kind !== "seed" || !blob.seed) {
wcIneligible.set(entry.id, "This wallet was imported from a single private key. WizardConnect needs a seed phrase to derive the per-dapp keys it signs with.");
emitStateForWallet(entry.id);
return;
}
const seedHex = String(blob.seed).trim();
if (!/^[0-9a-f]+$/i.test(seedHex) || seedHex.length < 32) {
wcIneligible.set(entry.id, "Imported seed material is not in a form WizardConnect can derive from.");
emitStateForWallet(entry.id);
return;
}
const root = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16)));
return c.wc.startForWallet({
walletId: entry.id, label: entry.label,
root32: root, accountPath: entry.accountPath || "m/44'/145'/0'",
}).finally(() => { try { root.fill(0); } catch {} });
}).catch((e) => {
c.api.log(`[${entry.id}] wc start (imported):`, e?.message || e);
wcIneligible.set(entry.id, cleanWcErr(e));
emitStateForWallet(entry.id);
});
}
} else if (entry.chain === "btc" || entry.chain === "dgb") {
adapter = new c.d.utxoImportedAdapter.UtxoImportedWallet({
...commonOpts, chain: entry.chain, address: entry.importedAddress,
@ -518,6 +554,32 @@ async function mountWallet(entry) {
rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || undefined),
});
adapter.schedulePoll(20_000);
} else if (entry.chain === "sc") {
// Sia's SiaWallet needs the 32-byte root at mount time — its key
// tree derives eagerly. Fetch the signer material from the vault
// (this branch runs only while the vault is unlocked; a locked
// vault would surface at import-time and gate the flow there).
// Falls back to a read-only stub if the fetch fails so a stray
// locked mount doesn't break panel rendering.
if (!c.api.vault?.imports || typeof c.api.vault.imports.signer !== "function") {
throw new Error("vault.imports.signer unavailable — cannot mount Sia import");
}
const signerBlob = await c.api.vault.imports.signer(entry.importId);
if (!signerBlob || signerBlob.kind !== "seed" || !signerBlob.seed) {
throw new Error("Sia signer material missing or malformed");
}
const seedHex = String(signerBlob.seed).trim();
if (!/^[0-9a-f]{64}$/i.test(seedHex)) throw new Error("Sia seed must be 32 bytes");
const rootBytes = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16)));
const walletdUrl = String(c.api.storage.get(`wallets/${entry.id}/walletdUrl`, "") || "");
adapter = new c.d.siaAdapter.SiaWallet(rootBytes, {
walletId: entry.id,
storage: c.api.storage,
log: (...a) => c.api.log(`[${entry.id}]`, ...a),
onChange: () => emitStateForWallet(entry.id),
walletdUrl,
});
if (walletdUrl && typeof adapter.startPolling === "function") adapter.startPolling();
} else {
throw new Error(`no imported adapter for chain "${entry.chain}"`);
}
@ -647,6 +709,7 @@ function unmountWallet(walletId) {
const rt = ctx.runtimes.get(walletId);
if (rt && rt.adapter) { try { rt.adapter.dispose(); } catch {} }
if (ctx.wc) { try { ctx.wc.stopForWallet(walletId); } catch {} }
wcIneligible.delete(walletId);
ctx.runtimes.delete(walletId);
}
@ -721,6 +784,18 @@ function overallPhase() {
return "ready";
}
// Per-wallet reason a BCH wallet can't do WizardConnect even though it's
// mounted and ready — today that's single-key (WIF) imports, which have no
// seed to derive a per-dapp key tree from. Surfaced in walletSummary so the
// picker can grey them out instead of offering a pairing that must fail.
const wcIneligible = new Map();
function cleanWcErr(e) {
const m = e?.message || String(e);
return /locked|vault/i.test(m)
? "Unlock the password vault to use WizardConnect with this wallet."
: m;
}
function walletSummary(w) {
const meta = chainMeta(w.chain, w.network);
const rt = ctx.runtimes.get(w.id);
@ -737,8 +812,15 @@ function walletSummary(w) {
// stay null so the picker knows whether to render the mono path line.
accountPath: w.accountPath || snap?.accountPath || null,
balance: snap?.balance || { confirmed: 0, unconfirmed: 0 },
// Per-wallet assets, so the coin drilldown can show what each ADDRESS
// holds instead of only the selected wallet's. `tokens` is the account-
// model shape (SPL / TRC20); `tokenBalances` is BCH CashTokens, keyed
// by category. Both stay null/empty for chains that have neither.
tokens: Array.isArray(snap?.tokens) ? snap.tokens : [],
tokenBalances: snap?.tokenBalances || null,
phase: rt?.phase || "locked",
error: rt?.error || null,
wcBlocked: w.chain === "bch" ? (wcIneligible.get(w.id) || null) : null,
};
}
@ -1024,6 +1106,31 @@ function registerPanelMessages(api) {
spec.wif = `aegis-privb58:${raw}`;
} else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); }
spec.cashaddr = address; // storage-key reuse — see BTC/DGB comment above
} else if (chain === "sc") {
// Siacoin. Uses a 32-byte root seed + u64 index (KeyFromSeed layout);
// no BIP44 path. Accepts a BIP39 12-word mnemonic (matches Sia
// Central Lite / walletd, PBKDF2 → first 32 bytes) or raw 32-byte
// seed hex. Address at index 0 is what we surface at import time;
// the mounted SiaWallet lets users advance through additional
// indices via the "Next unused address" affordance.
const net = network || "mainnet";
if (net !== "mainnet") throw new Error(`SC only supports mainnet (got ${net})`);
const index = Number(p && p.index != null ? p.index : 0);
if (!Number.isInteger(index) || index < 0) throw new Error("SC index must be a non-negative integer");
let seedHex;
if (p && p.mnemonic) {
const r = der.sc.fromMnemonic(String(p.mnemonic).trim(), index);
seedHex = r.seedHex; address = r.address;
} else if (p && p.seedHex) {
const r = der.sc.fromSeedHex(String(p.seedHex).trim(), index);
seedHex = r.seedHex; address = r.address;
} else { throw new Error("supply mnemonic or seedHex"); }
spec.kind = "seed";
spec.seed = seedHex;
// path field carries the Sia address index as an integer string,
// opaque to the vault. Mount reads it back as Number(spec.path).
spec.path = String(index);
spec.cashaddr = address;
} else {
throw new Error(`import not supported for chain "${chain}"`);
}
@ -1082,6 +1189,26 @@ function registerPanelMessages(api) {
});
api.onMessage("refresh", async (_p, m) => { fromPanel(m); const rt = requireSelected(); await rt.adapter.refresh(true); return snapshotForSelected(); });
// Panel drilldown → refresh every wallet under a chain (optionally scoped
// to one subnetwork). Fires each adapter's refresh in parallel; individual
// failures set the adapter's own error field (surfaced back to the panel
// via emitStateForWallet) rather than aborting the batch. Returns the
// list of {id, ok, error} so the panel can flash a summary.
api.onMessage("refreshChain", async (p, m) => {
fromPanel(m);
const chain = String(p?.chain || "");
const network = p?.network ? String(p.network) : null;
if (!chain) throw new Error("chain is required");
const targets = walletEntries().filter((w) => w.chain === chain && (!network || w.network === network));
const out = [];
await Promise.all(targets.map(async (w) => {
const rt = ctx.runtimes.get(w.id);
if (!rt || !rt.adapter) { out.push({ id: w.id, ok: false, error: "adapter not mounted" }); return; }
try { await rt.adapter.refresh(true); out.push({ id: w.id, ok: true }); }
catch (e) { out.push({ id: w.id, ok: false, error: e?.message || String(e) }); }
}));
return { chain, network, results: out };
});
api.onMessage("nextAddress", (_p, m) => {
fromPanel(m);
const rt = requireSelected();
@ -1273,6 +1400,131 @@ function registerPanelMessages(api) {
return rt.adapter.signAndBroadcast(plan);
});
// ---- Balance consolidation ------------------------------------------------
// Batch send-max from every same-chain/same-network wallet (or a subset the
// user picked with checkboxes) into the currently-selected wallet. Runs in
// two phases:
// consolidatePreview — dry-run plan() per source; returns balance/fee/
// net/error so the panel renders a preview list
// with checkboxes without asking the user to
// approve anything yet.
// consolidateIntoSelected — signs + broadcasts one send per chosen source.
// The panel shows a single upfront confirmation
// (with the total to move and total fees); Theseus's
// per-tx approval overlay is skipped because the
// batch itself is the user's explicit intent.
api.onMessage("consolidatePreview", async (_p, m) => {
fromPanel(m);
const destId = selectedWalletId();
if (!destId) throw new Error("no wallet selected");
const destEntry = walletEntries().find((w) => w.id === destId);
if (!destEntry) throw new Error("selected wallet not found");
const destRt = ctx.runtimes.get(destId);
if (!destRt?.adapter) throw new Error("destination wallet not ready");
const destSnap = destRt.adapter.snapshot();
const destAddr = destSnap.address;
if (!destAddr) throw new Error("destination wallet has no receive address");
const sources = walletEntries().filter((w) =>
w.chain === destEntry.chain &&
w.network === destEntry.network &&
w.id !== destId,
);
const items = [];
for (const src of sources) {
const rt = ctx.runtimes.get(src.id);
const snap = rt?.adapter?.snapshot?.() || {};
const bal = snap.balance || {};
const totalUnits = typeof bal.confirmed === "string"
? (BigInt(bal.confirmed || "0") + BigInt(bal.unconfirmed || "0")).toString()
: String((bal.confirmed || 0) + (bal.unconfirmed || 0));
const base = {
walletId: src.id, label: src.label,
address: snap.address || null,
balance: totalUnits,
fee: null, net: null, error: null, eligible: false,
};
if (!rt?.adapter) { items.push({ ...base, error: "adapter not mounted" }); continue; }
if (typeof rt.adapter.plan !== "function") { items.push({ ...base, error: "adapter has no plan()" }); continue; }
// Dry-run send-max to the destination. plan() throws on empty /
// dust-only wallets — that's the "nothing to sweep" case and it
// reads as an error string per source in the preview.
try {
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
const fee = String(plan.fee ?? 0);
const net = String(plan.recipients?.[0]?.value ?? 0);
items.push({ ...base, fee, net, eligible: true });
} catch (e) {
items.push({ ...base, error: e?.message || String(e) });
}
}
const meta = chainMeta(destEntry.chain, destEntry.network) || null;
return {
destinationWalletId: destId,
destinationLabel: destEntry.label,
destinationAddress: destAddr,
chain: destEntry.chain,
network: destEntry.network,
ticker: meta?.ticker || "",
decimals: meta?.decimals || 8,
sources: items,
};
});
api.onMessage("consolidateIntoSelected", async (p, m) => {
fromPanel(m);
const destId = selectedWalletId();
if (!destId) throw new Error("no wallet selected");
const destEntry = walletEntries().find((w) => w.id === destId);
if (!destEntry) throw new Error("selected wallet not found");
const destRt = ctx.runtimes.get(destId);
if (!destRt?.adapter) throw new Error("destination wallet not ready");
const destAddr = destRt.adapter.snapshot().address;
if (!destAddr) throw new Error("destination wallet has no receive address");
// sourceIds are the wallets the user CHECKED in the preview. Defaults
// to every eligible sibling if the panel omits the field (safety net,
// shouldn't happen in normal flow).
const requested = Array.isArray(p?.sourceIds) && p.sourceIds.length
? new Set(p.sourceIds.map(String))
: null;
const sources = walletEntries().filter((w) =>
w.chain === destEntry.chain &&
w.network === destEntry.network &&
w.id !== destId &&
(!requested || requested.has(w.id)),
);
const results = [];
for (const src of sources) {
const rt = ctx.runtimes.get(src.id);
if (!rt?.adapter || typeof rt.adapter.plan !== "function") {
results.push({ walletId: src.id, label: src.label, ok: false, error: "adapter not mounted" });
continue;
}
try {
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
const r = await rt.adapter.signAndBroadcast(plan);
results.push({
walletId: src.id, label: src.label, ok: true,
txid: r?.txid || null,
sent: String(plan.recipients?.[0]?.value ?? 0),
fee: String(plan.fee ?? 0),
});
} catch (e) {
results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) });
}
}
// Force a refresh on the destination so its balance jumps once the txs
// reach the network's mempool. Silent-fail — panel will pick up state
// on the next state emit anyway.
try { if (typeof destRt.adapter.refresh === "function") destRt.adapter.refresh(false); } catch {}
return {
destinationWalletId: destId,
destinationAddress: destAddr,
chain: destEntry.chain,
network: destEntry.network,
results,
};
});
api.onMessage("recovery", async (p, m) => {
fromPanel(m);
const id = String(p && p.id || selectedWalletId());
@ -1333,6 +1585,80 @@ function registerPanelMessages(api) {
return fullState();
});
// Scan the open dapp tab for a wiz:// pairing code, for dapps that render
// one but haven't adopted window.wizardconnect. Strictly user-initiated —
// it runs when someone presses "Scan page", never on a timer and never in
// the background. The host does the matching and returns only the URIs, so
// Aegis never receives page content.
api.onMessage("wcScanPage", async (_p, m) => {
fromPanel(m);
if (typeof api.scanActiveTabForUris !== "function") {
throw new Error("This Theseus build can't scan pages yet — update Theseus, or paste the wiz:// code manually.");
}
const { origin, uris } = await api.scanActiveTabForUris({ scheme: "wiz", limit: 10 });
return { origin: origin || null, uris: Array.isArray(uris) ? uris : [] };
});
// ---- WizardConnect from the page (0.8.8) --------------------------------
//
// WC was built for cross-device pairing: the dapp renders a QR, a phone
// scans it. Same-device that means copying a wiz:// string out of one
// tab and into the wallet by hand. These two handlers back the
// window.wizardconnect bridge so a dapp can hand Aegis the URI it has
// already generated, and the user just approves.
// Which BCH wallets can actually pair right now. Used by the page bridge
// AND by isReady() so a dapp can decide between "hand it to Aegis" and
// "render the QR" before it commits to either.
function wcPairableWallets() {
if (!ctx.wc) return [];
return walletEntries()
.filter((w) => w.chain === "bch")
.map((w) => ({ entry: w, rt: ctx.runtimes.get(w.id) }))
.filter(({ entry, rt }) => rt && rt.phase === "ready" && !wcIneligible.has(entry.id))
.map(({ entry }) => entry);
}
api.onMessage("wcPageReady", async (_p, m) => {
fromPage(m);
// Deliberately coarse: a page learns only whether pairing is possible,
// never how many wallets exist or what they are.
return { available: !!ctx.wc, pairable: wcPairableWallets().length > 0 };
});
api.onMessage("wcConnectFromPage", async (p, m) => {
const origin = fromPage(m);
if (!ctx.wc) throw new Error("WizardConnect is still starting up — try again in a moment");
const uri = String(p && p.uri || "").trim();
// Validate before showing any UI so a malformed or hostile value can't
// put a confusing approval in front of the user.
if (!/^wiz:\/\//i.test(uri)) throw new Error("not a WizardConnect URI");
if (uri.length > 4096) throw new Error("WizardConnect URI is implausibly long");
const candidates = wcPairableWallets();
if (!candidates.length) {
throw new Error("No Bitcoin Cash wallet is ready to pair. Unlock the Aegis vault (or add a BCH wallet) and try again.");
}
// Prefer the selected wallet when it qualifies, so the approval matches
// whatever the user currently sees in the panel.
const selId = selectedWalletId();
const chosen = candidates.find((w) => w.id === selId) || candidates[0];
return withOriginLock(origin, async () => {
const pick = await api.approvalModal({
title: "Pair this site with your wallet?",
origin,
body: "The site will be able to ask Aegis to sign Bitcoin Cash transactions over WizardConnect. Every signature still needs your approval — pairing on its own moves no funds.",
rows: [
{ label: "Wallet", value: `${chosen.label}` },
{ label: "Pairing code", value: uri.slice(0, 48) + (uri.length > 48 ? "…" : ""), mono: true },
],
actions: [{ id: "allow", label: "Pair", primary: true }],
});
if (!String(pick || "").startsWith("allow")) throw new Error("pairing declined");
await ctx.wc.connectUri(chosen.id, uri);
return { paired: true, wallet: chosen.label };
});
});
// Reorder wallets by an explicit ID list. Silently drops IDs that are
// not in the current wallet set (removed since the panel last read);
// appends any wallets missing from `order` to the end of the list so a
@ -1700,6 +2026,52 @@ function registerPageMessages(api) {
return rt.adapter.signMessage(message);
});
});
// BCH message verification (BIP-137). Panel-only path: given a message,
// a base64 signature, and an address, return { valid, address,
// recoveredHash }. No approval modal (nothing spendable happens), no
// wallet lookup — pure crypto against the given address.
// Panel → BCMR resolver. Batched: pass an array of category hex strings,
// get back { <categoryHex>: {name, symbol, iconUri, decimals, source} }
// for every one that resolved. Missed categories map to null. This
// triggers a background fetch for anything not in the disk cache, so
// the second call for the same set returns instantly.
api.onMessage("tokenMetadata", async (p, m) => {
fromPanel(m);
const cats = Array.isArray(p?.categories) ? p.categories.map(String).filter((c) => /^[0-9a-f]{64}$/i.test(c)) : [];
if (!cats.length) return {};
const entries = await ctx.bcmr.lookupMany(cats);
const out = {};
for (const [cat, entry] of Object.entries(entries)) {
out[cat] = ctx.bcmr.metadataOf(entry);
}
return out;
});
// Read the configured BCMR registry list (defaults + any user additions).
api.onMessage("bcmrRegistries", (_p, m) => {
fromPanel(m);
return { registries: ctx.bcmr.registryList() };
});
// Overwrite the registry list. Empty array restores defaults on next read.
api.onMessage("setBcmrRegistries", (p, m) => {
fromPanel(m);
ctx.bcmr.setRegistries(Array.isArray(p?.registries) ? p.registries : []);
return { registries: ctx.bcmr.registryList() };
});
api.onMessage("verifyMessage", (p, m) => {
fromPanel(m);
const message = String(p?.message != null ? p.message : "");
const signature = String(p?.signature || "");
const address = String(p?.address || "");
if (!signature || !address) throw new Error("signature and address are required");
try {
return ctx.d.keysLib.verifyMessage(message, signature, address, {
cashaddr: ctx.d.cashaddr, secp256k1: ctx.d.secp256k1,
});
} catch (e) {
return { valid: false, error: e?.message || String(e) };
}
});
// ---- Tron bridge (tronWeb / tronLink) -----------------------------------
api.onMessage("trx.requestAccounts", async (_p, m) => {
@ -2251,6 +2623,13 @@ module.exports = {
log: (...a) => api.log("prices", ...a),
onChange: () => emitState(),
}),
// BCMR (CashTokens metadata registry) — resolves category hex to
// { name, symbol, iconUri, decimals }. Storage-scoped so per-user
// caches don't stomp each other; disk-cached with 6h TTL.
bcmr: require("./lib/bcmr.js")({
storage: api.storage,
log: (...a) => api.log("bcmr", ...a),
}),
wc: null, // WizardConnect manager, initialised when deps load
};
migrateLegacyStorage(api);

View file

@ -0,0 +1,146 @@
// 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 };
};

View file

@ -31,7 +31,10 @@ function convertBits(data, from, to, pad) {
return out;
}
// type: 0 = P2PKH, 1 = P2SH. hash: 20 bytes (the only size we emit).
// type: 0 = P2PKH, 1 = P2SH, 2 = P2PKH+TOKEN, 3 = P2SH+TOKEN (CashTokens
// address types, CHIP-2022-02). hash: 20 bytes (the only size we emit).
// The type is a 5-bit value stored in the upper nibble of the version byte,
// so any type up to 15 encodes cleanly; every caller here uses 0-3.
function encode(prefix, type, hash) {
if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported");
const versionByte = (type << 3) | 0; // size bits 000 = 160
@ -41,6 +44,14 @@ function encode(prefix, type, hash) {
for (let i = 0; i < 8; i++) checksum.push(Number((mod >> BigInt(5 * (7 - i))) & 0x1fn));
return prefix + ":" + [...payload, ...checksum].map((v) => CHARSET[v]).join("");
}
// Whether a decoded address type carries the CashTokens "token-aware" flag.
// Callers use it to warn on token sends to non-token-aware addresses (a
// consensus rule — sending tokens to type 0/1 is a burn).
function isTokenAware(type) { return type === 2 || type === 3; }
// Fold a token-aware address type down to its bare equivalent so the
// UTXO / locking-script path can stay one-shape (P2PKH vs P2SH). The
// token payload is written via the 0xef prefix, not the address type.
function bareType(type) { return type & 0x01; }
// Accepts "prefix:payload" or a bare payload (assumes defaultPrefix).
function decode(address, defaultPrefix = "bitcoincash") {
@ -88,15 +99,30 @@ function decodeLegacy(address, sha256) {
return { prefix: "bitcoincash", type, hash: body.slice(1) };
}
// Anything a user might paste -> { type, hash, cashaddr }. Rejects other
// prefixes so a chipnet address can never be paid on mainnet by accident.
// Anything a user might paste -> { type, hash, cashaddr, tokenAware }.
// Rejects wrong prefixes so a chipnet address can never be paid on
// mainnet by accident. Type 2/3 (CashTokens-aware) is folded to type
// 0/1 for the locking-script side; the token-aware flag flows through
// so callers building token outputs can refuse to burn tokens on a
// non-aware recipient.
function parseAny(input, sha256, prefix = "bitcoincash") {
const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:");
if (!s) throw new Error("empty address");
const r = /^[13][1-9A-HJ-NP-Za-km-z]{25,34}$/.test(s) ? decodeLegacy(s, sha256) : decode(s, prefix);
if (r.prefix !== prefix) throw new Error(`address is for "${r.prefix}", expected "${prefix}"`);
if (r.type !== 0 && r.type !== 1) throw new Error("unsupported address type");
return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) };
if (r.type < 0 || r.type > 3) throw new Error(`unsupported address type ${r.type}`);
const tokenAware = isTokenAware(r.type);
const bare = bareType(r.type);
return {
type: bare, hash: r.hash, tokenAware,
// Round-trip through encode() so the returned cashaddr is
// canonical-cased and normalised, even if the input was a legacy
// Base58 (1…/3…) form. Emits type 0/1 by default; callers that
// want the token-aware form for display can re-encode with type
// 2/3 explicitly.
cashaddr: encode(prefix, bare, r.hash),
cashaddrTokenAware: encode(prefix, bare | 0x02, r.hash),
};
}
module.exports = { encode, decode, decodeLegacy, parseAny };
module.exports = { encode, decode, decodeLegacy, parseAny, isTokenAware, bareType };

View file

@ -0,0 +1,206 @@
// CashTokens (CHIP-2022-02) primitives — decode + encode the prefix byte
// that wraps a token-carrying scriptPubKey. Pure functions, no wallet or
// network state. Used by:
// - wallet.js → classify UTXOs (bare BCH vs fungible vs NFT vs both)
// - tx.js → build token outputs
// - panel.js → render token balances / send flows
//
// Prefix layout (CashTokens spec):
//
// 0xef — PREFIX_TOKEN marker
// category_id (32 bytes) — genesis txid of the token, LE-serialised
// token_bitfield (1 byte) — see BITS below
// [commitment_length (varint)] — present iff HAS_COMMITMENT_LENGTH
// [commitment (bytes)] — length equal to commitment_length
// [amount (varint)] — present iff HAS_AMOUNT (fungible token)
// <locking script bytes> — the "real" P2PKH / P2SH / … script
//
// Bitfield layout (spec §"Token Prefix Encoding"):
// Upper nibble = STRUCTURE bits (which fields are present):
// 0x10 HAS_AMOUNT — fungible token amount is encoded
// 0x20 HAS_NFT — NFT is present (commitment optional)
// 0x40 HAS_COMMITMENT_LENGTH — commitment_length is present
// 0x80 reserved (must be 0)
// Lower nibble = NFT CAPABILITY (meaningful only when HAS_NFT):
// 0x00 none / immutable
// 0x01 mutable
// 0x02 minting
// 0x03-0x0F reserved (must be 0)
const PREFIX_TOKEN = 0xef;
// Bit masks (STRUCTURE).
const HAS_AMOUNT = 0x10;
const HAS_NFT = 0x20;
const HAS_COMMITMENT_LENGTH = 0x40;
const STRUCTURE_RESERVED = 0x80;
// NFT capabilities. Values are read from bitfield & 0x0f.
const CAP_NONE = 0x00; // immutable NFT (or "no NFT" when HAS_NFT bit is off)
const CAP_MUTABLE = 0x01;
const CAP_MINTING = 0x02;
const CAP_LABEL = { 0: "immutable", 1: "mutable", 2: "minting" };
// Varint (compact size) encode/decode used for commitment length AND for
// the fungible-token amount. Amounts up to 9,223,372,036,854,775,807 sats
// (2^63-1) are legal; larger values are consensus-invalid, so we cap and
// throw on encode.
function readVarint(bytes, pos) {
if (pos >= bytes.length) throw new Error("cashtokens: truncated varint");
const first = bytes[pos];
if (first < 0xfd) return { value: BigInt(first), next: pos + 1 };
if (first === 0xfd) {
if (pos + 3 > bytes.length) throw new Error("cashtokens: truncated 0xfd varint");
return { value: BigInt(bytes[pos + 1] | (bytes[pos + 2] << 8)), next: pos + 3 };
}
if (first === 0xfe) {
if (pos + 5 > bytes.length) throw new Error("cashtokens: truncated 0xfe varint");
return {
value: BigInt(bytes[pos + 1]) | (BigInt(bytes[pos + 2]) << 8n)
| (BigInt(bytes[pos + 3]) << 16n) | (BigInt(bytes[pos + 4]) << 24n),
next: pos + 5,
};
}
// 0xff = 8-byte little-endian u64
if (pos + 9 > bytes.length) throw new Error("cashtokens: truncated 0xff varint");
let v = 0n;
for (let i = 0; i < 8; i++) v |= BigInt(bytes[pos + 1 + i]) << BigInt(8 * i);
return { value: v, next: pos + 9 };
}
function writeVarint(v) {
const n = typeof v === "bigint" ? v : BigInt(v);
if (n < 0n) throw new Error("cashtokens: negative varint");
if (n < 0xfdn) return Uint8Array.from([Number(n)]);
if (n <= 0xffffn) return Uint8Array.from([0xfd, Number(n & 0xffn), Number((n >> 8n) & 0xffn)]);
if (n <= 0xffffffffn) {
return Uint8Array.from([
0xfe, Number(n & 0xffn), Number((n >> 8n) & 0xffn),
Number((n >> 16n) & 0xffn), Number((n >> 24n) & 0xffn),
]);
}
if (n > (1n << 63n) - 1n) throw new Error("cashtokens: amount exceeds i64 max");
const out = new Uint8Array(9);
out[0] = 0xff;
let x = n;
for (let i = 1; i <= 8; i++) { out[i] = Number(x & 0xffn); x >>= 8n; }
return out;
}
// Split a scriptPubKey into { token, lockingScript, rawPrefix }. token is
// null when the script is NOT prefixed by 0xef. lockingScript is the
// tokenless portion — every downstream check (P2PKH, P2SH, OP_RETURN,
// electrum scripthash) works off THAT, so token-carrying and bare UTXOs
// stay comparable through the existing wallet code.
function decodePrefixedScript(script) {
const bytes = script instanceof Uint8Array ? script : Uint8Array.from(script);
if (!bytes.length || bytes[0] !== PREFIX_TOKEN) {
return { token: null, lockingScript: bytes, rawPrefix: null };
}
if (bytes.length < 1 + 32 + 1) throw new Error("cashtokens: prefix truncated at category");
let pos = 1;
const category = bytes.slice(pos, pos + 32); pos += 32;
const bitfield = bytes[pos]; pos += 1;
if (bitfield & STRUCTURE_RESERVED) throw new Error("cashtokens: reserved structure bit set");
const hasAmount = !!(bitfield & HAS_AMOUNT);
const hasNft = !!(bitfield & HAS_NFT);
const hasCommitLen = !!(bitfield & HAS_COMMITMENT_LENGTH);
const capability = bitfield & 0x0f;
// Structure invariants (spec):
// - Commitment-length present implies HAS_NFT (a commitment without an
// NFT is meaningless) AND commitment_length ≥ 1.
// - Capability lower nibble is only meaningful when HAS_NFT is set.
// - At least one of HAS_AMOUNT / HAS_NFT must be set, otherwise the
// prefix carries no useful info and should be rejected.
if (!hasAmount && !hasNft) throw new Error("cashtokens: prefix carries neither amount nor nft");
if (hasCommitLen && !hasNft) throw new Error("cashtokens: commitment without NFT");
if (!hasNft && capability !== 0) throw new Error("cashtokens: capability bits set on fungible-only prefix");
if (hasNft && capability > 2) throw new Error(`cashtokens: unknown NFT capability ${capability}`);
let commitment = null;
if (hasCommitLen) {
const clen = readVarint(bytes, pos); pos = clen.next;
if (clen.value === 0n) throw new Error("cashtokens: zero-length commitment");
if (clen.value > 40n) throw new Error(`cashtokens: commitment exceeds 40 bytes (${clen.value})`);
const length = Number(clen.value);
if (pos + length > bytes.length) throw new Error("cashtokens: commitment truncated");
commitment = bytes.slice(pos, pos + length); pos += length;
}
let amount = 0n;
if (hasAmount) {
const av = readVarint(bytes, pos); pos = av.next;
if (av.value === 0n) throw new Error("cashtokens: zero fungible amount");
if (av.value > (1n << 63n) - 1n) throw new Error("cashtokens: fungible amount overflow");
amount = av.value;
}
const lockingScript = bytes.slice(pos);
const rawPrefix = bytes.slice(0, pos);
return {
token: {
category, categoryHex: toHex(category),
amount, hasAmount, hasNft, capability, capabilityLabel: hasNft ? CAP_LABEL[capability] : null,
commitment, commitmentHex: commitment ? toHex(commitment) : null,
},
lockingScript, rawPrefix,
};
}
// Encode a { category, amount, nft: { commitment, capability } } spec into
// the prefix bytes ready to be prepended to a locking script. Absent fields
// mean "not present" — e.g. { amount: 100n } → fungible only.
function encodePrefix({ category, amount = 0n, nft = null }) {
const cat = category instanceof Uint8Array
? category
: Uint8Array.from(String(category).match(/../g).map((h) => parseInt(h, 16)));
if (cat.length !== 32) throw new Error("cashtokens: category must be 32 bytes");
const amt = typeof amount === "bigint" ? amount : BigInt(amount || 0);
if (amt < 0n) throw new Error("cashtokens: negative amount");
const hasAmount = amt > 0n;
const hasNft = !!nft;
const commitment = hasNft && nft.commitment
? (nft.commitment instanceof Uint8Array
? nft.commitment
: Uint8Array.from(String(nft.commitment).match(/../g).map((h) => parseInt(h, 16))))
: null;
const hasCommitLen = hasNft && commitment && commitment.length > 0;
if (commitment && commitment.length > 40) throw new Error("cashtokens: commitment > 40 bytes");
const capability = hasNft ? (Number(nft.capability) || 0) : 0;
if (capability > 2) throw new Error(`cashtokens: bad NFT capability ${capability}`);
if (!hasAmount && !hasNft) throw new Error("cashtokens: must have amount or NFT");
let bitfield = 0;
if (hasAmount) bitfield |= HAS_AMOUNT;
if (hasNft) bitfield |= HAS_NFT;
if (hasCommitLen) bitfield |= HAS_COMMITMENT_LENGTH;
bitfield |= capability & 0x0f;
const parts = [Uint8Array.from([PREFIX_TOKEN]), cat, Uint8Array.from([bitfield])];
if (hasCommitLen) { parts.push(writeVarint(commitment.length)); parts.push(commitment); }
if (hasAmount) parts.push(writeVarint(amt));
return concat(...parts);
}
// Prepend a token prefix to an existing locking script (P2PKH etc).
function wrapScript(prefix, lockingScript) {
return concat(prefix, lockingScript);
}
// Concise helper: given a JSON-serialisable descriptor and a P2PKH pubkey
// hash, produce the full token-carrying scriptPubKey ready for an output.
function tokenP2PKHScript({ category, amount = 0n, nft = null }, h160) {
const prefix = encodePrefix({ category, amount, nft });
const locking = Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
return wrapScript(prefix, locking);
}
// Utilities (kept private to this file to avoid coupling with tx.js).
function concat(...parts) {
const n = parts.reduce((a, p) => a + p.length, 0);
const out = new Uint8Array(n); let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function toHex(b) { return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); }
module.exports = {
PREFIX_TOKEN, HAS_AMOUNT, HAS_NFT, HAS_COMMITMENT_LENGTH,
CAP_NONE, CAP_MUTABLE, CAP_MINTING, CAP_LABEL,
decodePrefixedScript, encodePrefix, wrapScript, tokenP2PKHScript,
readVarint, writeVarint,
};

View file

@ -1,17 +1,21 @@
// Imported BCH wallet — single-address, key material lives in Theseus's
// wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's
// public shape (snapshot, refresh, plan, signAndBroadcast, dispose) but
// does NOT go through vault.derive + HKDF: derivation is direct from the
// seed+path or WIF that the user imported.
// public shape (snapshot, refresh, plan, signAndBroadcast, signMessage,
// dispose) but does NOT go through vault.derive + HKDF: derivation is
// direct from the seed+path or WIF that the user imported.
//
// M.1a scope: read-only (balance + history over Electrum). planSend/send
// throw with a clear message until M.1b lands the sign path.
// 0.6.36+: spend path enabled. plan() builds a P2PKH tx off the wallet's
// single scripthash UTXO set; signAndBroadcast() pulls the signer material
// from api.vault.imports.signer(importId), decodes the WIF or derives the
// mnemonic/path into a 32-byte priv key, and signs every input in RAM.
// The private key never lands in adapter state — signAndBroadcast fetches
// it fresh per broadcast and drops it before returning.
module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr, electrum, WebSocket, tx }) {
module.exports = function makeImportedBchAdapter({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
HDKey, secp256k1, base58check, vaultImports,
}) {
// Same electrum scripthash convention chain-bch uses: sha256(script), byte-
// reversed, hex. P2PKH-only for imports today — that's what every entry in
// Deviant's keystore is.
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
@ -19,9 +23,9 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
const IMPORTED_BCH_NETWORKS = {
mainnet: {
id: "mainnet", label: "Mainnet", prefix: "bitcoincash",
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
explorerAddr: "https://blockchair.com/bitcoin-cash/address/",
id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80,
explorerTx: "https://bchexplorer.cash/tx/",
explorerAddr: "https://bchexplorer.cash/address/",
defaultServers: [
"wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004",
@ -30,7 +34,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
],
},
chipnet: {
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest",
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef,
explorerTx: "https://chipnet.imaginary.cash/tx/",
explorerAddr: "https://chipnet.imaginary.cash/address/",
defaultServers: [
@ -41,9 +45,6 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
},
};
// Decode a cashaddr → 20-byte hash160 payload. We stored cashaddr at import
// time and use it here to compute the scripthash for Electrum without ever
// asking main for the signer material — that only happens at sign time.
function h160OfCashaddr(addr) {
const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, "");
const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean);
@ -51,12 +52,56 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
return hash;
}
// Decode a WIF-encoded private key. Accepts both mainnet (0x80) and
// testnet (0xef) version bytes and both compressed and uncompressed
// forms; returns { priv (32 bytes), compressed (bool) }.
function decodeWif(wif, versionByte) {
const bytes = base58check.decodeCheck(String(wif).trim());
if (!(bytes[0] === versionByte || bytes[0] === 0x80 || bytes[0] === 0xef)) {
throw new Error(`unexpected WIF version 0x${bytes[0].toString(16)}`);
}
const compressed = bytes.length === 34 && bytes[33] === 0x01;
const priv = bytes.slice(1, 33);
if (priv.length !== 32) throw new Error("WIF payload is not 32 bytes");
return { priv, compressed };
}
// Derive a P2PKH signer (32-byte priv + 33-byte compressed pubkey) from
// whatever vault.imports.signer returned. Two shapes today:
// { kind: "seed", seed: hex, path: "m/…" } — BIP32 derivation
// { kind: "wif", wif: base58check } — direct decode
// Anything else (or a missing signer) throws with a clear message so
// the panel can surface it rather than the broadcast returning garbage.
function signerToKey(signerBlob, net) {
if (!signerBlob) throw new Error("no signer material for this wallet");
if (signerBlob.kind === "seed") {
const seed = signerBlob.seed;
if (!/^[0-9a-f]+$/i.test(seed)) throw new Error("seed material must be hex");
const seedBytes = Uint8Array.from(seed.match(/../g).map((x) => parseInt(x, 16)));
const node = HDKey.fromMasterSeed(seedBytes).derive(signerBlob.path || "m");
return { priv: node.privateKey, pub: node.publicKey };
}
if (signerBlob.kind === "wif") {
const { priv } = decodeWif(signerBlob.wif, net.wifVersion);
const pub = secp256k1.getPublicKey(priv, true);
return { priv, pub };
}
throw new Error(`unknown signer kind: ${signerBlob.kind}`);
}
class ImportedBchWallet {
constructor({ walletId, storage, log = () => {}, onChange = () => {}, network = "mainnet", cashaddr: address, servers } = {}) {
constructor({
walletId, storage, log = () => {}, onChange = () => {},
network = "mainnet", cashaddr: address, servers, importId,
} = {}) {
const net = IMPORTED_BCH_NETWORKS[network];
if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`);
if (!address) throw new Error("chain-bch-imported: cashaddr required");
this.walletId = walletId;
// importId is the vault-side id used to fetch the signer at
// sign-time. Optional here so a mount without spend capability
// still works (read-only surface unaffected).
this._importId = importId || null;
this.chain = "bch";
this.network = net.id;
this._net = net;
@ -73,6 +118,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
this._state = {
balance: { confirmed: 0, unconfirmed: 0 },
history: [],
utxos: [],
height: 0,
scanning: false,
error: null,
@ -107,6 +153,10 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
server: this._client.url || null,
servers: this._servers,
imported: true,
// 0.6.36+: imported wallets can spend when the vault signer is
// reachable (i.e. Theseus is unlocked). canSpend reflects that so
// the panel can enable the Send tab without probing.
canSpend: !!this._importId,
explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr,
faucet: this._net.faucet,
@ -116,11 +166,15 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
async refresh(full) {
this._state.scanning = true; this._emit();
try {
// Balance for this single scripthash.
const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]);
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
// Always pull UTXOs so spend / send-max work off fresh state.
const utxos = await this._client.call("blockchain.scripthash.listunspent", [this._scripthash]);
this._state.utxos = (Array.isArray(utxos) ? utxos : []).map((u) => ({
txid: u.tx_hash, vout: u.tx_pos, value: Number(u.value), height: Number(u.height || 0),
}));
if (full) {
const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]);
const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]);
this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0,
}));
@ -135,11 +189,105 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
}
nextAddress() { return { address: this._address, index: 0 }; }
current() { return { address: this._address, index: 0, branch: 0, path: null, h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex }; }
current() {
return {
address: this._address, index: 0, branch: 0, path: null,
h160: this._h160, script: this._script, scripthash: this._scripthash, scriptHex: this._scriptHex,
};
}
plan() { throw new Error("Imported wallets are read-only in this build. Spending support ships in the next Aegis update."); }
signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); }
signMessage() { throw new Error("Imported wallets are read-only in this build."); }
// 0.6.36 spend path. Builds an unsigned P2PKH plan against the wallet's
// own UTXO set. Signing happens in signAndBroadcast, which fetches the
// key material from Theseus's vault at broadcast time — nothing key-
// bearing lives in the plan itself, so a plan can round-trip through
// the approval overlay without leaking secrets.
plan(spec) {
if (!this._state.utxos.length) throw new Error("wallet has no unspent outputs to spend from");
const targets = Array.isArray(spec?.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec?.to, value: spec?.amount ?? spec?.value }];
const outs = targets.map((t) => {
const a = cashaddr.parseAny(t.to, sha256, this._net.prefix);
const script = a.type === 0
? Uint8Array.from([0x76, 0xa9, 0x14, ...a.hash, 0x88, 0xac])
: Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
});
if (spec?.memo) outs.push({ value: 0, script: tx.memoScript(String(spec.memo)), data: true, memo: String(spec.memo) });
const rate = Math.min(10, Math.max(1, Number(spec?.feeRate) || 1));
// Imported wallets have exactly one address, so change goes back to
// itself — no need to derive a fresh change entry from an HD tree.
const changeScript = this._script;
const spendable = this._state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
const sel = tx.select(spendable, outs, rate, changeScript, { sendMax: !!spec?.sendMax });
const nonData = sel.outputs.filter((o) => !o.data);
const total = sel.outputs.reduce((a, o) => a + o.value, 0);
return {
...sel,
feeRate: rate,
recipients: nonData
.filter((_, i) => outs[i] && !outs[i].data)
.map((o, i) => ({ to: outs[i].to, value: o.value })),
memo: spec?.memo || null,
total,
};
}
async signAndBroadcast(plan) {
if (!this._importId) throw new Error("this wallet has no signer registered");
if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable");
const signerBlob = await vaultImports.signer(this._importId);
let key;
try {
key = signerToKey(signerBlob, this._net);
// Belt-and-braces: the signer must match the wallet's own address.
// Catches vault-side corruption and any accidental cross-mount.
const derivedH160 = hash160(key.pub);
const same = derivedH160.length === this._h160.length && derivedH160.every((b, i) => b === this._h160[i]);
if (!same) throw new Error("signer material does not match this wallet's address");
const inputs = plan.inputs.map((u) => ({ ...u, script: this._script }));
const t = { inputs, outputs: plan.outputs };
const signed = tx.sign(t, (inp, _i, digest) => ({
sig: secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "der" }),
publicKey: key.pub,
}));
const txid = await this._client.call("blockchain.transaction.broadcast", [signed.hex]);
if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid));
this.log("broadcast", txid);
setTimeout(() => this.refresh(false).catch(() => {}), 1500);
return { txid, hex: signed.hex, fee: plan.fee };
} finally {
// Wipe the private material before returning. Not perfect (JS can
// still relocate the underlying buffer during GC) but it minimises
// the window in which the raw key sits in this frame.
if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} }
}
}
// BIP-137 message signing from the imported key. Same MAGIC / double-
// sha256 payload as chain-bch.js so the resulting sig verifies through
// Electron Cash and every other BCH tool.
async signMessage(message) {
if (!this._importId) throw new Error("this wallet has no signer registered");
if (!vaultImports || typeof vaultImports.signer !== "function") throw new Error("vault.imports.signer unavailable");
const signerBlob = await vaultImports.signer(this._importId);
let key;
try {
key = signerToKey(signerBlob, this._net);
const enc = new TextEncoder();
const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("too long"); return Uint8Array.from([b.length, ...b]); };
const MAGIC = "Bitcoin Signed Message:\n";
const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]);
const digest = sha256(sha256(payload));
const sig = secp256k1.sign(digest, key.priv, { prehash: false, lowS: true, format: "recovered" });
const out = new Uint8Array(65);
out[0] = 27 + sig[0] + 4;
out.set(sig.subarray(1), 1);
return { address: this._address, signature: Buffer.from(out).toString("base64") };
} finally {
if (key && key.priv && key.priv.fill) { try { key.priv.fill(0); } catch {} }
}
}
recovery() { return { accountPath: null, xpub: null, xprv: null, note: "Recovery lives in the source of the import (Deviant keystore or wherever you got the seed/WIF from)." }; }

View file

@ -12,8 +12,8 @@ const BCH_NETWORKS = {
label: "Mainnet",
prefix: "bitcoincash",
defaultAccountPath: "m/44'/145'/0'",
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
explorerAddr: "https://blockchair.com/bitcoin-cash/address/",
explorerTx: "https://bchexplorer.cash/tx/",
explorerAddr: "https://bchexplorer.cash/address/",
defaultServers: [
"wss://bch.imaginary.cash:50004",
"wss://cashnode.bch.ninja:50004",
@ -105,6 +105,9 @@ module.exports = function makeBchAdapter({
addressIndex: w.addressIndex,
addressPath: w.addressPath,
balance: w.balance,
// CashTokens balances (0.7.0+). Categories → { fungible: str,
// nfts: [...], utxoCount }. Empty object when no tokens held.
tokenBalances: w.tokenBalances || {},
height: w.height,
history: w.history,
scanning: w.scanning,
@ -126,7 +129,8 @@ module.exports = function makeBchAdapter({
const targets = Array.isArray(spec.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec.to, value: spec.amount ?? spec.value }];
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax });
const memo = typeof spec.memo === "string" ? spec.memo : "";
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax, memo });
}
async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); }
// 65-byte BIP-137 recoverable signature — the format Electron Cash and

View file

@ -10,12 +10,70 @@
module.exports = function makeGenericImportedAdapter() {
// base58check T… -> 41-prefixed hex, for comparing against the raw
// owner_address/to_address fields the /v1 tx feed returns.
const B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
function tronAddrToHex(b58) {
try {
let n = 0n;
for (const ch of String(b58)) {
const i = B58.indexOf(ch);
if (i < 0) return "";
n = n * 58n + BigInt(i);
}
let hex = n.toString(16);
if (hex.length % 2) hex = "0" + hex;
// 25 bytes = 21 payload + 4 checksum; drop the checksum.
return hex.padStart(50, "0").slice(0, 42);
} catch { return ""; }
}
// Airdrop spam is the norm on public addresses — a real test address came
// back with 855 ERC-20s and 3078 SPL mints. Rendering all of those in a
// sidebar is useless, so every fetchTokens caps its list. Sorting puts
// named/known tokens first, so the cap drops spam before it drops
// anything the user recognises.
const TOKEN_CAP = 50;
// Token names are attacker-controlled. Scam mints ship symbols that are
// blank, pure whitespace, zero-width characters, or carry bidi overrides
// to make one string render as another. Strip the invisible classes, cap
// the length, and return "" when nothing legible survives so the caller
// can mark the token unknown instead of rendering an empty-looking row
// that borrows trust from the ones above it.
// Ranges are listed numerically rather than as a regex character class on
// purpose: a literal class would need these very characters in the source,
// where they are invisible to a reviewer and easy for an editor or a patch
// tool to mangle.
const INVISIBLE_RANGES = [
[0x0000, 0x001f], [0x007f, 0x009f], // C0 / C1 controls
[0x200b, 0x200f], // zero-width space..RTL mark
[0x202a, 0x202e], // bidi embedding / override
[0x2060, 0x206f], // word joiner, invisible operators
[0xfeff, 0xfeff], // BOM / zero-width no-break space
];
function cleanTokenText(s) {
let out = "";
for (const ch of String(s == null ? "" : s)) {
const cp = ch.codePointAt(0);
if (INVISIBLE_RANGES.some(([lo, hi]) => cp >= lo && cp <= hi)) continue;
out += ch;
}
return out.replace(/\s+/g, " ").trim().slice(0, 32);
}
const CHAIN_CFGS = {
eth: {
ticker: "ETH", decimals: 18,
networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://eth.llamarpc.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" },
sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" },
// `indexer` is a keyless Blockscout instance. The JSON-RPC endpoints
// above serve balances but have no history or token concept at all —
// that's why imported ETH wallets showed a balance and nothing else.
// Etherscan V2 would need an API key; Blockscout does not.
// publicnode, not llamarpc: llamarpc was answering 525 with an HTML
// error page, which surfaced as a JSON parse error and a 0 balance.
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://ethereum-rpc.publicnode.com", indexer: "https://eth.blockscout.com", explorerAddr: "https://etherscan.io/address/", explorerTx: "https://etherscan.io/tx/" },
sepolia: { id: "sepolia", label: "Sepolia", rpc: "https://ethereum-sepolia-rpc.publicnode.com", indexer: "https://eth-sepolia.blockscout.com", explorerAddr: "https://sepolia.etherscan.io/address/", explorerTx: "https://sepolia.etherscan.io/tx/", testnet: true, faucet: "https://sepoliafaucet.com/" },
},
// JSON-RPC eth_getBalance → hex-string wei.
async fetchBalance({ rpc, address }) {
@ -25,12 +83,64 @@ module.exports = function makeGenericImportedAdapter() {
const hex = String(j?.result || "0x0").replace(/^0x/, "");
return BigInt("0x" + hex).toString();
},
async fetchHistory({ address, net }) {
if (!net?.indexer) return null;
const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/transactions`, { headers: { accept: "application/json" } });
if (!r.ok) throw new Error(`Blockscout history HTTP ${r.status}`);
const j = await r.json();
const items = Array.isArray(j?.items) ? j.items : [];
const me = String(address).toLowerCase();
return items.slice(0, 25).map((t) => {
const from = String(t.from?.hash || "").toLowerCase();
const wei = BigInt(String(t.value || "0"));
const outgoing = from === me;
// A mempool tx comes back as {result:"pending", status:null,
// timestamp:null}. Reading that as `status !== "ok" → failed`
// showed pending sends as failures, which is the one thing a
// wallet must never get wrong.
const pending = t.result === "pending" || t.status == null;
return {
txid: t.hash,
time: Math.floor(new Date(t.timestamp || 0).getTime() / 1000) || 0,
confirmations: Number(t.confirmations) || 0,
status: pending ? "pending" : (t.status === "ok" ? "confirmed" : "failed"),
// Keep wei exact — 18 decimals overflows a JS number.
delta: (outgoing ? -wei : wei).toString(),
kind: t.method || "Transfer",
};
}).filter((t) => t.txid);
},
async fetchTokens({ address, net }) {
if (!net?.indexer) return null;
const r = await fetch(`${net.indexer}/api/v2/addresses/${encodeURIComponent(address)}/token-balances`, { headers: { accept: "application/json" } });
if (!r.ok) throw new Error(`Blockscout tokens HTTP ${r.status}`);
const j = await r.json();
const list = Array.isArray(j) ? j : [];
return list.map((e) => {
const t = e?.token || {};
const symbol = cleanTokenText(t.symbol);
return {
mint: t.address_hash || t.address || "",
symbol: symbol || "?",
name: cleanTokenText(t.name),
decimals: Number(t.decimals) || 0,
known: !!symbol,
balance: String(e.value ?? "0"),
};
}).filter((t) => t.mint && t.balance !== "0")
.sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
.slice(0, TOKEN_CAP);
},
},
trx: {
ticker: "TRX", decimals: 6,
networks: {
mainnet: { id: "mainnet", label: "Mainnet", rpc: "https://api.trongrid.io", explorerAddr: "https://tronscan.org/#/address/", explorerTx: "https://tronscan.org/#/transaction/" },
nile: { id: "nile", label: "Nile testnet", rpc: "https://api.nileex.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" },
// nile.trongrid.io, NOT api.nileex.io: nileex only serves the
// /wallet/* JSON-RPC family and 404s the whole /v1/ REST family,
// which is where transaction history and the trc20 token list
// live. Balance worked, everything else silently came back empty.
nile: { id: "nile", label: "Nile testnet", rpc: "https://nile.trongrid.io", explorerAddr: "https://nile.tronscan.org/#/address/", explorerTx: "https://nile.tronscan.org/#/transaction/", testnet: true, faucet: "https://nileex.io/join/getJoinPage" },
},
// Tron HTTP API returns account.balance in SUN (10^-6 TRX).
async fetchBalance({ rpc, address }) {
@ -40,6 +150,76 @@ module.exports = function makeGenericImportedAdapter() {
const j = await r.json();
return String(j?.balance || 0);
},
async fetchHistory({ rpc, address }) {
const r = await fetch(`${rpc.replace(/\/+$/, "")}/v1/accounts/${encodeURIComponent(address)}/transactions?limit=25`);
if (!r.ok) throw new Error(`Tron history HTTP ${r.status}`);
const j = await r.json();
const list = Array.isArray(j?.data) ? j.data : [];
return list.map((t) => {
const c = t?.raw_data?.contract?.[0];
const v = c?.parameter?.value || {};
const ownerHex = String(v.owner_address || "");
// owner/to come back as 41-prefixed hex regardless of visible.
const mineHex = tronAddrToHex(address);
const outgoing = !!mineHex && ownerHex.toLowerCase() === mineHex.toLowerCase();
const amount = Number(v.amount || 0);
const ok = Array.isArray(t.ret) ? t.ret[0]?.contractRet === "SUCCESS" : true;
return {
txid: t.txID || t.txid,
time: Math.floor((t.block_timestamp || t.raw_data?.timestamp || 0) / 1000),
confirmations: ok ? 1 : 0,
status: ok ? "confirmed" : "failed",
// Aegis renders `delta` in the wallet's base unit (sun here).
delta: c?.type === "TransferContract" ? (outgoing ? -amount : amount) : 0,
kind: c?.type || "Contract",
};
}).filter((t) => t.txid);
},
// TRC20 balances live on the /v1 REST family. The balance map is
// contract -> raw amount with no symbol/decimals, so we join it
// against token_info from recent transfers to name what we can.
async fetchTokens({ rpc, address }) {
const base = rpc.replace(/\/+$/, "");
const r = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}`);
if (!r.ok) throw new Error(`Tron account HTTP ${r.status}`);
const j = await r.json();
const acct = Array.isArray(j?.data) ? j.data[0] : j?.data;
const raw = Array.isArray(acct?.trc20) ? acct.trc20 : [];
const balances = new Map();
for (const entry of raw) {
for (const [contract, amt] of Object.entries(entry || {})) {
if (String(amt) !== "0") balances.set(contract, String(amt));
}
}
if (!balances.size) return [];
const info = new Map();
try {
const tr = await fetch(`${base}/v1/accounts/${encodeURIComponent(address)}/transactions/trc20?limit=100`);
if (tr.ok) {
const tj = await tr.json();
for (const t of (Array.isArray(tj?.data) ? tj.data : [])) {
const ti = t?.token_info;
if (ti && ti.address && !info.has(ti.address)) info.set(ti.address, ti);
}
}
} catch { /* names are a nicety; balances still render */ }
// Named tokens first: an address that's been airdrop-spammed can
// hold dozens of contracts we have no token_info for, and those
// would otherwise bury the ones the user actually cares about.
return Array.from(balances, ([contract, balance]) => {
const ti = info.get(contract);
const symbol = cleanTokenText(ti?.symbol);
return {
mint: contract,
symbol: symbol || "?",
name: cleanTokenText(ti?.name),
decimals: Number.isFinite(Number(ti?.decimals)) ? Number(ti.decimals) : 0,
known: !!ti && !!symbol,
balance,
};
}).sort((a, b) => (b.known - a.known) || (a.symbol || "").localeCompare(b.symbol || ""))
.slice(0, TOKEN_CAP);
},
},
sol: {
ticker: "SOL", decimals: 9,
@ -54,6 +234,62 @@ module.exports = function makeGenericImportedAdapter() {
const j = await r.json();
return String(j?.result?.value || 0);
},
// getSignaturesForAddress is keyless on the public RPC. It gives us
// the ledger of signatures touching this address but NOT the amounts —
// that would need a getTransaction per signature (25 extra round trips
// on every poll). We surface the entries with a null delta so the user
// at least sees activity and can open any of them in the explorer.
async fetchHistory({ rpc, address }) {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getSignaturesForAddress", params: [address, { limit: 25 }] }) });
if (!r.ok) throw new Error(`Solana history HTTP ${r.status}`);
const j = await r.json();
if (j?.error) throw new Error(j.error.message || "getSignaturesForAddress failed");
const list = Array.isArray(j?.result) ? j.result : [];
return list.map((s) => ({
txid: s.signature,
time: Number(s.blockTime) || 0,
confirmations: s.confirmationStatus === "finalized" ? 1 : 0,
status: s.err ? "failed" : "confirmed",
delta: null,
kind: "Transaction",
})).filter((t) => t.txid);
},
// SPL balances via getTokenAccountsByOwner with jsonParsed, matching
// what the built-in Solana adapter does. Symbol/name aren't on-chain
// in the token account, so the mint stands in for the symbol.
async fetchTokens({ rpc, address }) {
const SPL = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
const SPL22 = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb";
const call = async (programId) => {
const r = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getTokenAccountsByOwner",
params: [address, { programId }, { encoding: "jsonParsed" }] }) });
if (!r.ok) throw new Error(`Solana tokens HTTP ${r.status}`);
const j = await r.json();
if (j?.error) throw new Error(j.error.message || "getTokenAccountsByOwner failed");
return Array.isArray(j?.result?.value) ? j.result.value : [];
};
const accounts = [].concat(...await Promise.all([
call(SPL).catch(() => []),
call(SPL22).catch(() => []),
]));
const out = [];
for (const a of accounts) {
const info = a?.account?.data?.parsed?.info;
const amt = info?.tokenAmount;
if (!info?.mint || !amt || String(amt.amount) === "0") continue;
out.push({
mint: String(info.mint),
symbol: String(info.mint).slice(0, 4) + "…",
name: "",
decimals: Number(amt.decimals) || 0,
known: true, // decimals ARE on-chain here, so the amount is real
balance: String(amt.amount),
});
}
return out.sort((a, b) => (a.mint || "").localeCompare(b.mint || "")).slice(0, TOKEN_CAP);
},
},
};
@ -72,6 +308,7 @@ module.exports = function makeGenericImportedAdapter() {
this._state = {
balance: { confirmed: "0", unconfirmed: "0" },
history: [],
tokens: [],
scanning: false,
error: null,
};
@ -95,6 +332,7 @@ module.exports = function makeGenericImportedAdapter() {
addressPath: null,
balance: this._state.balance,
history: this._state.history,
tokens: this._state.tokens,
scanning: this._state.scanning,
error: this._state.error,
server: this._net.rpc,
@ -109,9 +347,23 @@ module.exports = function makeGenericImportedAdapter() {
async refresh() {
this._state.scanning = true; this._emit();
const opts = { rpc: this._net.rpc, address: this._address, net: this._net };
try {
const confirmed = await this._cfg.fetchBalance({ rpc: this._net.rpc, address: this._address });
// Only the balance is load-bearing — history and tokens are
// best-effort so one 404 on a chain that has no keyless feed
// doesn't blank the wallet.
const [confirmed, history, tokens] = await Promise.all([
this._cfg.fetchBalance(opts),
this._cfg.fetchHistory
? this._cfg.fetchHistory(opts).catch((e) => { this.log("history failed:", e?.message || e); return null; })
: Promise.resolve(null),
this._cfg.fetchTokens
? this._cfg.fetchTokens(opts).catch((e) => { this.log("tokens failed:", e?.message || e); return null; })
: Promise.resolve(null),
]);
this._state.balance = { confirmed: String(confirmed || 0), unconfirmed: "0" };
if (Array.isArray(history)) this._state.history = history;
if (Array.isArray(tokens)) this._state.tokens = tokens;
this._state.error = null;
} catch (e) {
this._state.error = e?.message || String(e);

View file

@ -20,6 +20,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
const keysLib = require("./sia/keys.js")({ sia });
const walletd = require("./sia/walletd.js")({ log: () => {} });
const walletFactory = require("./sia/wallet.js");
const siascanLib = require("./sia/siascan.js")({ log: () => {} });
function scopedStorage(storage, keyPrefix) {
const k = (key) => keyPrefix + key;
@ -32,7 +33,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
class SiaWallet {
constructor(root32, {
walletId, storage, log = () => {}, onChange = () => {},
walletdUrl = "",
walletdUrl = "", siascanUrl = "",
} = {}) {
if (!walletId) throw new Error("chain-sia: walletId required");
this.walletId = walletId;
@ -44,9 +45,67 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
this._root = new Uint8Array(root32);
this._keys = new keysLib.WalletKeys(root32);
this._walletdUrl = String(walletdUrl || "").trim();
this._siascanUrl = String(siascanUrl || "").trim() || siascanLib.DEFAULT_BASE;
this._client = null;
this._wallet = null;
// 0.7.5: siascan is the read-only fallback when no walletd URL is
// set. Users get balance + history + broadcast (v2) with zero
// hosting on their side, and can still point at their own walletd
// if they want to run everything sovereign.
this._siascan = new siascanLib.SiascanClient(this._siascanUrl);
this._siascanState = {
balance: { confirmed: "0", unconfirmed: "0", immature: "0" },
history: [],
height: 0,
addressIndex: 0,
scanning: false,
error: null,
};
this._siascanTimer = null;
if (this._walletdUrl) this._build();
else this._startSiascanPoll();
}
_stopSiascanPoll() { clearTimeout(this._siascanTimer); this._siascanTimer = null; }
_startSiascanPoll(intervalMs = 45_000) {
this._stopSiascanPoll();
const tick = async () => {
try { await this._refreshFromSiascan(); }
catch (e) { this._siascanState.error = e?.message || String(e); this._emitChange(); }
this._siascanTimer = setTimeout(tick, intervalMs);
};
// Initial fire is immediate — users see a balance without a poll wait.
this._siascanTimer = setTimeout(tick, 200);
}
_emitChange() { try { this.onChange(); } catch {} }
async _refreshFromSiascan() {
// Poll for the current receive index (default 0). SiaWallet's own
// "nextAddress" logic is walletd-scoped; without walletd we track
// the index in storage so the snapshot address stays stable.
const idx = Number(this.storage.get("siascan/receiveIndex", 0)) || 0;
const entry = this._keys.entry(idx);
this._siascanState.scanning = true; this._emitChange();
try {
const [tip, bal, events] = await Promise.all([
this._siascan.tip().catch(() => ({ height: 0 })),
this._siascan.balance(entry.address),
this._siascan.events(entry.address, { limit: 25 }).catch(() => []),
]);
this._siascanState.height = tip.height;
this._siascanState.balance = {
confirmed: bal.confirmed,
unconfirmed: bal.unconfirmed,
immature: bal.immature,
};
this._siascanState.history = siascanLib.normaliseEvents(events, entry.address, tip.height);
this._siascanState.addressIndex = idx;
this._siascanState.error = null;
} finally {
this._siascanState.scanning = false;
this._emitChange();
}
}
_build() {
@ -65,21 +124,44 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
const v = String(url || "").trim();
if (v === this._walletdUrl) return;
this._walletdUrl = v;
if (v) this._build(); else { try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null; }
if (v) {
// Switching to walletd: stop siascan polling — walletd owns the
// read path now.
this._stopSiascanPoll();
this._build();
} else {
// Dropping walletd URL: shut down the walletd wallet and resume
// siascan polling so the panel keeps a live balance.
try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null;
this._startSiascanPoll();
}
}
// The panel treats Sia amounts as decimal strings of hastings; the
// display layer picks how many SC-precision digits to show.
snapshot() {
const w = this._wallet && this._wallet.snapshot();
const usingSiascan = !this._wallet;
const siascanIdx = this._siascanState.addressIndex;
const siascanEntry = usingSiascan ? this._keys.entry(siascanIdx) : null;
const base = {
chain: "sc", network: "mainnet", ticker: "SC", decimals: 24,
address: null, addressIndex: 0, addressPath: `KeyFromSeed(seed, ${w?.addressIndex || 0})`,
address: null, addressIndex: 0,
addressPath: `KeyFromSeed(seed, ${w?.addressIndex || siascanIdx || 0})`,
balance: { confirmed: "0", unconfirmed: "0" },
height: 0, history: [], scanning: false, error: null,
server: this._client ? this._client.displayUrl : null,
// Server line the panel prints under the balance. When walletd is
// set that's the walletd URL; otherwise it's the siascan endpoint
// (which reads as public infrastructure, matching what's happening
// under the hood — no seed-material leaves the machine).
server: this._client ? this._client.displayUrl : (usingSiascan ? this._siascanUrl : null),
walletdUrl: this._walletdUrl,
needsWalletdUrl: !this._walletdUrl,
// 0.7.5: needsWalletdUrl no longer gates the wallet. Siascan handles
// read + broadcast automatically; the field stays for callers that
// want to nudge users toward self-hosted infrastructure.
needsWalletdUrl: false,
siascanUrl: usingSiascan ? this._siascanUrl : null,
readMode: usingSiascan ? "siascan" : "walletd",
explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null,
};
if (w) {
@ -100,21 +182,43 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
}));
base.scanning = w.scanning;
base.error = w.error;
} else if (siascanEntry) {
base.address = siascanEntry.address;
base.addressIndex = siascanIdx;
base.balance = {
confirmed: this._siascanState.balance.confirmed,
unconfirmed: this._siascanState.balance.unconfirmed,
};
base.height = this._siascanState.height;
base.history = this._siascanState.history;
base.scanning = this._siascanState.scanning;
base.error = this._siascanState.error;
}
return base;
}
async refresh(full) {
if (!this._wallet) return;
return this._wallet.refresh(!!full);
if (this._wallet) return this._wallet.refresh(!!full);
// Siascan path: fetch now, don't wait for the poll tick.
return this._refreshFromSiascan();
}
nextAddress() {
if (!this._wallet) throw new Error("no walletd URL configured");
return this._wallet.nextUnusedAddress();
if (this._wallet) return this._wallet.nextUnusedAddress();
// Siascan path: bump the stored receive index and re-poll. The next
// snapshot round-trips through _refreshFromSiascan which reads the
// updated storage value.
const cur = Number(this.storage.get("siascan/receiveIndex", 0)) || 0;
const nxt = cur + 1;
this.storage.set("siascan/receiveIndex", nxt);
this._refreshFromSiascan().catch(() => {});
const entry = this._keys.entry(nxt);
return { address: entry.address, index: nxt };
}
current() {
if (!this._wallet) throw new Error("no walletd URL configured");
return this._wallet.current();
if (this._wallet) return this._wallet.current();
const idx = Number(this.storage.get("siascan/receiveIndex", 0)) || 0;
const entry = this._keys.entry(idx);
return { address: entry.address, index: idx, path: `KeyFromSeed(seed, ${idx})`, pub: entry.pub };
}
plan(spec) {
if (!this._wallet) throw new Error("no walletd URL configured");
@ -162,8 +266,12 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
xprv: this._keys.seedHex,
};
}
startPolling() { if (this._wallet) this._wallet.startPolling(60_000); }
startPolling() {
if (this._wallet) this._wallet.startPolling(60_000);
else this._startSiascanPoll();
}
dispose() {
this._stopSiascanPoll();
try { this._wallet && this._wallet.dispose(); } catch {}
try { this._keys && this._keys.wipe(); } catch {}
if (this._root) this._root.fill(0);

View file

@ -109,10 +109,10 @@ module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore,
async refresh(full) {
this._state.scanning = true; this._emit();
try {
const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]);
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
if (full) {
const hist = await this._client.request("blockchain.scripthash.get_history", [this._scripthash]);
const hist = await this._client.call("blockchain.scripthash.get_history", [this._scripthash]);
this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0,
}));

View file

@ -7,10 +7,14 @@
// npm packages — same "hand it in" pattern the other adapters use.
module.exports = function makeImportDerive({
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256,
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39,
dgbCore,
}) {
// Sia's key derivation lives in lib/sia/sia.js — reuse it here so
// imported SC wallets end up with byte-identical addresses to what
// Sia Central Lite or walletd would show for the same seed.
const sia = blake2b ? require("./sia/sia.js")({ ed25519, blake2b }) : null;
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const fromHex = (h) => {
const s = String(h || "").replace(/^0x/i, "");
@ -207,6 +211,42 @@ module.exports = function makeImportDerive({
return base58check.encodeBase58(pub);
}
// ---- SC (Siacoin) --------------------------------------------------------
// Sia's walletd + Sia Central Lite Wallet both use a 32-byte root seed.
// Sia Central Lite exports it as a BIP39 12-word mnemonic (PBKDF2 →
// 64-byte seed → first 32 bytes = root); walletd's API accepts the raw
// 32-byte hex. Address at index N: standardUnlockHash(ed25519.pub(
// blake2b(root32 || u64le(N))
// )) — see lib/sia/sia.js:keyFromSeed for the byte layout.
function deriveScRootFromMnemonic(m) {
// BIP39 → 512-bit master seed; Sia Central takes the FIRST 32 bytes as
// the walletd root. Trimming the tail keeps addresses identical to
// what sialite.com and Sia Central mobile derive for the same phrase.
const fullSeedHex = mnemonicToSeedHex(m);
return fullSeedHex.slice(0, 64);
}
function deriveScRootFromHex(seedHex) {
const s = String(seedHex || "").trim().toLowerCase().replace(/^0x/, "");
if (!/^[0-9a-f]{64}$/.test(s)) throw new Error("SC seed hex must be exactly 32 bytes (64 hex chars)");
return s;
}
function deriveScAddressFromSeed(seedHex, index) {
if (!sia) throw new Error("SC derive unavailable (blake2b dep not passed)");
const root = fromHex(seedHex);
if (root.length !== 32) throw new Error("SC root seed must be 32 bytes");
const idx = Number(index || 0);
if (!Number.isInteger(idx) || idx < 0) throw new Error("SC index must be a non-negative integer");
const k = sia.keyFromSeed(root, idx);
return k.address; // 76 hex chars, canonical Sia address form
}
function deriveScFromMnemonic(mnemonic, index) {
return { seedHex: deriveScRootFromMnemonic(mnemonic), address: deriveScAddressFromSeed(deriveScRootFromMnemonic(mnemonic), index) };
}
function deriveScFromSeedHex(seedHex, index) {
const s = deriveScRootFromHex(seedHex);
return { seedHex: s, address: deriveScAddressFromSeed(s, index) };
}
return {
mnemonicToSeedHex,
btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif },
@ -214,5 +254,6 @@ module.exports = function makeImportDerive({
eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex },
trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex },
sol: { fromSeed: deriveSolFromSeed, fromPrivHex: deriveSolFromPrivHex, fromBase58: deriveSolFromBase58 },
sc: { fromMnemonic: deriveScFromMnemonic, fromSeedHex: deriveScFromSeedHex, addressAt: deriveScAddressFromSeed },
};
};

View file

@ -62,5 +62,57 @@ module.exports = function makeKeys({ HDKey, secp256k1, sha256, ripemd160, cashad
try { this._account.wipePrivateData(); } catch {}
}
}
return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex };
// BIP-137 verification. Given a message, a 65-byte recoverable signature
// (base64, produced by signRecoverable above or Electron Cash / any other
// BCH tool), and a CashAddr, recover the signer's pubkey, hash it to the
// address's h160, and compare. Returns { valid, address, recoveredHash }.
// Deliberately pure (no wallet state) so a panel can verify a sig pasted
// from anywhere without touching the vault.
function verifyMessage(message, base64Signature, address, { cashaddr: caLib, secp256k1: sec }) {
const dec = (b64) => {
const bin = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("binary");
const u = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
return u;
};
const sig = dec(String(base64Signature || "").trim());
if (sig.length !== 65) throw new Error(`signature must be 65 bytes (got ${sig.length})`);
const header = sig[0];
// BIP-137 header layout: 27 + recid + 4 (compressed). 0..3 → uncompressed,
// 4..7 → uncompressed P2SH-P2WPKH, 8..11 → uncompressed native-segwit,
// 12..15 → compressed. Every P2PKH BCH signer we care about uses the
// 31..34 range (27 + recid + 4). Anything outside 27..34 is rejected.
if (header < 27 || header > 34) throw new Error(`bad signature header ${header}`);
const recid = (header - 27) & 3;
const compressed = header >= 31;
const enc = new TextEncoder();
const varstr = (s) => { const b = enc.encode(s); if (b.length >= 0xfd) throw new Error("message too long"); return Uint8Array.from([b.length, ...b]); };
const MAGIC = "Bitcoin Signed Message:\n";
const payload = Uint8Array.from([...varstr(MAGIC), ...varstr(String(message))]);
const digest = sha256(sha256(payload));
// Reconstruct the raw signature (1-byte recid || r || s) for
// secp256k1.recoverPublicKey. @noble/curves takes the recovered format
// whether we pass compressed or uncompressed, we ask for compressed
// (matches every BCH wallet's derived pubkey).
const recovered = new Uint8Array(65);
recovered[0] = recid;
recovered.set(sig.subarray(1), 1);
const pub = sec.getPublicKey
? sec.recoverPublicKey(digest, recovered, { prehash: false, format: compressed ? "compressed" : "uncompressed" })
: sec.Signature.fromCompact(sig.subarray(1)).addRecoveryBit(recid).recoverPublicKey(digest).toRawBytes(compressed);
const recoveredHash = hash160(pub);
// Decode the expected address to its h160 payload; accept both mainnet
// and testnet prefixes. Rejects non-P2PKH addresses (type != 0) since
// this signing scheme has no notion of a P2SH signer.
const raw = String(address || "");
const full = raw.includes(":") ? raw : "bitcoincash:" + raw;
const { type, hash } = caLib.decode(full);
if (type !== 0) throw new Error(`address must be P2PKH (got type ${type})`);
const valid = recoveredHash.length === hash.length
&& recoveredHash.every((b, i) => b === hash[i]);
return { valid, address: raw, recoveredHash: toHex(recoveredHash) };
}
return { WalletKeys, hash160, p2pkhScript, p2shScript, scripthash, toHex, verifyMessage };
};

View file

@ -1,32 +1,39 @@
// Fiat prices for every Aegis-supported coin. Opt-in via Settings so a
// privacy-conscious user isn't quietly telling ANY oracle when Aegis is
// open. Source is user-selectable — different oracles trade off privacy,
// coverage, and freshness:
// Fiat prices for every Aegis-supported coin. Poll every enabled source in
// parallel and reconcile per chain: if two or more sources agree within a
// small band (±3% of the median), take their median as the truth; if none
// agree, fall back to the median of every reported value. This kills any
// single oracle's ability to make Aegis show a wrong number — a spoofed
// or wildly stale feed on one origin is outvoted by the others.
//
// - coingecko : one HTTP request covers all 7 coins, best coverage,
// default. Sees the browser IP + User-Agent every poll.
// - kraken : per-pair spot from Kraken's public /Ticker; fewer
// pairs (BCH/BTC/ETH/SOL/TRX; no SC/DGB). Sees IP but
// no user id.
// - coinbase : Coinbase's public spot endpoint; similar coverage to
// Kraken, similar IP-only exposure.
// The user-facing model in 0.6.36+ is just "on / off": no source picker,
// no per-source config. Adding a new oracle here fans out to everyone
// with no UI churn.
//
// New sources plug in by adding an entry to SOURCES. Each provider takes a
// list of chain keys and returns { <chain>: usd } for the ones it knows
// about; unknown chains just stay absent from the snapshot. The poller is
// generic.
// Sources currently wired:
// coingecko — 1 request covers all 7 coins, best overall coverage
// kraken — public /Ticker; BCH/BTC/ETH/SOL/TRX pairs
// coinbase — public /spot; BCH/BTC/ETH/SOL pairs
// coinspectrum — coin-spectrum.com free /assets/<slug>.json (~10 min TTL)
//
// Cache is in-memory (returned by fullState() → panel). Poll interval is
// per-source since some rate-limit tighter than others. Off by default.
// Sources deferred (need their own protocol work first):
// oracles.cash / General Protocols — the /oracleMetadata endpoint returns
// hex-encoded signed attestations. Extracting a usable USD number
// requires decoding the message format (pair || timestamp || price_int
// || decimals) and verifying the signature against a known oracle
// pubkey per pair. Left as a TODO stub below so the plumbing is
// ready; enable once the message parser is done.
const CHAINS = ["bch", "btc", "trx", "eth", "sol", "sc", "dgb"];
// Sources return { <chain>: usd_number } for every chain they know about.
// Absence just means "this source doesn't cover that chain"; reconciliation
// ignores it. Errors thrown here bubble to the poller which stores them
// per-source in the snapshot so the panel can show which oracle is down.
const SOURCES = {
coingecko: {
id: "coingecko",
label: "CoinGecko",
origin: "api.coingecko.com",
pollMs: 5 * 60 * 1000,
coversAll: true,
fetch: async () => {
const ids = {
@ -49,18 +56,14 @@ const SOURCES = {
id: "kraken",
label: "Kraken",
origin: "api.kraken.com",
pollMs: 60 * 1000,
coversAll: false,
fetch: async () => {
// Kraken uses non-standard pair names (XBT, ZUSD…). Only cover the
// coins Kraken lists with USD spot. SC + DGB are not on Kraken.
const pairs = { bch: "BCHUSD", btc: "XBTUSD", eth: "ETHUSD", sol: "SOLUSD", trx: "TRXUSD" };
const url = `https://api.kraken.com/0/public/Ticker?pair=${Object.values(pairs).join(",")}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`);
const body = await r.json();
if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";"));
// Kraken returns keys like "XBCHZUSD" — match by suffix.
const out = {};
const result = body?.result || {};
const entries = Object.entries(result);
@ -76,11 +79,8 @@ const SOURCES = {
id: "coinbase",
label: "Coinbase",
origin: "api.coinbase.com",
pollMs: 60 * 1000,
coversAll: false,
fetch: async () => {
// Coinbase publishes one spot per pair via /v2/prices/<pair>/spot.
// Runs the requests in parallel — 5 calls, each ~150 B response.
const map = { bch: "BCH-USD", btc: "BTC-USD", eth: "ETH-USD", sol: "SOL-USD" };
const out = {};
await Promise.all(Object.entries(map).map(async ([chain, pair]) => {
@ -95,35 +95,154 @@ const SOURCES = {
return out;
},
},
coinspectrum: {
id: "coinspectrum",
label: "Coin-Spectrum",
origin: "coin-spectrum.com",
coversAll: true,
fetch: async () => {
const slugs = {
bch: "bitcoin-cash", btc: "bitcoin", trx: "tron",
eth: "ethereum", sol: "solana", sc: "siacoin", dgb: "digibyte",
};
const out = {};
await Promise.all(Object.entries(slugs).map(async ([chain, slug]) => {
try {
const r = await fetch(`https://coin-spectrum.com/api/v1/assets/${slug}.json`, { cache: "no-store" });
if (!r.ok) return;
const body = await r.json();
// coin-spectrum wraps everything under body.asset:
// { generated_at, asset: { slug, symbol, price_usd, … } }
// Older builds read body.price_usd, which is undefined, so every
// chain silently NaN'd and the UI showed "✓ 0 coins".
const usd = Number(body?.asset?.price_usd ?? body?.price_usd);
if (Number.isFinite(usd) && usd > 0) out[chain] = usd;
} catch { /* one slug failing shouldn't kill the others */ }
}));
return out;
},
},
// oracles.cash (General Protocols) is deferred until we decode their
// signed-attestation message format. Enable by moving this entry into
// SOURCES above once fetch() returns real USD numbers.
// _oraclescash: {
// id: "oraclescash",
// label: "oracles.cash",
// origin: "oracles.generalprotocols.com",
// coversAll: false,
// fetch: async () => {
// // TODO: pick per-pair oracle pubkey, fetch /api/v1/oracleMessages,
// // decode `message` = pair_ascii(2 bytes) || timestamp(u32) ||
// // price_int(u32-or-u64) || decimals; verify signature. See
// // https://oracles.generalprotocols.com/api/v1/oracleMetadata for
// // the list of active oracles.
// return {};
// },
// },
};
const DEFAULT_SOURCE = "coingecko";
const POLL_MS = 5 * 60 * 1000; // 5 min: gentle on free tiers, still fresh enough
const AGREEMENT_BAND = 0.03; // ±3% around the median counts as "agreeing"
const median = (nums) => {
const s = nums.slice().sort((a, b) => a - b);
const m = s.length;
if (!m) return null;
return m % 2 ? s[(m - 1) / 2] : (s[m / 2 - 1] + s[m / 2]) / 2;
};
// Reconcile a per-source map for ONE chain into a single trusted USD number.
// perSource: { <sourceId>: usd_number }
// Returns { usd, method, samples: [{sourceId, usd, agrees}] }.
function reconcileOne(perSource) {
const samples = Object.entries(perSource)
.filter(([, v]) => Number.isFinite(v) && v > 0)
.map(([sourceId, usd]) => ({ sourceId, usd, agrees: false }));
if (!samples.length) return { usd: null, method: "none", samples };
if (samples.length === 1) {
samples[0].agrees = true;
return { usd: samples[0].usd, method: "single", samples };
}
// Pin agreement around the overall median so no single outlier can shift
// the anchor. Any two samples within ±3% of that median form a "cluster";
// if ≥2 exist we take their median as the truth.
const mid = median(samples.map((s) => s.usd));
const lo = mid * (1 - AGREEMENT_BAND);
const hi = mid * (1 + AGREEMENT_BAND);
const agreeing = samples.filter((s) => s.usd >= lo && s.usd <= hi);
if (agreeing.length >= 2) {
for (const a of agreeing) a.agrees = true;
return { usd: median(agreeing.map((s) => s.usd)), method: `majority-${agreeing.length}of${samples.length}`, samples };
}
// Nobody agrees within the band — every source disagrees. Fall back to
// the median of everything reported so we still show A price (biased
// toward the middle) rather than nothing. Panel can show a "spread"
// warning if callers care.
return { usd: mid, method: `median-${samples.length}`, samples };
}
// Fan out to every SOURCES.fetch() in parallel. Returns
// { perChain: { <chain>: {usd, method, samples} }, sourceStatus: { <id>: {ok, error, prices, at} } }.
async function pollAll(log) {
const sourceStatus = {};
const perSourcePrices = {}; // chain -> {sourceId: usd}
await Promise.all(Object.values(SOURCES).map(async (s) => {
const start = Date.now();
try {
const got = await s.fetch();
const prices = got && typeof got === "object" ? got : {};
sourceStatus[s.id] = { ok: true, error: null, prices, at: Date.now(), took: Date.now() - start };
for (const [chain, usd] of Object.entries(prices)) {
if (!Number.isFinite(usd) || usd <= 0) continue;
if (!perSourcePrices[chain]) perSourcePrices[chain] = {};
perSourcePrices[chain][s.id] = usd;
}
} catch (e) {
const msg = e?.message || String(e);
sourceStatus[s.id] = { ok: false, error: msg, prices: {}, at: Date.now(), took: Date.now() - start };
log(`price fetch (${s.id}) failed:`, msg);
}
}));
const perChain = {};
for (const chain of CHAINS) {
const rec = reconcileOne(perSourcePrices[chain] || {});
if (rec.usd != null) perChain[chain] = rec;
}
return { perChain, sourceStatus };
}
module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) {
const state = {
enabled: false,
source: DEFAULT_SOURCE,
prices: {}, // { <chain>: usd (number) }
prices: {}, // { <chain>: usd (number) } — backward-compat
reconciled: {}, // { <chain>: {usd, method, samples: [...]} }
sourceStatus: {}, // { <sourceId>: {ok, error, prices, at, took} }
fetchedAt: null,
error: null,
loading: false,
};
let timer = null;
function currentProvider() { return SOURCES[state.source] || SOURCES[DEFAULT_SOURCE]; }
async function fetchOnce() {
if (!state.enabled) return;
state.loading = true; state.error = null; onChange();
try {
const src = currentProvider();
const next = await src.fetch();
state.prices = next || {};
const { perChain, sourceStatus } = await pollAll(log);
const flat = {};
for (const [chain, rec] of Object.entries(perChain)) flat[chain] = rec.usd;
state.prices = flat;
state.reconciled = perChain;
state.sourceStatus = sourceStatus;
state.fetchedAt = Date.now();
state.error = null;
// Only escalate to a top-level error if EVERY source failed. A single
// oracle being unreachable is normal and doesn't need a red banner.
const allDown = Object.values(sourceStatus).every((s) => !s.ok);
state.error = allDown ? "All price sources unreachable" : null;
} catch (e) {
state.error = e?.message || String(e);
log(`price fetch (${state.source}) failed:`, state.error);
log("price poll failed:", state.error);
} finally {
state.loading = false;
onChange();
@ -133,30 +252,31 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
function schedule() {
clearTimeout(timer);
if (!state.enabled) return;
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs);
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
}
return {
snapshot() {
return {
enabled: state.enabled,
source: state.source,
prices: state.prices,
reconciled: state.reconciled,
sourceStatus: state.sourceStatus,
fetchedAt: state.fetchedAt,
error: state.error,
loading: state.loading,
sources: Object.values(SOURCES).map((s) => ({
id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll,
})),
// Retained so existing settings UI paths that expect a `sources`
// list keep rendering. `coversAll` is informational only now that
// the picker's gone.
sources: Object.values(SOURCES).map((s) => ({ id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll })),
};
},
// Turn the feed on/off. Enabling triggers an immediate fetch so the
// panel doesn't wait a full poll interval for the first price.
async setEnabled(on) {
const changed = !!on !== state.enabled;
state.enabled = !!on;
if (!state.enabled) {
state.prices = {}; state.fetchedAt = null; state.error = null;
state.prices = {}; state.reconciled = {}; state.sourceStatus = {};
state.fetchedAt = null; state.error = null;
clearTimeout(timer);
if (changed) onChange();
return;
@ -165,15 +285,10 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
await fetchOnce();
schedule();
},
// Switch source. Clears the current cache, kicks a fresh fetch if the
// feed is enabled. No-op when the source is already current.
async setSource(id) {
if (!SOURCES[id] || id === state.source) return;
state.source = id;
state.prices = {}; state.fetchedAt = null;
onChange();
if (state.enabled) { await fetchOnce(); schedule(); }
},
// No-op kept for API compatibility — there is no source picker anymore.
// Existing callers that persisted a chosen source can still call this
// and get a benign refresh.
async setSource(_id) { if (state.enabled) { await fetchOnce(); schedule(); } },
refresh() { return fetchOnce(); },
dispose() { clearTimeout(timer); state.enabled = false; },
};

View file

@ -0,0 +1,156 @@
// Siascan (SiaFoundation/explored) public read-only client. Used by
// SiaWallet when the user has NOT pointed Aegis at their own walletd
// URL — with a live siascan endpoint we can render balance, unspent
// outputs, transaction history, and the chain tip without any hosting
// on the user's side.
//
// Endpoints (from SiaFoundation/explored api/server.go):
// GET /consensus/tip
// GET /addresses/{addr}/balance
// GET /addresses/{addr}/events
// GET /addresses/{addr}/events/unconfirmed
// GET /addresses/{addr}/utxos/siacoin
// POST /txpool/broadcast — broadcast a v1 tx
// POST /v2/transactions — batch fetch (not broadcast; broadcast is v1)
//
// Broadcast (send) still requires walletd today: the tx we build is a v2
// transaction and siascan's broadcast is currently v1-only. Once the v2
// broadcast endpoint lands upstream this same client picks it up.
const DEFAULT_BASE = "https://api.siascan.com";
module.exports = function makeSiascan({ log = () => {} } = {}) {
class SiascanClient {
constructor(baseUrl) {
this._base = String(baseUrl || DEFAULT_BASE).replace(/\/+$/, "");
}
get displayUrl() { return this._base; }
setBase(url) { this._base = String(url || DEFAULT_BASE).replace(/\/+$/, ""); }
async _get(path) {
const url = this._base + path;
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) {
const body = await r.text().catch(() => "");
throw new Error(`siascan ${r.status} ${path}: ${body.slice(0, 200)}`);
}
return r.json();
}
// Chain tip. Used to compute confirmations on history events.
async tip() {
const j = await this._get("/consensus/tip");
return { height: Number(j?.height || 0), id: String(j?.id || "") };
}
// Wallet-agnostic balance for one address. Returns hastings as decimal
// strings so the panel keeps the BigInt-safe wire format the walletd
// path already emits.
async balance(address) {
const j = await this._get(`/addresses/${encodeURIComponent(address)}/balance`);
// explored shape: { siacoins, immatureSiacoins, siafunds }
// Each is a hastings string (v2 currency serialisation).
return {
confirmed: String(j?.siacoins || "0"),
immature: String(j?.immatureSiacoins || "0"),
// Aegis's panel treats "unconfirmed" as "not yet spendable". Explored
// lumps immature payout there; a strict unconfirmed number would
// need the /events/unconfirmed sum instead — added below.
unconfirmed: String(j?.immatureSiacoins || "0"),
siafunds: Number(j?.siafunds || 0),
};
}
// Confirmed history events. Each event carries a type ("v2Transaction",
// "siacoinInput", "minerPayout", …), the amount delta from THIS address's
// perspective, and a maturity/block height.
async events(address, { limit = 25, offset = 0 } = {}) {
const q = `?limit=${limit}&offset=${offset}`;
const list = await this._get(`/addresses/${encodeURIComponent(address)}/events${q}`);
return Array.isArray(list) ? list : [];
}
async unconfirmedEvents(address) {
const list = await this._get(`/addresses/${encodeURIComponent(address)}/events/unconfirmed`);
return Array.isArray(list) ? list : [];
}
// Unspent Siacoin outputs. { id, siacoinOutput: {value, address}, maturityHeight }
async siacoinUtxos(address) {
const list = await this._get(`/addresses/${encodeURIComponent(address)}/utxos/siacoin`);
return Array.isArray(list) ? list : [];
}
// Broadcast a v2 transaction (or a set). explored's POST /txpool/broadcast
// takes { transactions: [v1…], v2Transactions: [v2…] } — we only ever
// send the v2 form (Aegis's tx builder is v2-only). Returns nothing
// on success; a 200 means "accepted into the pool".
async broadcastV2(v2TxOrSet) {
const set = Array.isArray(v2TxOrSet) ? v2TxOrSet : [v2TxOrSet];
const url = this._base + "/txpool/broadcast";
const body = JSON.stringify({ transactions: [], v2Transactions: set });
const r = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body,
});
if (!r.ok) {
const errBody = await r.text().catch(() => "");
throw new Error(`siascan broadcast ${r.status}: ${errBody.slice(0, 250)}`);
}
// explored responds 200 with an empty body on success; nothing to
// parse. Caller derives the txid client-side from the signed tx.
return true;
}
}
// Compute a per-event delta for the SUBJECT address. explored returns
// rich event structures; we normalise to Aegis's { txid, delta, to,
// confirmations, time } row shape. delta is a signed BigInt-safe string.
// Positive = received, negative = spent.
function normaliseEvents(rawEvents, subjectAddress, tipHeight) {
const out = [];
for (const ev of rawEvents || []) {
const kind = String(ev?.type || "");
const height = Number(ev?.index?.height || ev?.maturityHeight || 0);
const confirmations = height && tipHeight ? Math.max(0, tipHeight - height + 1) : 0;
// Sum outputs to us minus inputs from us.
let received = 0n, spent = 0n, other = null;
const dat = ev?.data || {};
const outputs = dat?.siacoinOutputs || dat?.transaction?.siacoinOutputs || [];
const inputs = dat?.siacoinInputs || dat?.transaction?.siacoinInputs || [];
for (const o of outputs) {
const addr = o?.siacoinOutput?.address || o?.address || null;
const val = toBigStr(o?.siacoinOutput?.value || o?.value);
if (addr === subjectAddress) received += BigInt(val);
else if (!other) other = addr;
}
for (const i of inputs) {
const addr = i?.parent?.siacoinOutput?.address || i?.address || null;
const val = toBigStr(i?.parent?.siacoinOutput?.value || i?.value);
if (addr === subjectAddress) spent += BigInt(val);
}
const delta = (received - spent).toString();
out.push({
txid: String(ev?.id || ""),
delta,
to: (BigInt(delta) < 0n && other) ? other : null,
from: null,
fee: null,
time: Number(ev?.timestamp || 0),
confirmations,
status: confirmations > 0 ? "confirmed" : "pending",
kind,
});
}
return out;
}
function toBigStr(x) {
if (typeof x === "string") return x;
if (typeof x === "bigint") return x.toString();
return String(x || "0");
}
return { SiascanClient, normaliseEvents, DEFAULT_BASE };
};

View file

@ -37,7 +37,22 @@ module.exports = function makeTx({ sha256 }) {
const outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout));
const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE;
const feeFor = (nIn, nOut, satPerByte) => Math.ceil(estimateSize(nIn, nOut) * satPerByte);
// OP_RETURN data outputs vary — approximate with 12 + payload bytes to
// keep the fee estimate honest without threading a full byte size through.
const estimateSizeWithData = (nIn, nOut, dataBytes) => estimateSize(nIn, nOut) + (dataBytes ? 12 + dataBytes : 0);
const feeFor = (nIn, nOut, satPerByte, dataBytes = 0) => Math.ceil(estimateSizeWithData(nIn, nOut, dataBytes) * satPerByte);
// Build a memo protocol OP_RETURN script from a plain UTF-8 string. Layout:
// 0x6a OP_RETURN
// [pushdata] memo bytes (up to 220 to stay under standardness)
// The output's value is always 0 and it's flagged { data: true } so the
// dust check in select() skips it. Callers can pass raw bytes if they
// want to embed a non-UTF8 payload; strings are the common case.
function memoScript(input) {
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input || 0);
if (bytes.length > 220) throw new Error(`memo too long: ${bytes.length} bytes (max 220)`);
return concat(Uint8Array.from([0x6a]), pushdata(bytes));
}
// inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }]
// outputs: [{ value, script(Uint8Array) }]
@ -81,42 +96,51 @@ module.exports = function makeTx({ sha256 }) {
return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) };
}
// Largest-first accumulation. `targets` = [{ value, script }]; returns
// { inputs, outputs, fee, change } or throws when funds don't cover it.
// sendMax: spend every UTXO into targets[0] and no change.
// Largest-first accumulation. `targets` = [{ value, script, data? }]:
// data:true — an OP_RETURN memo output; value MUST be 0 and doesn't
// count toward the send amount or the dust check.
// sendMax spends every UTXO into the sole non-data target with no change.
// Data outputs are preserved verbatim in every returned outputs array.
function select(utxos, targets, satPerByte, changeScript, { sendMax = false } = {}) {
const dataOuts = targets.filter((t) => t.data);
const spendOuts = targets.filter((t) => !t.data);
const dataBytes = dataOuts.reduce((a, t) => a + (t.script?.length || 0), 0);
const sorted = utxos.slice().sort((a, b) => b.value - a.value);
const total = sorted.reduce((a, u) => a + u.value, 0);
if (sendMax) {
if (targets.length !== 1) throw new Error("send max needs exactly one recipient");
const fee = feeFor(sorted.length, 1, satPerByte);
if (spendOuts.length !== 1) throw new Error("send max needs exactly one recipient");
const fee = feeFor(sorted.length, 1 + dataOuts.length, satPerByte, dataBytes);
const value = total - fee;
if (!sorted.length || value < DUST) throw new Error("balance too small to send");
return { inputs: sorted, outputs: [{ value, script: targets[0].script }], fee, change: 0 };
return { inputs: sorted, outputs: [{ value, script: spendOuts[0].script }, ...dataOuts], fee, change: 0 };
}
const want = targets.reduce((a, t) => a + t.value, 0);
for (const t of targets) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`);
const want = spendOuts.reduce((a, t) => a + t.value, 0);
for (const t of spendOuts) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`);
const chosen = []; let sum = 0;
for (const u of sorted) {
chosen.push(u); sum += u.value;
const feeWithChange = feeFor(chosen.length, targets.length + 1, satPerByte);
const feeWithChange = feeFor(chosen.length, spendOuts.length + dataOuts.length + 1, satPerByte, dataBytes);
if (sum >= want + feeWithChange) {
const change = sum - want - feeWithChange;
if (change >= DUST) {
return { inputs: chosen, outputs: [...targets, { value: change, script: changeScript }], fee: feeWithChange, change };
return {
inputs: chosen,
outputs: [...spendOuts, { value: change, script: changeScript }, ...dataOuts],
fee: feeWithChange, change,
};
}
// Change would be dust: fold it into the fee, one output fewer.
const fee = sum - want;
return { inputs: chosen, outputs: targets.slice(), fee, change: 0 };
return { inputs: chosen, outputs: [...spendOuts, ...dataOuts], fee, change: 0 };
}
const feeNoChange = feeFor(chosen.length, targets.length, satPerByte);
const feeNoChange = feeFor(chosen.length, spendOuts.length + dataOuts.length, satPerByte, dataBytes);
if (sum >= want + feeNoChange && sum - want - feeNoChange < DUST) {
return { inputs: chosen, outputs: targets.slice(), fee: sum - want, change: 0 };
return { inputs: chosen, outputs: [...spendOuts, ...dataOuts], fee: sum - want, change: 0 };
}
}
const short = want + feeFor(Math.max(1, sorted.length), targets.length + 1, satPerByte) - total;
const short = want + feeFor(Math.max(1, sorted.length), spendOuts.length + dataOuts.length + 1, satPerByte, dataBytes) - total;
throw new Error(`insufficient funds: need about ${short} more sat`);
}
return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, toHex, fromHex, dsha };
return { SIGHASH_ALL_FORKID, DUST, serialize, sighash, sign, select, feeFor, estimateSize, memoScript, toHex, fromHex, dsha, concat, pushdata };
};

View file

@ -1,6 +1,15 @@
// Wallet state machine on top of an electrum client and a WalletKeys tree:
// address discovery (gap limit), balance, history with per-tx deltas, UTXO
// set and send construction. Knows nothing about UI or IPC.
//
// 0.7.0: CashTokens read + coin-selection guard. Every UTXO fetched from
// listunspent is enriched with its scriptPubKey and passed through
// cashtokens.decodePrefixedScript. Token UTXOs are tagged { token: {…} }
// and pooled into state.tokenBalances (category → aggregate); they are
// deliberately EXCLUDED from plain-BCH coin selection so no token UTXO
// gets accidentally spent (and its category burned) on a routine send.
const cashtokens = require("./cashtokens.js");
module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) {
const GAP = 20;
const HISTORY_LIMIT = 25;
@ -11,7 +20,8 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
watched: new Map(), // scripthash -> entry
height: 0,
balance: { confirmed: 0, unconfirmed: 0 },
utxos: [], // { txid, vout, value, height, entry }
utxos: [], // { txid, vout, value, height, entry, token? }
tokenBalances: {}, // { <categoryHex>: { fungible: bigint, nfts: [...], utxoIds: [...] } }
history: [], // newest first
receiveIndex: 0,
scanning: false,
@ -70,12 +80,70 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
async function loadUtxos() {
const lists = await Promise.all([...state.watched.values()].map(async (e) => {
const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]);
return (Array.isArray(u) ? u : []).map((x) => ({ txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e }));
return (Array.isArray(u) ? u : []).map((x) => ({
txid: x.tx_hash, vout: x.tx_pos, value: x.value, height: x.height, entry: e,
}));
}));
state.utxos = lists.flat();
let confirmed = 0, unconfirmed = 0;
for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; }
state.balance = { confirmed, unconfirmed };
const utxos = lists.flat();
// Enrich each UTXO with its scriptPubKey so cashtokens.decodePrefixedScript
// can classify it. getTx() already caches to disk, so a re-scan on a
// wallet with hundreds of UTXOs only fetches new ones. Failures are
// tolerated — an un-classifiable UTXO is treated as bare BCH, which
// is the conservative choice (worst case: user sees BCH value in
// balance but the coin selector still won't pick it if its token
// status matters — it just won't participate in a token send either).
const tokenBalances = {};
await Promise.all(utxos.map(async (u) => {
try {
const t = await getTx(u.txid);
const out = t.vout[u.vout];
if (!out || !out.scriptHex) return;
const scriptBytes = tx.fromHex(out.scriptHex);
const { token, lockingScript } = cashtokens.decodePrefixedScript(scriptBytes);
u.scriptHex = out.scriptHex;
u.lockingScriptHex = Array.from(lockingScript, (x) => x.toString(16).padStart(2, "0")).join("");
if (token) {
u.token = token;
const cat = token.categoryHex;
if (!tokenBalances[cat]) tokenBalances[cat] = { fungible: 0n, nfts: [], utxoIds: [] };
if (token.hasAmount) tokenBalances[cat].fungible += token.amount;
if (token.hasNft) {
tokenBalances[cat].nfts.push({
utxoId: `${u.txid}:${u.vout}`,
commitmentHex: token.commitmentHex,
capability: token.capability,
capabilityLabel: token.capabilityLabel,
});
}
tokenBalances[cat].utxoIds.push(`${u.txid}:${u.vout}`);
}
} catch (e) {
log("utxo classify failed:", u.txid + ":" + u.vout, e?.message || e);
}
}));
state.utxos = utxos;
// Serialize BigInt fungible amounts as decimal strings for the snapshot
// (JSON.stringify chokes on BigInt otherwise).
const serializedBalances = {};
for (const [cat, bal] of Object.entries(tokenBalances)) {
serializedBalances[cat] = {
fungible: bal.fungible.toString(),
nfts: bal.nfts,
utxoCount: bal.utxoIds.length,
};
}
state.tokenBalances = serializedBalances;
// Balance number is BCH sat only — token UTXOs still carry a small
// BCH value (dust minimum for the prefix), but treating that as
// spendable would let a routine send burn the token. Track total
// separately as bareBalance so the panel can still show "there's
// BCH sitting in token UTXOs".
let confirmed = 0, unconfirmed = 0, tokenLocked = 0;
for (const u of utxos) {
if (u.token) { tokenLocked += u.value; continue; }
if (u.height > 0) confirmed += u.value; else unconfirmed += u.value;
}
state.balance = { confirmed, unconfirmed, tokenLocked };
}
async function getTx(txid) {
@ -197,7 +265,10 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
}
// targets: [{ to, value }] (value in sats; ignored for sendMax) -> unsigned plan.
function plan({ targets, feeRate = 1, sendMax = false }) {
// memo: optional string (UTF-8, ≤220 bytes) — attached as an OP_RETURN
// data output. Zero value, no dust check, fee estimate accounts
// for the extra bytes. Passing "" disables the memo.
function plan({ targets, feeRate = 1, sendMax = false, memo = "" }) {
const rate = Math.min(10, Math.max(1, Number(feeRate) || 1));
const outs = targets.map((t) => {
const a = cashaddr.parseAny(t.to, sha256, keys.prefix);
@ -206,10 +277,25 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
: Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
});
// Spend confirmed coins first; unconfirmed only when needed.
const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0));
if (memo) outs.push({ value: 0, script: tx.memoScript(memo), data: true, memo });
// Spend confirmed coins first; unconfirmed only when needed. Token
// UTXOs are excluded entirely — burning a category by dropping its
// prefix is not a mistake we can undo, so a plain BCH send must
// never pull one. Token sends have their own code path with
// { includeToken: category } later.
const spendable = state.utxos
.filter((u) => !u.token)
.slice()
.sort((a, b) => (b.height > 0) - (a.height > 0));
const sel = tx.select(spendable, outs, rate, changeEntry().script, { sendMax });
return { ...sel, feeRate: rate, recipients: outs.map((o, i) => ({ to: o.to, value: sel.outputs[i].value })) };
// recipients only lists spendable (non-data) outputs, keeping the
// panel's summary honest — the memo is surfaced separately as .memo.
const spendable_outs = sel.outputs.filter((o) => !o.data);
return {
...sel, feeRate: rate,
recipients: spendable_outs.map((o, i) => ({ to: outs[i]?.to, value: o.value })),
memo: memo || null,
};
}
async function signAndBroadcast(p) {
@ -232,6 +318,10 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
height: state.height,
history: state.history,
utxoCount: state.utxos.length,
// CashTokens balances, keyed by category hex. Empty object when the
// wallet holds no token UTXOs. Serialised BigInts (fungible amounts)
// come across as decimal strings — panel formats via BigInt again.
tokenBalances: state.tokenBalances,
scanning: state.scanning,
error: state.error,
};

View file

@ -37,9 +37,11 @@
.bal .big small { font-size: 13px; color: var(--mut); font-weight: 500; margin-left: 4px; }
.bal .sub { color: var(--dim); font-size: 11.5px; display: flex; justify-content: space-between; gap: 8px; }
.bal .sub .netlbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Full-panel sheet — fills the sidebar so long wallet lists and the
import form aren't squeezed into a small popover. */
#drop { position: fixed; left: 0; right: 0; top: 60px; bottom: 0;
/* Full-panel sheet — fills most of the sidebar but stops above the
footer so the aegis.x brand + version chip stay visible. Users
opening + should still know which build they're on and be able
to hit the update button. */
#drop { position: fixed; left: 0; right: 0; top: 60px; bottom: 30px;
background: var(--panel); border-top: 1px solid var(--line);
box-shadow: 0 -6px 26px rgba(0,0,0,.35); z-index: 20;
display: flex; flex-direction: column; }
@ -66,9 +68,37 @@
#drop .netgroup[hidden] { display: none; }
#drop .netchoice { padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--mut); }
#drop .netchoice:hover { background: rgba(255,255,255,.06); color: var(--ink); }
/* Currency-browse network chip row (0.8.0). Sits above the wallet list
under a coin; clicking a chip switches which subnetwork's wallets
are visible AND persists the choice per chain. */
#drop .brnetrow { display: flex; gap: 6px; padding: 8px 8px 4px; flex-wrap: wrap; border-bottom: 1px solid var(--line); }
#drop .brnet { background: transparent; border: 1px solid var(--line); color: var(--mut);
border-radius: 999px; padding: 3px 10px; font: inherit; font-size: 12px; cursor: pointer;
display: inline-flex; align-items: center; gap: 4px; }
#drop .brnet:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); }
#drop .brnet.on { background: rgb(from var(--acid, #d6ff3d) r g b / .14); border-color: rgb(from var(--acid, #d6ff3d) r g b / .55);
color: var(--acid, #d6ff3d); font-weight: 600; }
#drop .brnet .hint { color: currentColor; opacity: .65; font-size: 11px; }
.ttag { display: inline-block; font-size: 9.5px; letter-spacing: .06em; padding: 1px 5px; border-radius: 3px;
background: rgba(224,179,65,.18); color: #e0b341; font-weight: 700; vertical-align: middle; margin-left: 2px; }
#hNet { color: var(--dim); font-size: 11px; margin-left: 4px; font-weight: 500; }
/* 0.8.4: hNet became a proper chip on its own row (.netrow) so the
network the wallet is on is easy to spot AND easy to switch. */
.netrow { display: flex; margin-top: 6px; padding: 0 2px; }
.netchip { background: rgba(255,255,255,.05); border: 1px solid var(--line);
color: var(--mut); border-radius: 999px; padding: 3px 10px 3px 8px;
font: inherit; font-size: 11.5px; cursor: pointer; display: inline-flex;
align-items: center; gap: 6px; line-height: 1.2; }
.netchip:hover { border-color: var(--acid, #d6ff3d); color: var(--acid, #d6ff3d); }
.netchip::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; opacity: .7; }
/* Currency-picker search + all-coins catalogue (0.8.4). */
#drop .pickersearch { padding: 8px; border-bottom: 1px solid var(--line); }
#drop .pickersearch input { width: 100%; box-sizing: border-box; background: rgba(255,255,255,.04);
border: 1px solid var(--line); color: var(--ink); border-radius: 7px;
padding: 7px 10px; font: inherit; font-size: 12.5px; outline: none; }
#drop .pickersearch input:focus { border-color: rgb(from var(--acid, #d6ff3d) r g b / .55); }
#drop .catgroup { padding: 4px 6px 2px 10px; color: var(--dim); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; }
#drop .row.unowned { opacity: .8; }
#drop .row.unowned .v { color: var(--acid, #d6ff3d); font-weight: 600; font-size: 11px; }
.fiat { color: var(--dim); font-size: 12.5px; margin-left: 10px; font-weight: 500; letter-spacing: .2px; }
.portfolio { margin-top: 6px; color: var(--mut); font-size: 11.5px; }
.portfolio b { color: var(--ink); font-weight: 600; }
@ -78,6 +108,39 @@
padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1;
display: inline-flex; align-items: center; justify-content: center; }
.chip:hover { border-color: var(--acid); color: var(--acid); }
/* Edit-this-wallet button living inside the label group. Compact and
borderless so it reads as an inline affordance, not a separate
action chip. Fades in on hover of the label row so the header
itself stays visually quiet when the user isn't targeting it. */
.editchip { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 4px; border-radius: 4px; font: inherit; font-size: 12px; line-height: 1;
opacity: .5; transition: opacity .12s, color .12s; margin-left: 2px; }
.picker:hover .editchip { opacity: 1; }
.editchip:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* Send/Receive mode toggle (0.8.0). Segmented control at the top of a
tab; picks whether the body shows the normal flow or the consolidate
inline picker. */
.modetoggle { display: flex; gap: 0; border: 1px solid var(--line); border-radius: 8px; padding: 3px;
margin-bottom: 12px; background: rgba(255,255,255,.03); }
.modetoggle[hidden] { display: none; }
.modetoggle button { flex: 1; background: transparent; border: 0; color: var(--dim);
padding: 6px 8px; font: inherit; font-size: 12.5px; border-radius: 6px;
cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 5px; }
.modetoggle button.on { background: rgb(from var(--acid, #d6ff3d) r g b / .16); color: var(--acid, #d6ff3d); font-weight: 600; }
.modetoggle button:hover:not(.on) { color: var(--ink); }
.modetoggle .hint { color: currentColor; opacity: .65; font-size: 11px; }
/* Settings section chip nav (0.8.2). Segmented row that pages between
Security / Session / Wallet / Prices / Sites / About cards without
scrolling through the whole tab. */
.setsecnav { display: flex; flex-wrap: wrap; gap: 4px; border: 1px solid var(--line);
border-radius: 8px; padding: 3px; margin-bottom: 14px;
background: rgba(255,255,255,.03); }
.setsecnav button { flex: 1 0 auto; min-width: 62px; background: transparent; border: 0;
color: var(--dim); padding: 6px 10px; font: inherit; font-size: 12px;
border-radius: 6px; cursor: pointer; }
.setsecnav button.on { background: rgb(from var(--acid, #d6ff3d) r g b / .16);
color: var(--acid, #d6ff3d); font-weight: 600; }
.setsecnav button:hover:not(.on) { color: var(--ink); }
nav { display: flex; border-bottom: 1px solid var(--line); background: var(--panel); }
nav button { flex: 1; padding: 9px 0 8px; border: 0; background: transparent; color: var(--mut); cursor: pointer;
font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; }
@ -262,6 +325,14 @@
.wstrip .wcname .wnetpill.wchipnet { background: rgb(from var(--acid, #d6ff3d) r g b / .18);
color: var(--acid, #d6ff3d); font-weight: 600; }
.wstrip .wcname .wnetpill.wtestnet { background: rgba(224,179,65,.18); color: #e0b341; font-weight: 600; }
/* Multi-wallet count pill inside the ticker cell. When the group has
more than one wallet it doubles as the "swap active wallet" trigger
(▾ chevron), independent of the row's own click target which now
selects the current active wallet directly. */
.wstrip .wcname .wgcount.wgpick { cursor: pointer; }
.wstrip .wcname .wgcount.wgpick:hover { background: rgba(255,255,255,.12); color: var(--acid, #d6ff3d); }
.wstrip .wcname .wgcount .wgchev { color: var(--dim); font-size: 9px; margin-left: 1px; }
.wstrip .wcname .wgcount.wgpick:hover .wgchev { color: var(--acid, #d6ff3d); }
.wstrip .wcname .wgcount { color: var(--dim); font-size: 10.5px; padding: 0 6px; border-radius: 999px;
background: rgba(255,255,255,.06); font-variant-numeric: tabular-nums; line-height: 1.4; }
.wstrip .wcname .wcprice { color: var(--acid, #d6ff3d); font-size: 11px; font-weight: 600;
@ -313,21 +384,86 @@
.wstrip .wcoinhead .wctitle { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .wcoinhead .wcount { color: var(--dim); font-size: 11.5px; font-weight: 500; }
.wstrip .warow { display: grid; grid-template-columns: 16px minmax(60px,1fr) auto auto auto;
gap: 6px; align-items: center; padding: 5px 4px;
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 30px; }
/* Inline address-list row (per-coin drilldown). 0.6.35 layout: strictly
columnar so every row's fields line up in the same x positions no
matter how long each label happens to be. The address column is the
only flex one (minmax 0/1fr) — it fills whatever's left after the
fixed cells and truncates with an ellipsis, so a wider sidebar shows
more of the address without the label/balance jumping around. Every
other column has an explicit width — label pill is fixed to 68px so
"a" and "anthem…" occupy the same slot, amount is fixed to 90px so
"0" and "0.000123" right-align identically, actions are fixed to
46px. Net result: columns look like a table, not a flex mess. */
.wstrip .warow { display: grid;
grid-template-columns: 16px minmax(0,1fr) 22px 68px 90px 46px;
gap: 6px; align-items: center; padding: 4px 4px;
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 28px; }
.wstrip .warow:hover { background: rgba(255,255,255,.04); }
.wstrip .warow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .35); }
.wstrip .warow .waname { font-size: 13px; color: var(--ink); overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-weight: 500; }
.wstrip .warow .waaddr { font: 11px/1.15 ui-monospace, Consolas, monospace; color: var(--dim); margin-top: 2px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .warow .waamt { text-align: right; font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--ink); }
.wstrip .warow .wafiat { font-size: 11px; color: var(--dim); }
.wstrip .warow .waaddr { font: 12px/1.15 ui-monospace, Consolas, monospace; color: var(--ink);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
/* Copy chip anchored right after the address. Fixed 22px column so the
copy button sits at the same x on every row. */
.wstrip .warow .wacopy { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 4px; border-radius: 4px; font-size: 11px; line-height: 1;
transition: color .12s; justify-self: start; }
.wstrip .warow .wacopy:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
.wstrip .warow .wacopy.copied { color: var(--acid, #d6ff3d); }
/* Label pill: fixed 68px column. Even an empty label renders an
invisible placeholder so the amount column stays put. Long labels
truncate with ellipsis inside the pill (max-width: 100% of column). */
.wstrip .warow .walabel { font-size: 11px; color: var(--mut);
padding: 1px 6px; border-radius: 999px;
background: rgba(255,255,255,.06);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 100%; box-sizing: border-box;
justify-self: center; }
.wstrip .warow .walabel:empty { visibility: hidden; }
/* Balance shares a single line with everything else — no vertical
amount/fiat stack. Ticker follows the number in a dim tone so the
row reads "0 BCH" at a glance. Right-aligned within the fixed
amount column so short and long numbers line up. */
.wstrip .warow .waamt { text-align: right; font-variant-numeric: tabular-nums;
font-size: 12.5px; color: var(--ink); white-space: nowrap;
display: inline-flex; align-items: baseline; gap: 4px;
justify-self: end; overflow: hidden; }
.wstrip .warow .waamt .watkr { color: var(--dim); font-size: 11px; font-weight: 500; }
.wstrip .warow .wact { background: transparent; border: 0; color: var(--dim); cursor: pointer;
padding: 2px 5px; border-radius: 4px; font-size: 12.5px; line-height: 1; }
.wstrip .warow .wact:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* Destructive strip action (remove wallet). Stays quiet until hovered so
a row of icons doesn't read as a row of warnings. */
.wstrip .wact.wactdel:hover { color: var(--danger, #f6768a); background: rgb(from var(--danger, #f6768a) r g b / .12); }
/* Per-address asset list (0.8.8). The count chip expands the row into a
nested list of what that ONE address holds beyond the native coin. */
.wstrip .waassets { background: rgba(255,255,255,.06); border: 0; color: var(--dim); cursor: pointer;
font: inherit; font-size: 10px; padding: 1px 6px; border-radius: 999px; line-height: 1.5; }
.wstrip .waassets:hover, .wstrip .waassets.on { color: var(--acid, #d6ff3d); background: rgb(from var(--acid, #d6ff3d) r g b / .14); }
.wstrip .waassetlist { padding: 2px 6px 6px 26px; display: flex; flex-direction: column; gap: 2px; }
.wstrip .waasset { display: grid; grid-template-columns: minmax(0,1fr) auto auto; gap: 8px; align-items: baseline;
font-size: 11px; color: var(--mut); padding: 2px 4px; border-radius: 4px; }
.wstrip .waasset:hover { background: rgba(255,255,255,.04); }
.wstrip .waasset .waaname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wstrip .waasset .waaid { color: var(--dim); font-size: 10px; }
.wstrip .waasset .waaamt { font-variant-numeric: tabular-nums; color: var(--ink); }
/* Coin drilldown header: shows the coin's per-unit price + running
total of the addresses below, so the aggregate context isn't lost
when the user is deep in the per-address view. */
.wstrip .wcoinsub { display: flex; align-items: baseline; gap: 8px; padding: 2px 4px 5px 4px;
font-size: 11.5px; color: var(--mut); border-bottom: 1px solid var(--line);
margin-bottom: 3px; }
.wstrip .wcoinsub .wprice { color: var(--acid, #d6ff3d); font-weight: 600; font-variant-numeric: tabular-nums;
text-shadow: 0 0 5px rgb(from var(--acid, #d6ff3d) r g b / .30); }
.wstrip .wcoinsub .wsep { color: var(--dim); }
.wstrip .wcoinsub .wtot { color: var(--ink); font-variant-numeric: tabular-nums; font-weight: 500; }
.wstrip .wcoinsub .wtotfiat { color: var(--dim); font-variant-numeric: tabular-nums; }
/* Adapter-error line above the address rows. Shown only when at least
one wallet has a non-null .error — makes silent RPC failures visible
instead of the display quietly rendering 0. */
.wstrip .wcoinerr { color: #e05a5a; font-size: 11px; padding: 4px 6px;
background: rgba(224,90,90,.08); border-radius: 4px;
margin-bottom: 4px; overflow-wrap: anywhere; }
/* Full-panel lock screen — takes over the entire panel below the aegis
footer when the vault is locked or awaiting first-time setup. Rest of
@ -343,10 +479,17 @@
filter: drop-shadow(0 0 12px rgb(from var(--acid, #d6ff3d) r g b / .35)); }
#lockScreen h1 { font: 600 16px/1.3 inherit; margin: 0 0 4px 0; letter-spacing: .2px; }
#lockScreen .subhint { color: var(--mut); font-size: 12px; max-width: 320px; margin: 0 0 20px 0; }
#lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: left; }
/* The form inherits the lock screen's centred alignment — it used to
force text-align:left, which left the setup screen's helper copy
running ragged against a centred title, mark and description. */
#lockScreen .lockform { width: min(320px, 100%); display: flex; flex-direction: column; gap: 10px; text-align: center; }
#lockScreen .lockform input[type=password],
#lockScreen .lockform input[type=text],
#lockScreen .lockform textarea { text-align: center; }
#lockScreen .lockform input[type=text] { text-align: center; }
/* The mnemonic stays left-aligned on purpose: 12/24 words wrap across
several lines, and centring makes them ragged on both edges, which is
exactly the wrong thing when someone is checking a seed word by word. */
#lockScreen .lockform textarea { text-align: left; }
#lockScreen .lockform .hint { text-align: center; }
#lockScreen .altline { color: var(--dim); font-size: 11.5px; margin-top: 12px; text-align: center; }
#lockScreen .altline a { color: var(--acid, #d6ff3d); cursor: pointer; text-decoration: none; }
#lockScreen .altline a:hover { text-decoration: underline; }
@ -396,14 +539,25 @@
<div class="t">
<span class="badge" id="hBadge"></span>
<span class="lbl" id="hLabel">Aegis Wallet</span>
<span id="hNet"></span>
<!-- Edit chip sits inline with the wallet name so it reads as
"edit THIS wallet". Was in picker-actions on the far right
until 0.7.2, which mixed poorly with the global + button. -->
<button type="button" id="hManage" class="editchip" title="Edit this wallet — rename, derivation path, remove">✎</button>
</div>
<div class="picker-actions">
<button type="button" id="hAdd" class="chip" title="Add a new wallet">+</button>
<button type="button" id="hMore" class="chip" title="Import / Connect / About">⋯</button>
<button type="button" id="hManage" class="chip" title="Edit this wallet — rename, derivation path, remove">✎</button>
<!-- Single right-corner chip. Opens a compact popover menu with
Create new / Import / Connect / About — replacing the old
three-chip cluster (+ ⋯ ✎). Method chosen there routes into
the coin picker inside #drop or an appropriate modal. -->
<button type="button" id="hAdd" class="chip" title="Add or import a wallet">+</button>
</div>
</div>
<!-- 0.8.4: network picker is its own row below the coin label so it
reads as "the network I'm on right now" instead of a tiny suffix
next to the wallet name. Click cycles / opens the network menu. -->
<div class="netrow" id="hNetRow" hidden>
<button type="button" class="netchip" id="hNet" title="Switch network"></button>
</div>
<div id="drop" hidden></div>
<div class="bal">
<div class="big">
@ -432,6 +586,15 @@
<div id="gate" class="gate" hidden></div>
<div id="tabs">
<section id="tab-receive">
<!-- Segmented mode toggle (0.8.0). Switches the tab body between
the normal Receive view (QR + address) and the Consolidate
view (inline picker for sweeping other-wallet balances into
this one). Hidden when there is nothing to consolidate. -->
<div id="rcvModeToggle" class="modetoggle" hidden>
<button data-rcv-mode="receive" class="on" type="button">Receive</button>
<button data-rcv-mode="consolidate" type="button">Consolidate <span id="rcvConsolidateCount" class="hint"></span></button>
</div>
<div id="rcvNormal">
<div class="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div>
<div class="card">
<div class="lbl">Receiving address <span id="addrMeta"></span></div>
@ -446,10 +609,30 @@
<div class="card" id="tokensCard" hidden style="margin-top:12px">
<div class="lbl">Tokens</div>
<div id="tokensList" class="kv"></div>
<div class="hint">SPL tokens held by this wallet. Send by picking one under the Send tab's Asset dropdown.</div>
<div class="hint" id="tokensHint">Tokens held by this wallet.</div>
</div>
</div><!-- /rcvNormal -->
<!-- Consolidate view (inline, replaces Receive body when active). -->
<div id="rcvConsolidate" hidden>
<div id="rcvConsolidateInline"></div>
</div>
</section>
<section id="tab-send" hidden>
<!-- Send/Consolidate mode toggle (0.8.0). Same pattern as Receive. -->
<div id="sendModeToggle" class="modetoggle" hidden>
<button data-send-mode="send" class="on" type="button">Send</button>
<button data-send-mode="consolidate" type="button">Consolidate <span id="sendConsolidateCount" class="hint"></span></button>
</div>
<div id="sendNormal">
<!-- Legacy chip retained but hidden — 0.7.7's chip is replaced by
the mode toggle above. Kept in the DOM so existing panel.js
code that reads it doesn't NPE mid-migration. -->
<div id="consolidateChipWrap" hidden style="margin-bottom:12px">
<button id="consolidateChip" class="btn sm" type="button" style="width:100%;text-align:left;display:flex;align-items:center;gap:8px;justify-content:space-between">
<span>⇢ Consolidate balances into <b id="consolidateChipDest">this wallet</b></span>
<span class="hint" id="consolidateChipCount"></span>
</button>
</div>
<div class="field" id="sendAssetField" hidden>
<div class="lbl">Asset</div>
<select id="sendAsset"></select>
@ -472,6 +655,15 @@
<div class="lbl">Fee</div>
<div class="fee"><input type="range" id="feeRate" min="1" max="5" step="1" value="1"><span class="v" id="feeLbl">1 sat/B</span></div>
</div>
<!-- Optional OP_RETURN memo. BCH-only for now (UTXO chains only);
the field hides when a non-BCH wallet is selected. 220 byte cap
keeps the tx under standardness relay policy. Memo is public on
the ledger — the placeholder text warns before someone types. -->
<div class="field" id="memoField" hidden>
<div class="lbl">Memo <span class="hint" style="margin-left:8px;font-weight:normal">optional · public on-chain · ≤220 bytes</span></div>
<input type="text" id="sendMemo" maxlength="220" spellcheck="false" autocomplete="off"
placeholder="e.g. Invoice #1234 — visible to anyone">
</div>
<div class="card">
<div class="summary">
<div class="k">Amount</div><div class="v" id="sumAmt">—</div>
@ -481,11 +673,29 @@
</div>
<div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div>
<div class="msg" id="sendMsg" hidden></div>
</div><!-- /sendNormal -->
<div id="sendConsolidate" hidden>
<div id="sendConsolidateInline"></div>
</div>
</section>
<section id="tab-history" hidden>
<div id="txlist"></div>
</section>
<section id="tab-settings" hidden>
<!-- 0.8.2: Settings sectioned. Chip row at top picks which section
is visible; each section wraps a group of related cards. The
chain-specific cards (bchSettings / trxSettings / …) sit
inside the "Wallet" section and their own hidden-vs-shown
toggle (per selected chain) still applies within it. -->
<div class="setsecnav" id="setsecnav">
<button data-setsec="security" class="on" type="button">Security</button>
<button data-setsec="session" type="button">Session</button>
<button data-setsec="wallet" type="button">Wallet</button>
<button data-setsec="prices" type="button">Prices</button>
<button data-setsec="sites" type="button">Sites</button>
<button data-setsec="about" type="button">About</button>
</div>
<div data-setsec-body="security">
<!-- Cross-cutting security / policy controls, ahead of anything
per-wallet or chain-specific. Present even when the vault is
still locked so users can set up a PIN or read the multi-sig
@ -515,6 +725,8 @@
</div>
</div>
</div><!-- /security section (multi-sig card lives inside it below) -->
<div data-setsec-body="session" hidden>
<!-- Session: stay-signed-in + idle auto-lock + manual sign-out.
Uses electron.safeStorage under the hood so the master password
is only decryptable under this OS user account. When "Lock on
@ -570,7 +782,9 @@
</div>
</div>
</div>
</div><!-- /session section -->
<div data-setsec-body="security" hidden>
<div class="card gsec" id="genMultiSig" style="margin-bottom:14px">
<div class="gtitle">Second-device approval <span class="gsoon">soon</span></div>
<div class="gline">
@ -583,7 +797,9 @@
</div>
</div>
</div>
</div><!-- /security-multisig -->
<div data-setsec-body="wallet" hidden>
<div class="card" id="walletManage" style="margin-bottom:14px">
<div class="lbl">This wallet</div>
<div class="field" style="margin-top:6px;margin-bottom:8px">
@ -771,18 +987,18 @@
</div>
</div>
<div class="card" style="margin-top:16px">
</div><!-- /wallet section -->
<div data-setsec-body="prices" hidden>
<div class="card" style="margin-top:0">
<div class="lbl">Fiat prices</div>
<div class="hint" style="margin-bottom:10px">
Off by default. When enabled, Aegis polls the chosen oracle for USD prices
on each supported coin. No keys and no address data are ever sent — but the
oracle sees your IP + a User-Agent every poll, which is a signal that a
wallet is open on this machine.
</div>
<div class="field">
<div class="lbl">Oracle</div>
<select id="pricesSource" style="width:100%;padding:7px 9px;border-radius:7px;background:var(--panel);border:1px solid var(--line);color:var(--ink);font-size:13px"></select>
<div class="hint" id="pricesSourceHint"></div>
Off by default. When enabled, Aegis polls every supported oracle in
parallel and reconciles the results — two or more sources agreeing
within ±3% form the trusted price, so no single oracle can quietly
make Aegis show a wrong number. Every enabled oracle sees your IP
+ a User-Agent every poll, which is a signal that a wallet is open
on this machine.
</div>
<div class="actions" style="align-items:center;gap:12px">
<label class="switch">
@ -792,13 +1008,41 @@
<button class="btn sm" id="refreshPrices" hidden>Refresh now</button>
<span class="hint" id="pricesStatus" style="margin-left:auto"></span>
</div>
<!-- Per-source status: which oracles are up, which are down, and
the last per-chain reconciliation method (majority / median /
single). Populated from the priceFeed snapshot. -->
<div id="pricesSources" class="hint" style="margin-top:10px"></div>
</div>
<!-- Hidden legacy select so panel.js code that reads .value doesn't
NPE while we transition; select stays hidden and empty. -->
<select id="pricesSource" hidden></select>
<span id="pricesSourceHint" hidden></span>
</div><!-- /prices section -->
<div class="card" style="margin-top:16px">
<div data-setsec-body="sites" hidden>
<div class="card" style="margin-top:0">
<div class="lbl">Connected sites</div>
<div class="hint">Sites allowed to see your address, allowances for silent BCH payments, and Tron dapps you've connected. Message signing always asks.</div>
<div id="sites" class="kv"></div>
</div>
</div><!-- /sites section -->
<div data-setsec-body="about" hidden>
<div class="card" style="margin-top:0">
<div class="lbl">About Aegis</div>
<div class="hint" style="margin-bottom:10px">
Aegis is Silent Mode's built-in multi-chain wallet. Keys are derived
from your Theseus vault — same vault, every wallet across every
chain. Aegis lives at <a class="link" id="aboutOpenAegisSite">aegis.x</a> and
this build's version + update chip sit at the bottom-right of the
panel.
</div>
<div class="hint">
Silent Mode — <a class="link" id="aboutOpenSilentmodeSite">silentmode.st</a> ·
docs on the plugin system live at <span class="mono">theseus.x/plug-ins</span>.
</div>
</div>
</div><!-- /about section -->
<div class="msg err" id="settingsMsg" hidden></div>
</section>
</div>
@ -825,15 +1069,16 @@
<svg viewBox="0 0 32 32" width="14" height="14" aria-hidden="true"><polygon points="16,2 28,9 28,23 16,30 4,23 4,9" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/><circle cx="16" cy="16" r="4.3" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="16" cy="16" r="1.3" fill="currentColor"/></svg>
<span>aegis.x</span>
</a>
<!-- Update controls: a subtle "check" link that polls the OTA manifest,
then swaps to an "Update to vX.Y.Z" chip when a newer version is
ready. Panel-only view since the addon can't promote itself; the
chip links to Settings > Extensions > Aegis where the Apply flow
lives. Version marker on the far right doubles as the up-to-date
affordance so a happy panel says nothing extra. -->
<span id="brandUpdate" class="brandupd" hidden></span>
<!-- Update controls (0.8.1): the update chip lives INSIDE the right-side
group, sharing the slot with the version marker. paintFooterUpdate()
hides brandVer when a newer build is available and shows brandUpdate
in its place — clicking the acid-green "↑ v0.8.1" text stages the
download and relaunches Theseus. Idle: brandVer visible, brandUpdate
hidden. Same one-click Update/Install path the user asked for, at
the exact spot they identified. -->
<span class="brandfoot-right">
<button id="brandCheck" class="brandcheck" type="button" title="Check for updates">↻</button>
<span id="brandUpdate" class="brandupd" hidden></span>
<span class="brandver" id="brandVer"></span>
</span>
</footer>

File diff suppressed because it is too large Load diff

View file

@ -141,6 +141,36 @@ const tronLink = {
theseus.contextBridge.exposeInMainWorld("tronWeb", tronWeb);
theseus.contextBridge.exposeInMainWorld("tronLink", tronLink);
// -------- WizardConnect bridge (window.wizardconnect), everywhere -----------
//
// WizardConnect is a Nostr-relay pairing protocol built for CROSS-device use:
// the dapp calls initiateDappRelay(), gets a wiz:// uri, and renders it as a
// QR for a phone wallet to scan. On the same device that QR round-trip is
// pure friction — the dapp and Aegis are in the same browser.
//
// The SDK has no in-page wallet discovery, so this is Silent Mode's own
// surface. A dapp keeps its existing initiateDappRelay() call and simply
// hands us the uri it already generated:
//
// const { uri } = initiateDappRelay(onStatus);
// if (window.wizardconnect) await window.wizardconnect.connect(uri);
// else renderQr(uri); // unchanged fallback
//
// connect() resolves once the user approves the pairing in Aegis and the
// key exchange completes, and rejects if they decline. Nothing is paired
// without an explicit approval, and we never read the page to find a uri —
// the dapp hands it to us.
const wizardconnect = {
isAegis: true,
version: "1.0.0",
// Present so a dapp can tell "wallet is installed" from "wallet is
// installed but has no BCH wallet ready to pair with" before it decides
// whether to fall back to a QR.
isReady: () => call("wcPageReady"),
connect: (uri) => call("wcConnectFromPage", { uri: String(uri ?? "") }),
};
theseus.contextBridge.exposeInMainWorld("wizardconnect", wizardconnect);
// -------- Main-world bridges (window.ethereum, window.solana) ---------------
//
// EIP-1193 (Ethereum) and the Solana wallet-adapter both expect the wallet
@ -491,6 +521,23 @@ const mainWorldSource = `(function () {
announce();
window.addEventListener("eip6963:requestProvider", announce);
} catch {}
// Same announce/request handshake for WizardConnect. The WC SDK defines
// no discovery mechanism at all, so we borrow EIP-6963's shape: a dapp
// that wants to support several WC wallets dispatches
// "wizardconnect:requestProvider" and collects the announcements instead
// of reaching for window.wizardconnect and finding whoever won the race.
// window.wizardconnect stays for the simple single-wallet case.
try {
const wcInfo = { uuid: crypto.randomUUID(), name: "Aegis", icon: "data:image/svg+xml;utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpolygon points='16,2 29,9 29,23 16,30 3,23 3,9' fill='none' stroke='%23d6ff3d' stroke-width='2.5'/%3E%3Ccircle cx='16' cy='16' r='4.3' fill='none' stroke='%23d6ff3d' stroke-width='1.6'/%3E%3Ccircle cx='16' cy='16' r='1.6' fill='%23d6ff3d'/%3E%3C/svg%3E", rdns: "st.silentmode.aegis" };
const announceWc = () => {
const provider = window.wizardconnect;
if (!provider) return;
window.dispatchEvent(new CustomEvent("wizardconnect:announceProvider", { detail: Object.freeze({ info: wcInfo, provider }) }));
};
announceWc();
window.addEventListener("wizardconnect:requestProvider", announceWc);
} catch {}
})();`;
// Actually push the script into the main world. Doing this at

90
main.js
View file

@ -2122,6 +2122,59 @@ function initAddons() {
// and-stitch pass; kept for a later revision.
// region — run the caller-supplied overlay source in the tab, wait
// for a rect (or null = cancel), then capturePage(rect).
// Back scan-page. The extraction runs IN the page and returns only the
// matched URIs — the add-on never sees the DOM. We look at anchor hrefs,
// visible text, and the handful of attributes a dapp realistically
// stashes a pairing code in (data-uri, value, title), then dedupe.
//
// WizardConnect also has a QR-alphanumeric form (WIZ://%3FP%3D…), which
// is often the ONLY thing in the DOM when a dapp renders a QR, so the
// matcher accepts the percent-encoded spelling too and decodes it.
scanTabForUris: async ({ scheme, limit }, addonId) => {
const t = activeTab();
if (!t) throw new Error("no active tab");
if (t.addonId || t.settings) throw new Error("open the dapp's tab first, then scan");
const wc = t.view.webContents;
const origin = pageOriginOf(wc.getURL());
// The regex SOURCES are built here and shipped as JSON. Assembling
// them inside the injected string instead means hand-escaping
// backslashes and quotes through two levels of literal, which is both
// easy to get wrong and unreviewable. JSON.stringify does it exactly.
// `scheme` is already validated against [a-z][a-z0-9+.-]* upstream, so
// it cannot carry regex metacharacters.
const plainSrc = `\\b${scheme}://[^\\s"'<>]{4,2048}`;
// Percent-encoded QR spelling: WIZ://%3FP%3D…
const qrSrc = `\\b${scheme}://(?:%[0-9A-Fa-f]{2}|[A-Za-z0-9._~$+-])+`;
const arg = JSON.stringify({ plainSrc, qrSrc, limit });
const found = await wc.executeJavaScript(`(() => {
const { plainSrc, qrSrc, limit } = ${arg};
const out = new Set();
const plain = new RegExp(plainSrc, "gi");
const qr = new RegExp(qrSrc, "gi");
const push = (s) => {
if (!s || out.size >= limit) return;
let v = String(s).trim();
if (v.includes("%3F") || v.includes("%3f")) { try { v = decodeURIComponent(v); } catch {} }
if (v.length <= 2048) out.add(v);
};
const scan = (s) => {
if (!s) return;
for (const m of String(s).matchAll(plain)) push(m[0]);
for (const m of String(s).matchAll(qr)) push(m[0]);
};
for (const a of document.querySelectorAll("a[href]")) scan(a.getAttribute("href"));
for (const el of document.querySelectorAll("[data-uri],[data-wc-uri],[value],[title]")) {
scan(el.getAttribute("data-uri")); scan(el.getAttribute("data-wc-uri"));
scan(el.getAttribute("value")); scan(el.getAttribute("title"));
}
for (const el of document.querySelectorAll("input,textarea")) scan(el.value);
scan(document.body ? document.body.innerText : "");
return [...out].slice(0, limit);
})()`, true);
const uris = Array.isArray(found) ? found.filter((s) => typeof s === "string") : [];
console.log(`[addons] ${addonId} scanned ${origin || "tab"} for ${scheme}:// — ${uris.length} match(es)`);
return { origin, uris };
},
captureTab: async (opts, addonId) => {
// Prefer the currently-active tab, BUT if that's an add-on-owned page
// (e.g. the screenshot editor is already up when the user re-picks a
@ -3062,6 +3115,13 @@ function createTab(initial, opts = {}) {
}
return navigateTab(id, target + rest);
}
// A wiz:// click never navigates — it hands the pairing URI to the
// wallet and leaves the dapp exactly where it is.
if (parsed.protocol === "wiz:") {
e.preventDefault();
routeWizUri(u, pageOriginOf(wc.getURL()), tab.id);
return;
}
if (parsed.protocol === "bns:") return;
if (isBnsHost(parsed.hostname)) {
// Only intercept cross-origin navigations. Same-origin (a form submit
@ -3103,6 +3163,12 @@ function createTab(initial, opts = {}) {
});
// Links that open a new tab: target="_blank", window.open, Ctrl/middle-click.
wc.setWindowOpenHandler(({ url, disposition }) => {
// target="_blank" on a wiz:// link lands here rather than will-navigate.
// Route it to the wallet instead of opening a tab on an unloadable URL.
if (url && /^wiz:/i.test(url)) {
routeWizUri(url, pageOriginOf(wc.getURL()), tab.id);
return { action: "deny" };
}
const installId = installLinkId(url);
if (installId) {
let requester = null; try { requester = new URL(wc.getURL()).host || null; } catch {}
@ -4132,6 +4198,30 @@ function pageOriginOf(url) {
return u.protocol && u.host ? `${u.protocol}//${u.host}` : null;
} catch { return null; }
}
// wiz:// — WizardConnect pairing links.
//
// WizardConnect is a cross-device protocol: a dapp renders its pairing URI
// as a QR for a phone wallet to scan. On the same device that means copying
// a wiz:// string out of one tab and pasting it into the wallet by hand.
// When a dapp renders the URI as a link instead, we can route the click
// straight to the wallet — no copying, and no change required on the dapp
// side beyond making it an anchor, so this works for third-party dapps that
// will never adopt a Silent Mode API.
//
// The wallet still shows its own approval before anything is paired; all
// this does is carry the URI across, tagged with the origin that offered it
// so the approval can name the real site.
function routeWizUri(uri, origin, tabId) {
if (!addonHost) return false;
const u = String(uri || "");
if (!/^wiz:/i.test(u) || u.length > 4096) return false;
if (!addonHost.hasHandler("aegis", "wcConnectFromPage")) return false;
addonHost
.dispatch("aegis", "wcConnectFromPage", { uri: u }, { from: "page", origin: origin || "unknown site", tabId })
.catch((err) => console.log("[wiz] pairing failed:", err?.message || err));
return true;
}
ipcMain.handle("addon-page-msg", async (e, addonId, msg, payload) => {
const tab = tabForSender(e.sender);
if (!tab || !addonHost) throw new Error("not a page");