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

View file

@ -91,6 +91,7 @@ async function loadDeps(api) {
// the primary BCH adapter but a single fixed address per wallet.
const importedBchAdapter = require("./lib/chain-bch-imported.js")({
sha256, ripemd160, cashaddr, electrum, WebSocket, tx,
HDKey, secp256k1, base58check, vaultImports: api.vault && api.vault.imports,
});
// Multi-chain imported adapters. UTXO chains (BTC, DGB) share an electrum-
// based reader; account-model chains (ETH, TRX, SOL) share a JSON-RPC
@ -103,7 +104,7 @@ async function loadDeps(api) {
// chain-native private key). Used by the importWallet handler to compute
// the address client-side before wallet-imports.enc stores the material.
const derive = require("./lib/import-derive.js")({
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256,
HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b,
cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory,
ecpairFactory: ECPairFactory, ecc, bip39, dgbCore,
});
@ -505,6 +506,7 @@ async function mountWallet(entry) {
...commonOpts,
cashaddr: entry.importedCashaddr || entry.importedAddress,
servers: entry.network === "mainnet" ? bchServerList(c.api) : undefined,
importId: entry.importId,
});
adapter.schedulePoll(20_000);
} 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),
});
adapter.schedulePoll(20_000);
} else if (entry.chain === "sc") {
// Sia's SiaWallet needs the 32-byte root at mount time — its key
// tree derives eagerly. Fetch the signer material from the vault
// (this branch runs only while the vault is unlocked; a locked
// vault would surface at import-time and gate the flow there).
// Falls back to a read-only stub if the fetch fails so a stray
// locked mount doesn't break panel rendering.
if (!c.api.vault?.imports || typeof c.api.vault.imports.signer !== "function") {
throw new Error("vault.imports.signer unavailable — cannot mount Sia import");
}
const signerBlob = await c.api.vault.imports.signer(entry.importId);
if (!signerBlob || signerBlob.kind !== "seed" || !signerBlob.seed) {
throw new Error("Sia signer material missing or malformed");
}
const seedHex = String(signerBlob.seed).trim();
if (!/^[0-9a-f]{64}$/i.test(seedHex)) throw new Error("Sia seed must be 32 bytes");
const rootBytes = new Uint8Array(seedHex.match(/../g).map((x) => parseInt(x, 16)));
const walletdUrl = String(c.api.storage.get(`wallets/${entry.id}/walletdUrl`, "") || "");
adapter = new c.d.siaAdapter.SiaWallet(rootBytes, {
walletId: entry.id,
storage: c.api.storage,
log: (...a) => c.api.log(`[${entry.id}]`, ...a),
onChange: () => emitStateForWallet(entry.id),
walletdUrl,
});
if (walletdUrl && typeof adapter.startPolling === "function") adapter.startPolling();
} else {
throw new Error(`no imported adapter for chain "${entry.chain}"`);
}
@ -1024,6 +1052,31 @@ function registerPanelMessages(api) {
spec.wif = `aegis-privb58:${raw}`;
} else { throw new Error("supply mnemonic, privHex" + (chain === "sol" ? ", or privB58" : "")); }
spec.cashaddr = address; // storage-key reuse — see BTC/DGB comment above
} else if (chain === "sc") {
// Siacoin. Uses a 32-byte root seed + u64 index (KeyFromSeed layout);
// no BIP44 path. Accepts a BIP39 12-word mnemonic (matches Sia
// Central Lite / walletd, PBKDF2 → first 32 bytes) or raw 32-byte
// seed hex. Address at index 0 is what we surface at import time;
// the mounted SiaWallet lets users advance through additional
// indices via the "Next unused address" affordance.
const net = network || "mainnet";
if (net !== "mainnet") throw new Error(`SC only supports mainnet (got ${net})`);
const index = Number(p && p.index != null ? p.index : 0);
if (!Number.isInteger(index) || index < 0) throw new Error("SC index must be a non-negative integer");
let seedHex;
if (p && p.mnemonic) {
const r = der.sc.fromMnemonic(String(p.mnemonic).trim(), index);
seedHex = r.seedHex; address = r.address;
} else if (p && p.seedHex) {
const r = der.sc.fromSeedHex(String(p.seedHex).trim(), index);
seedHex = r.seedHex; address = r.address;
} else { throw new Error("supply mnemonic or seedHex"); }
spec.kind = "seed";
spec.seed = seedHex;
// path field carries the Sia address index as an integer string,
// opaque to the vault. Mount reads it back as Number(spec.path).
spec.path = String(index);
spec.cashaddr = address;
} else {
throw new Error(`import not supported for chain "${chain}"`);
}
@ -1082,6 +1135,26 @@ function registerPanelMessages(api) {
});
api.onMessage("refresh", async (_p, m) => { fromPanel(m); const rt = requireSelected(); await rt.adapter.refresh(true); return snapshotForSelected(); });
// Panel drilldown → refresh every wallet under a chain (optionally scoped
// to one subnetwork). Fires each adapter's refresh in parallel; individual
// failures set the adapter's own error field (surfaced back to the panel
// via emitStateForWallet) rather than aborting the batch. Returns the
// list of {id, ok, error} so the panel can flash a summary.
api.onMessage("refreshChain", async (p, m) => {
fromPanel(m);
const chain = String(p?.chain || "");
const network = p?.network ? String(p.network) : null;
if (!chain) throw new Error("chain is required");
const targets = walletEntries().filter((w) => w.chain === chain && (!network || w.network === network));
const out = [];
await Promise.all(targets.map(async (w) => {
const rt = ctx.runtimes.get(w.id);
if (!rt || !rt.adapter) { out.push({ id: w.id, ok: false, error: "adapter not mounted" }); return; }
try { await rt.adapter.refresh(true); out.push({ id: w.id, ok: true }); }
catch (e) { out.push({ id: w.id, ok: false, error: e?.message || String(e) }); }
}));
return { chain, network, results: out };
});
api.onMessage("nextAddress", (_p, m) => {
fromPanel(m);
const rt = requireSelected();
@ -1273,6 +1346,131 @@ function registerPanelMessages(api) {
return rt.adapter.signAndBroadcast(plan);
});
// ---- Balance consolidation ------------------------------------------------
// Batch send-max from every same-chain/same-network wallet (or a subset the
// user picked with checkboxes) into the currently-selected wallet. Runs in
// two phases:
// consolidatePreview — dry-run plan() per source; returns balance/fee/
// net/error so the panel renders a preview list
// with checkboxes without asking the user to
// approve anything yet.
// consolidateIntoSelected — signs + broadcasts one send per chosen source.
// The panel shows a single upfront confirmation
// (with the total to move and total fees); Theseus's
// per-tx approval overlay is skipped because the
// batch itself is the user's explicit intent.
api.onMessage("consolidatePreview", async (_p, m) => {
fromPanel(m);
const destId = selectedWalletId();
if (!destId) throw new Error("no wallet selected");
const destEntry = walletEntries().find((w) => w.id === destId);
if (!destEntry) throw new Error("selected wallet not found");
const destRt = ctx.runtimes.get(destId);
if (!destRt?.adapter) throw new Error("destination wallet not ready");
const destSnap = destRt.adapter.snapshot();
const destAddr = destSnap.address;
if (!destAddr) throw new Error("destination wallet has no receive address");
const sources = walletEntries().filter((w) =>
w.chain === destEntry.chain &&
w.network === destEntry.network &&
w.id !== destId,
);
const items = [];
for (const src of sources) {
const rt = ctx.runtimes.get(src.id);
const snap = rt?.adapter?.snapshot?.() || {};
const bal = snap.balance || {};
const totalUnits = typeof bal.confirmed === "string"
? (BigInt(bal.confirmed || "0") + BigInt(bal.unconfirmed || "0")).toString()
: String((bal.confirmed || 0) + (bal.unconfirmed || 0));
const base = {
walletId: src.id, label: src.label,
address: snap.address || null,
balance: totalUnits,
fee: null, net: null, error: null, eligible: false,
};
if (!rt?.adapter) { items.push({ ...base, error: "adapter not mounted" }); continue; }
if (typeof rt.adapter.plan !== "function") { items.push({ ...base, error: "adapter has no plan()" }); continue; }
// Dry-run send-max to the destination. plan() throws on empty /
// dust-only wallets — that's the "nothing to sweep" case and it
// reads as an error string per source in the preview.
try {
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
const fee = String(plan.fee ?? 0);
const net = String(plan.recipients?.[0]?.value ?? 0);
items.push({ ...base, fee, net, eligible: true });
} catch (e) {
items.push({ ...base, error: e?.message || String(e) });
}
}
const meta = chainMeta(destEntry.chain, destEntry.network) || null;
return {
destinationWalletId: destId,
destinationLabel: destEntry.label,
destinationAddress: destAddr,
chain: destEntry.chain,
network: destEntry.network,
ticker: meta?.ticker || "",
decimals: meta?.decimals || 8,
sources: items,
};
});
api.onMessage("consolidateIntoSelected", async (p, m) => {
fromPanel(m);
const destId = selectedWalletId();
if (!destId) throw new Error("no wallet selected");
const destEntry = walletEntries().find((w) => w.id === destId);
if (!destEntry) throw new Error("selected wallet not found");
const destRt = ctx.runtimes.get(destId);
if (!destRt?.adapter) throw new Error("destination wallet not ready");
const destAddr = destRt.adapter.snapshot().address;
if (!destAddr) throw new Error("destination wallet has no receive address");
// sourceIds are the wallets the user CHECKED in the preview. Defaults
// to every eligible sibling if the panel omits the field (safety net,
// shouldn't happen in normal flow).
const requested = Array.isArray(p?.sourceIds) && p.sourceIds.length
? new Set(p.sourceIds.map(String))
: null;
const sources = walletEntries().filter((w) =>
w.chain === destEntry.chain &&
w.network === destEntry.network &&
w.id !== destId &&
(!requested || requested.has(w.id)),
);
const results = [];
for (const src of sources) {
const rt = ctx.runtimes.get(src.id);
if (!rt?.adapter || typeof rt.adapter.plan !== "function") {
results.push({ walletId: src.id, label: src.label, ok: false, error: "adapter not mounted" });
continue;
}
try {
const plan = await Promise.resolve(rt.adapter.plan({ to: destAddr, sendMax: true }));
const r = await rt.adapter.signAndBroadcast(plan);
results.push({
walletId: src.id, label: src.label, ok: true,
txid: r?.txid || null,
sent: String(plan.recipients?.[0]?.value ?? 0),
fee: String(plan.fee ?? 0),
});
} catch (e) {
results.push({ walletId: src.id, label: src.label, ok: false, error: e?.message || String(e) });
}
}
// Force a refresh on the destination so its balance jumps once the txs
// reach the network's mempool. Silent-fail — panel will pick up state
// on the next state emit anyway.
try { if (typeof destRt.adapter.refresh === "function") destRt.adapter.refresh(false); } catch {}
return {
destinationWalletId: destId,
destinationAddress: destAddr,
chain: destEntry.chain,
network: destEntry.network,
results,
};
});
api.onMessage("recovery", async (p, m) => {
fromPanel(m);
const id = String(p && p.id || selectedWalletId());
@ -1700,6 +1898,52 @@ function registerPageMessages(api) {
return rt.adapter.signMessage(message);
});
});
// BCH message verification (BIP-137). Panel-only path: given a message,
// a base64 signature, and an address, return { valid, address,
// recoveredHash }. No approval modal (nothing spendable happens), no
// wallet lookup — pure crypto against the given address.
// Panel → BCMR resolver. Batched: pass an array of category hex strings,
// get back { <categoryHex>: {name, symbol, iconUri, decimals, source} }
// for every one that resolved. Missed categories map to null. This
// triggers a background fetch for anything not in the disk cache, so
// the second call for the same set returns instantly.
api.onMessage("tokenMetadata", async (p, m) => {
fromPanel(m);
const cats = Array.isArray(p?.categories) ? p.categories.map(String).filter((c) => /^[0-9a-f]{64}$/i.test(c)) : [];
if (!cats.length) return {};
const entries = await ctx.bcmr.lookupMany(cats);
const out = {};
for (const [cat, entry] of Object.entries(entries)) {
out[cat] = ctx.bcmr.metadataOf(entry);
}
return out;
});
// Read the configured BCMR registry list (defaults + any user additions).
api.onMessage("bcmrRegistries", (_p, m) => {
fromPanel(m);
return { registries: ctx.bcmr.registryList() };
});
// Overwrite the registry list. Empty array restores defaults on next read.
api.onMessage("setBcmrRegistries", (p, m) => {
fromPanel(m);
ctx.bcmr.setRegistries(Array.isArray(p?.registries) ? p.registries : []);
return { registries: ctx.bcmr.registryList() };
});
api.onMessage("verifyMessage", (p, m) => {
fromPanel(m);
const message = String(p?.message != null ? p.message : "");
const signature = String(p?.signature || "");
const address = String(p?.address || "");
if (!signature || !address) throw new Error("signature and address are required");
try {
return ctx.d.keysLib.verifyMessage(message, signature, address, {
cashaddr: ctx.d.cashaddr, secp256k1: ctx.d.secp256k1,
});
} catch (e) {
return { valid: false, error: e?.message || String(e) };
}
});
// ---- Tron bridge (tronWeb / tronLink) -----------------------------------
api.onMessage("trx.requestAccounts", async (_p, m) => {
@ -2251,6 +2495,13 @@ module.exports = {
log: (...a) => api.log("prices", ...a),
onChange: () => emitState(),
}),
// BCMR (CashTokens metadata registry) — resolves category hex to
// { name, symbol, iconUri, decimals }. Storage-scoped so per-user
// caches don't stomp each other; disk-cached with 6h TTL.
bcmr: require("./lib/bcmr.js")({
storage: api.storage,
log: (...a) => api.log("bcmr", ...a),
}),
wc: null, // WizardConnect manager, initialised when deps load
};
migrateLegacyStorage(api);

