chore(aegis): 0.8.2 — sectioned Settings tab

Settings grew tall enough that the user had to scroll past a dozen
cards to reach Prices, Sites or About. Split it into six named
sections (Security, Session, Wallet, Prices, Sites, About) with a
chip nav row at the top; only one section is visible at a time and
the choice persists across restarts.

Adds an About card that names the wallet, the aegis.x front-door
site and the silentmode.st umbrella, so support triage has a
one-click way to reach either from within the panel.

Includes the accumulated 0.7.x-0.8.1 wallet work that was already
shipping on OTA (CashTokens/BCMR, imported-wallet spend, siascan
integration, consolidate, currency picker, footer update chip).
This commit is contained in:
Local Dev 2026-09-22 20:37:28 +02:00
parent f46e9112b7
commit cb7ca53b01
17 changed files with 2833 additions and 351 deletions

View file

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

View file

@ -91,6 +91,7 @@ async function loadDeps(api) {
// the primary BCH adapter but a single fixed address per wallet. // the primary BCH adapter but a single fixed address per wallet.
const importedBchAdapter = require("./lib/chain-bch-imported.js")({ const importedBchAdapter = require("./lib/chain-bch-imported.js")({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx, 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- // Multi-chain imported adapters. UTXO chains (BTC, DGB) share an electrum-
// based reader; account-model chains (ETH, TRX, SOL) share a JSON-RPC // 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 // chain-native private key). Used by the importWallet handler to compute
// the address client-side before wallet-imports.enc stores the material. // the address client-side before wallet-imports.enc stores the material.
const derive = require("./lib/import-derive.js")({ 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, cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory,
ecpairFactory: ECPairFactory, ecc, bip39, dgbCore, ecpairFactory: ECPairFactory, ecc, bip39, dgbCore,
}); });
@ -505,6 +506,7 @@ async function mountWallet(entry) {
...commonOpts, ...commonOpts,
cashaddr: entry.importedCashaddr || entry.importedAddress, cashaddr: entry.importedCashaddr || entry.importedAddress,
servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined, servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
importId: entry.importId,
}); });
adapter.schedulePoll(20_000); adapter.schedulePoll(20_000);
} else if (entry.chain === "btc" || entry.chain === "dgb") { } else if (entry.chain === "btc" || entry.chain === "dgb") {
@ -518,6 +520,32 @@ async function mountWallet(entry) {
rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || undefined), rpcUrl: String(c.api.storage.get(`wallets/${entry.id}/rpcUrl`, "") || undefined),
}); });
adapter.schedulePoll(20_000); 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 { } else {
throw new Error(`no imported adapter for chain "${entry.chain}"`); throw new Error(`no imported adapter for chain "${entry.chain}"`);
} }
@ -1024,6 +1052,31 @@ function registerPanelMessages(api) {
spec.wif = `aegis-privb58:${raw}`; spec.wif = `aegis-privb58:${raw}`;
} else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); } } else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); }
spec.cashaddr = address; // storage-key reuse — see BTC/DGB comment above 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 { } else {
throw new Error(`import not supported for chain "${chain}"`); throw new Error(`import not supported for chain "${chain}"`);
} }
@ -1082,6 +1135,26 @@ function registerPanelMessages(api) {
}); });
api.onMessage("refresh", async (_p, m) => { fromPanel(m); const rt = requireSelected(); await rt.adapter.refresh(true); return snapshotForSelected(); }); 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) => { api.onMessage("nextAddress", (_p, m) => {
fromPanel(m); fromPanel(m);
const rt = requireSelected(); const rt = requireSelected();
@ -1273,6 +1346,131 @@ function registerPanelMessages(api) {
return rt.adapter.signAndBroadcast(plan); 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) => { api.onMessage("recovery", async (p, m) => {
fromPanel(m); fromPanel(m);
const id = String(p && p.id || selectedWalletId()); const id = String(p && p.id || selectedWalletId());
@ -1700,6 +1898,52 @@ function registerPageMessages(api) {
return rt.adapter.signMessage(message); 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) ----------------------------------- // ---- Tron bridge (tronWeb / tronLink) -----------------------------------
api.onMessage("trx.requestAccounts", async (_p, m) => { api.onMessage("trx.requestAccounts", async (_p, m) => {
@ -2251,6 +2495,13 @@ module.exports = {
log: (...a) => api.log("prices", ...a), log: (...a) => api.log("prices", ...a),
onChange: () => emitState(), 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 wc: null, // WizardConnect manager, initialised when deps load
}; };
migrateLegacyStorage(api); 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; 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) { function encode(prefix, type, hash) {
if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported"); if (hash.length !== 20) throw new Error("cashaddr: only 160-bit hashes supported");
const versionByte = (type << 3) | 0; // size bits 000 = 160 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)); 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(""); 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). // Accepts "prefix:payload" or a bare payload (assumes defaultPrefix).
function decode(address, defaultPrefix = "bitcoincash") { function decode(address, defaultPrefix = "bitcoincash") {
@ -88,15 +99,30 @@ function decodeLegacy(address, sha256) {
return { prefix: "bitcoincash", type, hash: body.slice(1) }; return { prefix: "bitcoincash", type, hash: body.slice(1) };
} }
// Anything a user might paste -> { type, hash, cashaddr }. Rejects other // Anything a user might paste -> { type, hash, cashaddr, tokenAware }.
// prefixes so a chipnet address can never be paid on mainnet by accident. // 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") { function parseAny(input, sha256, prefix = "bitcoincash") {
const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:"); const s = String(input || "").trim().replace(/^bitcoincash:\/\//i, "bitcoincash:");
if (!s) throw new Error("empty address"); 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); 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.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"); if (r.type < 0 || r.type > 3) throw new Error(`unsupported address type ${r.type}`);
return { type: r.type, hash: r.hash, cashaddr: encode(prefix, r.type, r.hash) }; 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 // Imported BCH wallet — single-address, key material lives in Theseus's
// wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's // wallet-imports.enc (design §3.2). This adapter mirrors chain-bch.js's
// public shape (snapshot, refresh, plan, signAndBroadcast, dispose) but // public shape (snapshot, refresh, plan, signAndBroadcast, signMessage,
// does NOT go through vault.derive + HKDF: derivation is direct from the // dispose) but does NOT go through vault.derive + HKDF: derivation is
// seed+path or WIF that the user imported. // direct from the seed+path or WIF that the user imported.
// //
// M.1a scope: read-only (balance + history over Electrum). planSend/send // 0.6.36+: spend path enabled. plan() builds a P2PKH tx off the wallet's
// throw with a clear message until M.1b lands the sign path. // 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 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 p2pkhScript = (h160) => Uint8Array.from([0x76, 0xa9, 0x14, ...h160, 0x88, 0xac]);
const scripthashOf = (script) => toHex(sha256(script).slice().reverse()); const scripthashOf = (script) => toHex(sha256(script).slice().reverse());
@ -19,7 +23,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
const IMPORTED_BCH_NETWORKS = { const IMPORTED_BCH_NETWORKS = {
mainnet: { mainnet: {
id: "mainnet", label: "Mainnet", prefix: "bitcoincash", id: "mainnet", label: "Mainnet", prefix: "bitcoincash", wifVersion: 0x80,
explorerTx: "https://blockchair.com/bitcoin-cash/transaction/", explorerTx: "https://blockchair.com/bitcoin-cash/transaction/",
explorerAddr: "https://blockchair.com/bitcoin-cash/address/", explorerAddr: "https://blockchair.com/bitcoin-cash/address/",
defaultServers: [ defaultServers: [
@ -30,7 +34,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
], ],
}, },
chipnet: { chipnet: {
id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", id: "chipnet", label: "Chipnet testnet", prefix: "bchtest", wifVersion: 0xef,
explorerTx: "https://chipnet.imaginary.cash/tx/", explorerTx: "https://chipnet.imaginary.cash/tx/",
explorerAddr: "https://chipnet.imaginary.cash/address/", explorerAddr: "https://chipnet.imaginary.cash/address/",
defaultServers: [ 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) { function h160OfCashaddr(addr) {
const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, ""); const clean = String(addr || "").replace(/^bitcoincash:|^bchtest:/, "");
const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean); const { type, hash } = cashaddr.decode(addr.includes(":") ? addr : "bitcoincash:" + clean);
@ -51,12 +52,56 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
return hash; 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 { 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]; const net = IMPORTED_BCH_NETWORKS[network];
if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`); if (!net) throw new Error(`chain-bch-imported: unknown network ${network}`);
if (!address) throw new Error("chain-bch-imported: cashaddr required"); if (!address) throw new Error("chain-bch-imported: cashaddr required");
this.walletId = walletId; 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.chain = "bch";
this.network = net.id; this.network = net.id;
this._net = net; this._net = net;
@ -73,6 +118,7 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
this._state = { this._state = {
balance: { confirmed: 0, unconfirmed: 0 }, balance: { confirmed: 0, unconfirmed: 0 },
history: [], history: [],
utxos: [],
height: 0, height: 0,
scanning: false, scanning: false,
error: null, error: null,
@ -107,6 +153,10 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
server: this._client.url || null, server: this._client.url || null,
servers: this._servers, servers: this._servers,
imported: true, 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, explorerTx: this._net.explorerTx,
explorerAddr: this._net.explorerAddr, explorerAddr: this._net.explorerAddr,
faucet: this._net.faucet, faucet: this._net.faucet,
@ -116,11 +166,15 @@ module.exports = function makeImportedBchAdapter({ sha256, ripemd160, cashaddr,
async refresh(full) { async refresh(full) {
this._state.scanning = true; this._emit(); this._state.scanning = true; this._emit();
try { try {
// Balance for this single scripthash. const bal = await this._client.call("blockchain.scripthash.get_balance", [this._scripthash]);
const bal = await this._client.request("blockchain.scripthash.get_balance", [this._scripthash]);
this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) }; 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) { 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) => ({ this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0, 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 }; } 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."); } // 0.6.36 spend path. Builds an unsigned P2PKH plan against the wallet's
signAndBroadcast() { throw new Error("Imported wallets are read-only in this build."); } // own UTXO set. Signing happens in signAndBroadcast, which fetches the
signMessage() { throw new Error("Imported wallets are read-only in this build."); } // 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)." }; } 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

@ -105,6 +105,9 @@ module.exports = function makeBchAdapter({
addressIndex: w.addressIndex, addressIndex: w.addressIndex,
addressPath: w.addressPath, addressPath: w.addressPath,
balance: w.balance, 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, height: w.height,
history: w.history, history: w.history,
scanning: w.scanning, scanning: w.scanning,
@ -126,7 +129,8 @@ module.exports = function makeBchAdapter({
const targets = Array.isArray(spec.outputs) && spec.outputs.length const targets = Array.isArray(spec.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value })) ? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec.to, value: spec.amount ?? spec.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); } async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); }
// 65-byte BIP-137 recoverable signature — the format Electron Cash and // 65-byte BIP-137 recoverable signature — the format Electron Cash and

View file

@ -20,6 +20,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
const keysLib = require("./sia/keys.js")({ sia }); const keysLib = require("./sia/keys.js")({ sia });
const walletd = require("./sia/walletd.js")({ log: () => {} }); const walletd = require("./sia/walletd.js")({ log: () => {} });
const walletFactory = require("./sia/wallet.js"); const walletFactory = require("./sia/wallet.js");
const siascanLib = require("./sia/siascan.js")({ log: () => {} });
function scopedStorage(storage, keyPrefix) { function scopedStorage(storage, keyPrefix) {
const k = (key) => keyPrefix + key; const k = (key) => keyPrefix + key;
@ -32,7 +33,7 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
class SiaWallet { class SiaWallet {
constructor(root32, { constructor(root32, {
walletId, storage, log = () => {}, onChange = () => {}, walletId, storage, log = () => {}, onChange = () => {},
walletdUrl = "", walletdUrl = "", siascanUrl = "",
} = {}) { } = {}) {
if (!walletId) throw new Error("chain-sia: walletId required"); if (!walletId) throw new Error("chain-sia: walletId required");
this.walletId = walletId; this.walletId = walletId;
@ -44,9 +45,67 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
this._root = new Uint8Array(root32); this._root = new Uint8Array(root32);
this._keys = new keysLib.WalletKeys(root32); this._keys = new keysLib.WalletKeys(root32);
this._walletdUrl = String(walletdUrl || "").trim(); this._walletdUrl = String(walletdUrl || "").trim();
this._siascanUrl = String(siascanUrl || "").trim() || siascanLib.DEFAULT_BASE;
this._client = null; this._client = null;
this._wallet = 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(); 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() { _build() {
@ -65,21 +124,44 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
const v = String(url || "").trim(); const v = String(url || "").trim();
if (v === this._walletdUrl) return; if (v === this._walletdUrl) return;
this._walletdUrl = v; 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 // The panel treats Sia amounts as decimal strings of hastings; the
// display layer picks how many SC-precision digits to show. // display layer picks how many SC-precision digits to show.
snapshot() { snapshot() {
const w = this._wallet && this._wallet.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 = { const base = {
chain: "sc", network: "mainnet", ticker: "SC", decimals: 24, 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" }, balance: { confirmed: "0", unconfirmed: "0" },
height: 0, history: [], scanning: false, error: null, 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, 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, explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null,
}; };
if (w) { if (w) {
@ -100,21 +182,43 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
})); }));
base.scanning = w.scanning; base.scanning = w.scanning;
base.error = w.error; 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; return base;
} }
async refresh(full) { async refresh(full) {
if (!this._wallet) return; if (this._wallet) return this._wallet.refresh(!!full);
return this._wallet.refresh(!!full); // Siascan path: fetch now, don't wait for the poll tick.
return this._refreshFromSiascan();
} }
nextAddress() { nextAddress() {
if (!this._wallet) throw new Error("no walletd URL configured"); if (this._wallet) return this._wallet.nextUnusedAddress();
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() { current() {
if (!this._wallet) throw new Error("no walletd URL configured"); if (this._wallet) return this._wallet.current();
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) { plan(spec) {
if (!this._wallet) throw new Error("no walletd URL configured"); if (!this._wallet) throw new Error("no walletd URL configured");
@ -162,8 +266,12 @@ module.exports = function makeSiaAdapter({ ed25519, blake2b }) {
xprv: this._keys.seedHex, 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() { dispose() {
this._stopSiascanPoll();
try { this._wallet && this._wallet.dispose(); } catch {} try { this._wallet && this._wallet.dispose(); } catch {}
try { this._keys && this._keys.wipe(); } catch {} try { this._keys && this._keys.wipe(); } catch {}
if (this._root) this._root.fill(0); if (this._root) this._root.fill(0);

View file

@ -109,10 +109,10 @@ module.exports = function makeUtxoImportedAdapter({ sha256, bitcoinjs, dgbCore,
async refresh(full) { async refresh(full) {
this._state.scanning = true; this._emit(); this._state.scanning = true; this._emit();
try { 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) }; this._state.balance = { confirmed: Number(bal?.confirmed || 0), unconfirmed: Number(bal?.unconfirmed || 0) };
if (full) { 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) => ({ this._state.history = (hist || []).slice(-50).map((h) => ({
txid: h.tx_hash, time: 0, delta: 0, confirmations: h.height > 0 ? 1 : 0, 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. // npm packages — same "hand it in" pattern the other adapters use.
module.exports = function makeImportDerive({ 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, cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39,
dgbCore, 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 toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const fromHex = (h) => { const fromHex = (h) => {
const s = String(h || "").replace(/^0x/i, ""); const s = String(h || "").replace(/^0x/i, "");
@ -207,6 +211,42 @@ module.exports = function makeImportDerive({
return base58check.encodeBase58(pub); 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 { return {
mnemonicToSeedHex, mnemonicToSeedHex,
btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif }, btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif },
@ -214,5 +254,6 @@ module.exports = function makeImportDerive({
eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex }, eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex },
trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex }, trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex },
sol: { fromSeed: deriveSolFromSeed, fromPrivHex: deriveSolFromPrivHex, fromBase58: deriveSolFromBase58 }, 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 {} 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 // Fiat prices for every Aegis-supported coin. Poll every enabled source in
// privacy-conscious user isn't quietly telling ANY oracle when Aegis is // parallel and reconcile per chain: if two or more sources agree within a
// open. Source is user-selectable — different oracles trade off privacy, // small band (±3% of the median), take their median as the truth; if none
// coverage, and freshness: // 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, // The user-facing model in 0.6.36+ is just "on / off": no source picker,
// default. Sees the browser IP + User-Agent every poll. // no per-source config. Adding a new oracle here fans out to everyone
// - kraken : per-pair spot from Kraken's public /Ticker; fewer // with no UI churn.
// 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.
// //
// New sources plug in by adding an entry to SOURCES. Each provider takes a // Sources currently wired:
// list of chain keys and returns { <chain>: usd } for the ones it knows // coingecko — 1 request covers all 7 coins, best overall coverage
// about; unknown chains just stay absent from the snapshot. The poller is // kraken — public /Ticker; BCH/BTC/ETH/SOL/TRX pairs
// generic. // 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 // Sources deferred (need their own protocol work first):
// per-source since some rate-limit tighter than others. Off by default. // 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"]; 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 = { const SOURCES = {
coingecko: { coingecko: {
id: "coingecko", id: "coingecko",
label: "CoinGecko", label: "CoinGecko",
origin: "api.coingecko.com", origin: "api.coingecko.com",
pollMs: 5 * 60 * 1000,
coversAll: true, coversAll: true,
fetch: async () => { fetch: async () => {
const ids = { const ids = {
@ -49,18 +56,14 @@ const SOURCES = {
id: "kraken", id: "kraken",
label: "Kraken", label: "Kraken",
origin: "api.kraken.com", origin: "api.kraken.com",
pollMs: 60 * 1000,
coversAll: false, coversAll: false,
fetch: async () => { 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 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 url = `https://api.kraken.com/0/public/Ticker?pair=${Object.values(pairs).join(",")}`;
const r = await fetch(url); const r = await fetch(url);
if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`); if (!r.ok) throw new Error(`Kraken HTTP ${r.status}`);
const body = await r.json(); const body = await r.json();
if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";")); if (body?.error?.length) throw new Error("Kraken: " + body.error.join(";"));
// Kraken returns keys like "XBCHZUSD" — match by suffix.
const out = {}; const out = {};
const result = body?.result || {}; const result = body?.result || {};
const entries = Object.entries(result); const entries = Object.entries(result);
@ -76,11 +79,8 @@ const SOURCES = {
id: "coinbase", id: "coinbase",
label: "Coinbase", label: "Coinbase",
origin: "api.coinbase.com", origin: "api.coinbase.com",
pollMs: 60 * 1000,
coversAll: false, coversAll: false,
fetch: async () => { 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 map = { bch: "BCH-USD", btc: "BTC-USD", eth: "ETH-USD", sol: "SOL-USD" };
const out = {}; const out = {};
await Promise.all(Object.entries(map).map(async ([chain, pair]) => { await Promise.all(Object.entries(map).map(async ([chain, pair]) => {
@ -95,35 +95,150 @@ const SOURCES = {
return out; 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();
const usd = Number(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 = () => {} } = {}) { module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} } = {}) {
const state = { const state = {
enabled: false, enabled: false,
source: DEFAULT_SOURCE, prices: {}, // { <chain>: usd (number) } — backward-compat
prices: {}, // { <chain>: usd (number) } reconciled: {}, // { <chain>: {usd, method, samples: [...]} }
sourceStatus: {}, // { <sourceId>: {ok, error, prices, at, took} }
fetchedAt: null, fetchedAt: null,
error: null, error: null,
loading: false, loading: false,
}; };
let timer = null; let timer = null;
function currentProvider() { return SOURCES[state.source] || SOURCES[DEFAULT_SOURCE]; }
async function fetchOnce() { async function fetchOnce() {
if (!state.enabled) return; if (!state.enabled) return;
state.loading = true; state.error = null; onChange(); state.loading = true; state.error = null; onChange();
try { try {
const src = currentProvider(); const { perChain, sourceStatus } = await pollAll(log);
const next = await src.fetch(); const flat = {};
state.prices = next || {}; 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.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) { } catch (e) {
state.error = e?.message || String(e); state.error = e?.message || String(e);
log(`price fetch (${state.source}) failed:`, state.error); log("price poll failed:", state.error);
} finally { } finally {
state.loading = false; state.loading = false;
onChange(); onChange();
@ -133,30 +248,31 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
function schedule() { function schedule() {
clearTimeout(timer); clearTimeout(timer);
if (!state.enabled) return; if (!state.enabled) return;
timer = setTimeout(async () => { await fetchOnce(); schedule(); }, currentProvider().pollMs); timer = setTimeout(async () => { await fetchOnce(); schedule(); }, POLL_MS);
} }
return { return {
snapshot() { snapshot() {
return { return {
enabled: state.enabled, enabled: state.enabled,
source: state.source,
prices: state.prices, prices: state.prices,
reconciled: state.reconciled,
sourceStatus: state.sourceStatus,
fetchedAt: state.fetchedAt, fetchedAt: state.fetchedAt,
error: state.error, error: state.error,
loading: state.loading, loading: state.loading,
sources: Object.values(SOURCES).map((s) => ({ // Retained so existing settings UI paths that expect a `sources`
id: s.id, label: s.label, origin: s.origin, coversAll: s.coversAll, // 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) { async setEnabled(on) {
const changed = !!on !== state.enabled; const changed = !!on !== state.enabled;
state.enabled = !!on; state.enabled = !!on;
if (!state.enabled) { 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); clearTimeout(timer);
if (changed) onChange(); if (changed) onChange();
return; return;
@ -165,15 +281,10 @@ module.exports = function makePriceFeed({ log = () => {}, onChange = () => {} }
await fetchOnce(); await fetchOnce();
schedule(); schedule();
}, },
// Switch source. Clears the current cache, kicks a fresh fetch if the // No-op kept for API compatibility — there is no source picker anymore.
// feed is enabled. No-op when the source is already current. // Existing callers that persisted a chosen source can still call this
async setSource(id) { // and get a benign refresh.
if (!SOURCES[id] || id === state.source) return; async setSource(_id) { if (state.enabled) { await fetchOnce(); schedule(); } },
state.source = id;
state.prices = {}; state.fetchedAt = null;
onChange();
if (state.enabled) { await fetchOnce(); schedule(); }
},
refresh() { return fetchOnce(); }, refresh() { return fetchOnce(); },
dispose() { clearTimeout(timer); state.enabled = false; }, 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 outpoint = (inp) => concat(fromHex(inp.txid).reverse(), u32le(inp.vout));
const estimateSize = (nIn, nOut) => OVERHEAD + nIn * P2PKH_INPUT_SIZE + nOut * P2PKH_OUTPUT_SIZE; 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) }] // inputs: [{ txid, vout, value, script(Uint8Array), sig?(Uint8Array) }]
// outputs: [{ value, script(Uint8Array) }] // outputs: [{ value, script(Uint8Array) }]
@ -81,42 +96,51 @@ module.exports = function makeTx({ sha256 }) {
return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) }; return { raw, hex: toHex(raw), txid: toHex(dsha(raw).reverse()) };
} }
// Largest-first accumulation. `targets` = [{ value, script }]; returns // Largest-first accumulation. `targets` = [{ value, script, data? }]:
// { inputs, outputs, fee, change } or throws when funds don't cover it. // data:true — an OP_RETURN memo output; value MUST be 0 and doesn't
// sendMax: spend every UTXO into targets[0] and no change. // 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 } = {}) { 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 sorted = utxos.slice().sort((a, b) => b.value - a.value);
const total = sorted.reduce((a, u) => a + u.value, 0); const total = sorted.reduce((a, u) => a + u.value, 0);
if (sendMax) { if (sendMax) {
if (targets.length !== 1) throw new Error("send max needs exactly one recipient"); if (spendOuts.length !== 1) throw new Error("send max needs exactly one recipient");
const fee = feeFor(sorted.length, 1, satPerByte); const fee = feeFor(sorted.length, 1 + dataOuts.length, satPerByte, dataBytes);
const value = total - fee; const value = total - fee;
if (!sorted.length || value < DUST) throw new Error("balance too small to send"); 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); const want = spendOuts.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)`); for (const t of spendOuts) if (t.value < DUST) throw new Error(`amount below dust limit (${DUST} sat)`);
const chosen = []; let sum = 0; const chosen = []; let sum = 0;
for (const u of sorted) { for (const u of sorted) {
chosen.push(u); sum += u.value; 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) { if (sum >= want + feeWithChange) {
const change = sum - want - feeWithChange; const change = sum - want - feeWithChange;
if (change >= DUST) { 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. // Change would be dust: fold it into the fee, one output fewer.
const fee = sum - want; 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) { 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`); 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: // Wallet state machine on top of an electrum client and a WalletKeys tree:
// address discovery (gap limit), balance, history with per-tx deltas, UTXO // address discovery (gap limit), balance, history with per-tx deltas, UTXO
// set and send construction. Knows nothing about UI or IPC. // 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 = () => {} }) { module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) {
const GAP = 20; const GAP = 20;
const HISTORY_LIMIT = 25; const HISTORY_LIMIT = 25;
@ -11,7 +20,8 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
watched: new Map(), // scripthash -> entry watched: new Map(), // scripthash -> entry
height: 0, height: 0,
balance: { confirmed: 0, unconfirmed: 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 history: [], // newest first
receiveIndex: 0, receiveIndex: 0,
scanning: false, scanning: false,
@ -70,12 +80,70 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
async function loadUtxos() { async function loadUtxos() {
const lists = await Promise.all([...state.watched.values()].map(async (e) => { const lists = await Promise.all([...state.watched.values()].map(async (e) => {
const u = await client.call("blockchain.scripthash.listunspent", [e.scripthash]); 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; const utxos = lists.flat();
for (const u of state.utxos) { if (u.height > 0) confirmed += u.value; else unconfirmed += u.value; } // Enrich each UTXO with its scriptPubKey so cashtokens.decodePrefixedScript
state.balance = { confirmed, unconfirmed }; // 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) { 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. // 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 rate = Math.min(10, Math.max(1, Number(feeRate) || 1));
const outs = targets.map((t) => { const outs = targets.map((t) => {
const a = cashaddr.parseAny(t.to, sha256, keys.prefix); 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]); : Uint8Array.from([0xa9, 0x14, ...a.hash, 0x87]);
return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr }; return { value: Math.round(Number(t.value) || 0), script, to: a.cashaddr };
}); });
// Spend confirmed coins first; unconfirmed only when needed. if (memo) outs.push({ value: 0, script: tx.memoScript(memo), data: true, memo });
const spendable = state.utxos.slice().sort((a, b) => (b.height > 0) - (a.height > 0)); // 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 }); 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) { async function signAndBroadcast(p) {
@ -232,6 +318,10 @@ module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, stora
height: state.height, height: state.height,
history: state.history, history: state.history,
utxoCount: state.utxos.length, 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, scanning: state.scanning,
error: state.error, 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 .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 { 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; } .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 /* Full-panel sheet — fills most of the sidebar but stops above the
import form aren't squeezed into a small popover. */ footer so the aegis.x brand + version chip stay visible. Users
#drop { position: fixed; left: 0; right: 0; top: 60px; bottom: 0; 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); background: var(--panel); border-top: 1px solid var(--line);
box-shadow: 0 -6px 26px rgba(0,0,0,.35); z-index: 20; box-shadow: 0 -6px 26px rgba(0,0,0,.35); z-index: 20;
display: flex; flex-direction: column; } display: flex; flex-direction: column; }
@ -66,6 +68,17 @@
#drop .netgroup[hidden] { display: none; } #drop .netgroup[hidden] { display: none; }
#drop .netchoice { padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 12.5px; color: var(--mut); } #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); } #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; .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; } 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; } #hNet { color: var(--dim); font-size: 11px; margin-left: 4px; font-weight: 500; }
@ -78,6 +91,39 @@
padding: 0 8px; height: 22px; cursor: pointer; font: inherit; font-size: 13.5px; line-height: 1; 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; } display: inline-flex; align-items: center; justify-content: center; }
.chip:hover { border-color: var(--acid); color: var(--acid); } .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 { 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; 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; } font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; }
@ -262,6 +308,14 @@
.wstrip .wcname .wnetpill.wchipnet { background: rgb(from var(--acid, #d6ff3d) r g b / .18); .wstrip .wcname .wnetpill.wchipnet { background: rgb(from var(--acid, #d6ff3d) r g b / .18);
color: var(--acid, #d6ff3d); font-weight: 600; } color: var(--acid, #d6ff3d); font-weight: 600; }
.wstrip .wcname .wnetpill.wtestnet { background: rgba(224,179,65,.18); color: #e0b341; 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; .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; } 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; .wstrip .wcname .wcprice { color: var(--acid, #d6ff3d); font-size: 11px; font-weight: 600;
@ -313,21 +367,71 @@
.wstrip .wcoinhead .wctitle { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 6px; .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; } 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 .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; /* Inline address-list row (per-coin drilldown). 0.6.35 layout: strictly
gap: 6px; align-items: center; padding: 5px 4px; columnar so every row's fields line up in the same x positions no
border-radius: 6px; border: 1px solid transparent; cursor: pointer; min-height: 30px; } 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:hover { background: rgba(255,255,255,.04); }
.wstrip .warow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10); .wstrip .warow.on { background: rgb(from var(--acid, #d6ff3d) r g b / .10);
border-color: rgb(from var(--acid, #d6ff3d) r g b / .35); } 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; .wstrip .warow .waaddr { font: 12px/1.15 ui-monospace, Consolas, monospace; color: var(--ink);
white-space: nowrap; font-weight: 500; } overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.wstrip .warow .waaddr { font: 11px/1.15 ui-monospace, Consolas, monospace; color: var(--dim); margin-top: 2px; /* Copy chip anchored right after the address. Fixed 22px column so the
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } copy button sits at the same x on every row. */
.wstrip .warow .waamt { text-align: right; font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--ink); } .wstrip .warow .wacopy { background: transparent; border: 0; color: var(--dim); cursor: pointer;
.wstrip .warow .wafiat { font-size: 11px; color: var(--dim); } 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; .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; } 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); } .wstrip .warow .wact:hover { color: var(--acid, #d6ff3d); background: rgba(255,255,255,.06); }
/* 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 /* 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 footer when the vault is locked or awaiting first-time setup. Rest of
@ -397,11 +501,17 @@
<span class="badge" id="hBadge"></span> <span class="badge" id="hBadge"></span>
<span class="lbl" id="hLabel">Aegis Wallet</span> <span class="lbl" id="hLabel">Aegis Wallet</span>
<span id="hNet"></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>
<div class="picker-actions"> <div class="picker-actions">
<button type="button" id="hAdd" class="chip" title="Add a new wallet">+</button> <!-- Single right-corner chip. Opens a compact popover menu with
<button type="button" id="hMore" class="chip" title="Import / Connect / About">⋯</button> Create new / Import / Connect / About — replacing the old
<button type="button" id="hManage" class="chip" title="Edit this wallet — rename, derivation path, remove">✎</button> 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>
</div> </div>
<div id="drop" hidden></div> <div id="drop" hidden></div>
@ -432,6 +542,15 @@
<div id="gate" class="gate" hidden></div> <div id="gate" class="gate" hidden></div>
<div id="tabs"> <div id="tabs">
<section id="tab-receive"> <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="qrwrap"><canvas id="qr" width="200" height="200"></canvas></div>
<div class="card"> <div class="card">
<div class="lbl">Receiving address <span id="addrMeta"></span></div> <div class="lbl">Receiving address <span id="addrMeta"></span></div>
@ -446,10 +565,30 @@
<div class="card" id="tokensCard" hidden style="margin-top:12px"> <div class="card" id="tokensCard" hidden style="margin-top:12px">
<div class="lbl">Tokens</div> <div class="lbl">Tokens</div>
<div id="tokensList" class="kv"></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> </div>
</section> </section>
<section id="tab-send" hidden> <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="field" id="sendAssetField" hidden>
<div class="lbl">Asset</div> <div class="lbl">Asset</div>
<select id="sendAsset"></select> <select id="sendAsset"></select>
@ -472,6 +611,15 @@
<div class="lbl">Fee</div> <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 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> </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="card">
<div class="summary"> <div class="summary">
<div class="k">Amount</div><div class="v" id="sumAmt">—</div> <div class="k">Amount</div><div class="v" id="sumAmt">—</div>
@ -481,11 +629,29 @@
</div> </div>
<div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div> <div class="actions"><button class="btn primary" id="sendBtn" disabled>Send</button></div>
<div class="msg" id="sendMsg" hidden></div> <div class="msg" id="sendMsg" hidden></div>
</div><!-- /sendNormal -->
<div id="sendConsolidate" hidden>
<div id="sendConsolidateInline"></div>
</div>
</section> </section>
<section id="tab-history" hidden> <section id="tab-history" hidden>
<div id="txlist"></div> <div id="txlist"></div>
</section> </section>
<section id="tab-settings" hidden> <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 <!-- Cross-cutting security / policy controls, ahead of anything
per-wallet or chain-specific. Present even when the vault is 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 still locked so users can set up a PIN or read the multi-sig
@ -515,6 +681,8 @@
</div> </div>
</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. <!-- Session: stay-signed-in + idle auto-lock + manual sign-out.
Uses electron.safeStorage under the hood so the master password Uses electron.safeStorage under the hood so the master password
is only decryptable under this OS user account. When "Lock on is only decryptable under this OS user account. When "Lock on
@ -570,7 +738,9 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- /session section -->
<div data-setsec-body="security" hidden>
<div class="card gsec" id="genMultiSig" style="margin-bottom:14px"> <div class="card gsec" id="genMultiSig" style="margin-bottom:14px">
<div class="gtitle">Second-device approval <span class="gsoon">soon</span></div> <div class="gtitle">Second-device approval <span class="gsoon">soon</span></div>
<div class="gline"> <div class="gline">
@ -583,7 +753,9 @@
</div> </div>
</div> </div>
</div> </div>
</div><!-- /security-multisig -->
<div data-setsec-body="wallet" hidden>
<div class="card" id="walletManage" style="margin-bottom:14px"> <div class="card" id="walletManage" style="margin-bottom:14px">
<div class="lbl">This wallet</div> <div class="lbl">This wallet</div>
<div class="field" style="margin-top:6px;margin-bottom:8px"> <div class="field" style="margin-top:6px;margin-bottom:8px">
@ -771,18 +943,18 @@
</div> </div>
</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="lbl">Fiat prices</div>
<div class="hint" style="margin-bottom:10px"> <div class="hint" style="margin-bottom:10px">
Off by default. When enabled, Aegis polls the chosen oracle for USD prices Off by default. When enabled, Aegis polls every supported oracle in
on each supported coin. No keys and no address data are ever sent — but the parallel and reconciles the results — two or more sources agreeing
oracle sees your IP + a User-Agent every poll, which is a signal that a within ±3% form the trusted price, so no single oracle can quietly
wallet is open on this machine. make Aegis show a wrong number. Every enabled oracle sees your IP
</div> + a User-Agent every poll, which is a signal that a wallet is open
<div class="field"> on this machine.
<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>
</div> </div>
<div class="actions" style="align-items:center;gap:12px"> <div class="actions" style="align-items:center;gap:12px">
<label class="switch"> <label class="switch">
@ -792,13 +964,41 @@
<button class="btn sm" id="refreshPrices" hidden>Refresh now</button> <button class="btn sm" id="refreshPrices" hidden>Refresh now</button>
<span class="hint" id="pricesStatus" style="margin-left:auto"></span> <span class="hint" id="pricesStatus" style="margin-left:auto"></span>
</div> </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> </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="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 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 id="sites" class="kv"></div>
</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> <div class="msg err" id="settingsMsg" hidden></div>
</section> </section>
</div> </div>
@ -825,15 +1025,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> <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> <span>aegis.x</span>
</a> </a>
<!-- Update controls: a subtle "check" link that polls the OTA manifest, <!-- Update controls (0.8.1): the update chip lives INSIDE the right-side
then swaps to an "Update to vX.Y.Z" chip when a newer version is group, sharing the slot with the version marker. paintFooterUpdate()
ready. Panel-only view since the addon can't promote itself; the hides brandVer when a newer build is available and shows brandUpdate
chip links to Settings > Extensions > Aegis where the Apply flow in its place — clicking the acid-green "↑ v0.8.1" text stages the
lives. Version marker on the far right doubles as the up-to-date download and relaunches Theseus. Idle: brandVer visible, brandUpdate
affordance so a happy panel says nothing extra. --> hidden. Same one-click Update/Install path the user asked for, at
<span id="brandUpdate" class="brandupd" hidden></span> the exact spot they identified. -->
<span class="brandfoot-right"> <span class="brandfoot-right">
<button id="brandCheck" class="brandcheck" type="button" title="Check for updates">↻</button> <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 class="brandver" id="brandVer"></span>
</span> </span>
</footer> </footer>

File diff suppressed because it is too large Load diff