View file

@ -0,0 +1,146 @@
// BCMR (Bitcoin Cash Metadata Registry) fetcher + cache. Resolves a
// CashTokens category hex to human-readable metadata: name, description,
// symbol, decimals, icon URL, and per-NFT metadata when the registry
// carries it.
//
// Registries are plain JSON documents (Bitauth "Bitcoin Cash Metadata
// Registries v2" schema). We support two ways to reach a registry today:
//
// 1. HTTPS URL configured per-user in Settings ("registry endpoints").
// The registry publishes a compact JSON with keyed identities;
// lookup by category is O(1).
// 2. Static bundled fallback (registries/) for a handful of well-known
// tokens (Cauldron, Fex.cash, TapSwap, ParyonUSD). Ships in the
// addon so brand-new users see names on the first launch even
// before they configure a live registry.
//
// Cache is on-disk via api.storage under "bcmr/<categoryHex>" =
// { snapshot, fetchedAt, source }. A metadata refresh runs at most once
// per REFRESH_MIN_MS per category to keep the panel snappy on repaint.
// No signature verification yet (BCMR v2 spec allows authchain-anchored
// signing; adding that is a follow-up once we support arbitrary chain
// script parsing).
const REFRESH_MIN_MS = 6 * 60 * 60 * 1000; // 6 hours
// Well-known registries seeded on first run so a fresh wallet doesn't need
// any configuration to see names for the top BCH tokens. Users can add /
// remove entries in Settings.
const DEFAULT_REGISTRIES = [
{ id: "cashonize", label: "Cashonize registry", url: "https://raw.githubusercontent.com/cashonize/registry/main/bcmr.json" },
{ id: "salemkode", label: "SalemKode registry", url: "https://bcmr.salemkode.com/registry.json" },
];
module.exports = function makeBcmr({ storage, log = () => {} }) {
function registryList() {
const custom = storage.get("bcmr/registries", null);
if (Array.isArray(custom) && custom.length) return custom;
return DEFAULT_REGISTRIES.slice();
}
function setRegistries(list) {
const clean = Array.isArray(list) ? list.filter((r) => r && typeof r.url === "string" && /^https?:\/\//i.test(r.url)) : [];
storage.set("bcmr/registries", clean);
}
// Registry lookup: index-into-registry by category. BCMR v2 stores
// identities keyed by category id (hex). Each identity has a history
// array; the newest history[0] entry is the current snapshot.
function pickIdentity(regJson, categoryHex) {
const identities = regJson?.identities || {};
const identity = identities[categoryHex];
if (!identity) return null;
// History is a { <timestamp>: snapshot } map. Newest wins by ISO
// string sort — the schema recommends ISO 8601 timestamps and both
// registries above emit them, so lexicographic sort matches temporal
// sort for anything after 1000 AD.
const entries = Object.entries(identity);
if (!entries.length) return null;
entries.sort((a, b) => (b[0] > a[0] ? 1 : -1));
const [, snap] = entries[0];
return snap;
}
async function fetchRegistry(url) {
const r = await fetch(url, { cache: "no-store" });
if (!r.ok) throw new Error(`bcmr: HTTP ${r.status} from ${url}`);
return r.json();
}
// Attempt every configured registry in parallel; first identity found
// wins. When two registries carry a category, we prefer the one earlier
// in the list (user-configured order = priority).
async function lookup(categoryHex) {
const registries = registryList();
if (!registries.length) return null;
// Try cache first.
const cached = storage.get(`bcmr/${categoryHex}`, null);
if (cached && Date.now() - (cached.fetchedAt || 0) < REFRESH_MIN_MS) return cached;
const attempts = await Promise.all(registries.map(async (reg) => {
try {
const json = await fetchRegistry(reg.url);
const identity = pickIdentity(json, categoryHex);
return identity ? { identity, source: reg.label || reg.id, url: reg.url } : null;
} catch (e) {
log(`bcmr: registry "${reg.label || reg.url}" failed:`, e?.message || e);
return null;
}
}));
const hit = attempts.find((a) => a);
if (!hit) {
// Negative cache with a short TTL so a missing category doesn't
// hammer every registry on every wallet refresh.
const miss = { snapshot: null, fetchedAt: Date.now(), source: null, url: null };
storage.set(`bcmr/${categoryHex}`, miss);
return miss;
}
const entry = {
snapshot: hit.identity,
fetchedAt: Date.now(),
source: hit.source,
url: hit.url,
};
storage.set(`bcmr/${categoryHex}`, entry);
return entry;
}
// Batch lookup — returns { <categoryHex>: cacheEntry }. Reuses individual
// lookup() which handles per-category caching + negative caching.
async function lookupMany(categoryHexes) {
const out = {};
await Promise.all(categoryHexes.map(async (cat) => {
try { out[cat] = await lookup(cat); }
catch (e) { out[cat] = { snapshot: null, error: e?.message || String(e) }; }
}));
return out;
}
// Read-only cached lookup — never hits network. Used for the panel's
// synchronous render path so tokens draw immediately with whatever's
// in the cache; the async lookup() runs in the background afterwards.
function cached(categoryHex) {
return storage.get(`bcmr/${categoryHex}`, null);
}
// Compact metadata slice the panel wants: { name, symbol, description,
// decimals, iconUri }. Handles both the top-level identity fields and
// the token subobject (BCMR v2 puts token-specific data there).
function metadataOf(entry) {
if (!entry || !entry.snapshot) return null;
const s = entry.snapshot;
const t = s.token || {};
return {
name: s.name || t.name || null,
symbol: s.token?.symbol || s.symbol || null,
description: s.description || null,
decimals: Number.isFinite(Number(t.decimals)) ? Number(t.decimals) : 0,
// Icon URIs live under s.uris.icon per schema; older files use s.icon.
iconUri: s.uris?.icon || s.icon || null,
source: entry.source || null,
};
}
return { lookup, lookupMany, cached, metadataOf, registryList, setRegistries, DEFAULT_REGISTRIES };
};

View file

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

View file

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

View file

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

View file

@ -105,6 +105,9 @@ module.exports = function makeBchAdapter({
addressIndex: w.addressIndex,
addressPath: w.addressPath,
balance: w.balance,
// CashTokens balances (0.7.0+). Categories → { fungible: str,
// nfts: [...], utxoCount }. Empty object when no tokens held.
tokenBalances: w.tokenBalances || {},
height: w.height,
history: w.history,
scanning: w.scanning,
@ -126,7 +129,8 @@ module.exports = function makeBchAdapter({
const targets = Array.isArray(spec.outputs) && spec.outputs.length
? spec.outputs.map((o) => ({ to: o.to, value: o.amount ?? o.value }))
: [{ to: spec.to, value: spec.amount ?? spec.value }];
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax });
const memo = typeof spec.memo === "string" ? spec.memo : "";
return this._wallet.plan({ targets, feeRate: spec.feeRate, sendMax: !!spec.sendMax, memo });
}
async signAndBroadcast(plan) { return this._wallet.signAndBroadcast(plan); }
// 65-byte BIP-137 recoverable signature — the format Electron Cash and

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff