theseus/bundled-addons/aegis/lib/wallet.js
Local Dev 2e54bf5e5a Ship Theseus 0.3.28 5d15508b (Aegis update card + DevTools in tab sidebar + real favicons)
Setup    5d15508bba929f1f074c052ac933863eadf6eb8e56984ebd5a1af75e80626643
Portable a5d346b97f5a13d85fa3bd301a72075ddb82fe636d7b1a51840ffd5a16d879f4

Bundled since 0.3.27:

32d4b75 - Aegis (bchwallet) gains its own update card in Settings >
General beside Ariadne. Check for updates hits the same signed OTA
endpoint the boot timer uses; Restart to apply appears when a signed
newer version is staged. Uses the existing addons-check-updates + a
new app-restart IPC. New Aegis versions ship without a Theseus release.

32d4b75 (same commit) - DevTools (F12 / Ctrl+Shift+I) opens docked to
the right of the tab (mode: 'right') instead of a detached window.
Matches stock Chrome. Users who prefer detached can drag out via the
DevTools own toolbar.

b71c925 - Search-engine favicons in Settings > Search now use Google's
/s2/favicons service — DuckDuckGo's ip3 source returned 404 for enough
hosts (Brave, Bing, Yandex, etc.) that half the list was falling
through to the emoji placeholder.

Deployed. Verified LIVE 0.3.28.
2026-09-08 18:17:25 +02:00

243 lines
9.8 KiB
JavaScript

// 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.
module.exports = function makeWallet({ client, keys, tx, cashaddr, sha256, storage, log = () => {}, onChange = () => {} }) {
const GAP = 20;
const HISTORY_LIMIT = 25;
const sats = (bch) => Math.round(Number(bch) * 1e8);
const state = {
used: new Set(), // "branch/index" with history
watched: new Map(), // scripthash -> entry
height: 0,
balance: { confirmed: 0, unconfirmed: 0 },
utxos: [], // { txid, vout, value, height, entry }
history: [], // newest first
receiveIndex: 0,
scanning: false,
error: null,
};
// Verbose transactions are public chain data; caching them on disk saves a
// round of fetches on every launch.
const txCache = storage.get("txCache", {}) || {};
let refreshTimer = null;
let subscribedHeaders = false;
function key(e) { return e.branch + "/" + e.index; }
function watch(e) { if (!state.watched.has(e.scripthash)) state.watched.set(e.scripthash, e); }
async function historyOf(e) {
const h = await client.call("blockchain.scripthash.get_history", [e.scripthash]);
return Array.isArray(h) ? h : [];
}
// Walk both branches until GAP consecutive unused indexes, always covering
// the user's chosen receive cursor so its lookahead stays subscribed.
async function scan() {
const cursor = Number(storage.get("receiveCursor", 0)) || 0;
for (const branch of [0, 1]) {
let gap = 0, i = 0;
const minIndex = branch === 0 ? cursor + 1 : 0;
while (gap < GAP || i < minIndex + GAP) {
const batch = [];
for (let k = 0; k < 10; k++) batch.push(keys.entry(branch, i + k));
const results = await Promise.all(batch.map(historyOf));
for (let k = 0; k < batch.length; k++) {
const e = batch[k]; watch(e);
if (results[k].length) { state.used.add(key(e)); gap = 0; } else gap++;
i++;
if (gap >= GAP && i >= minIndex + GAP) break;
}
}
}
// Current receive address: first unused at or after the cursor.
let r = cursor;
while (state.used.has("0/" + r)) r++;
state.receiveIndex = r;
watch(keys.entry(0, r));
}
async function subscribeAll() {
if (!subscribedHeaders) {
subscribedHeaders = true;
const tip = await client.subscribe("blockchain.headers.subscribe", []);
if (tip && tip.height) state.height = tip.height;
}
await Promise.all([...state.watched.values()].map((e) =>
client.subscribe("blockchain.scripthash.subscribe", [e.scripthash]).catch(() => {})));
}
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 }));
}));
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 };
}
async function getTx(txid) {
const c = txCache[txid];
if (c && c.confirmations > 0) return c;
const raw = await client.call("blockchain.transaction.get", [txid, true]);
const slim = {
txid,
confirmations: raw.confirmations || 0,
time: raw.blocktime || raw.time || 0,
vin: (raw.vin || []).map((i) => ({ txid: i.txid, vout: i.vout })),
vout: (raw.vout || []).map((o) => ({ value: sats(o.value), scriptHex: o.scriptPubKey && o.scriptPubKey.hex })),
size: raw.size || 0,
};
txCache[txid] = slim;
return slim;
}
async function loadHistory() {
const entries = [...state.watched.values()].filter((e) => state.used.has(key(e)));
const merged = new Map();
const lists = await Promise.all(entries.map(historyOf));
for (const list of lists) for (const h of list) {
const prev = merged.get(h.tx_hash);
if (!prev || (h.height > 0 && prev.height <= 0)) merged.set(h.tx_hash, { txid: h.tx_hash, height: h.height });
}
const ordered = [...merged.values()].sort((a, b) => {
const ha = a.height > 0 ? a.height : Infinity, hb = b.height > 0 ? b.height : Infinity;
return hb - ha;
}).slice(0, HISTORY_LIMIT);
const ours = new Set([...state.watched.values()].map((e) => e.scriptHex));
const out = [];
for (const h of ordered) {
const t = await getTx(h.txid);
let received = 0, spent = 0, inputsTotal = 0, outputsTotal = 0, allInputsOurs = true;
for (const o of t.vout) { outputsTotal += o.value; if (ours.has(o.scriptHex)) received += o.value; }
for (const i of t.vin) {
if (!i.txid) continue; // coinbase
const p = await getTx(i.txid);
const po = p.vout[i.vout];
if (!po) continue;
inputsTotal += po.value;
if (ours.has(po.scriptHex)) spent += po.value; else allInputsOurs = false;
}
const delta = received - spent;
let to = null;
if (delta < 0) {
const ext = t.vout.find((o) => !ours.has(o.scriptHex));
if (ext && ext.scriptHex) to = scriptToAddress(ext.scriptHex);
}
out.push({
txid: t.txid, height: h.height, confirmations: t.confirmations, time: t.time,
delta, fee: allInputsOurs && inputsTotal ? inputsTotal - outputsTotal : null, to,
});
}
state.history = out;
storage.set("txCache", txCache);
}
function scriptToAddress(scriptHex) {
try {
if (/^76a914[0-9a-f]{40}88ac$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 0, tx.fromHex(scriptHex.slice(6, 46)));
if (/^a914[0-9a-f]{40}87$/.test(scriptHex)) return cashaddr.encode(keys.prefix, 1, tx.fromHex(scriptHex.slice(4, 44)));
} catch {}
return null;
}
async function refresh(full = false) {
if (state.scanning) return;
state.scanning = true; state.error = null; onChange();
try {
if (full || !state.watched.size) await scan();
else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has("0/" + r)) r++; state.receiveIndex = r; watch(keys.entry(0, r)); }
await loadUtxos();
await loadHistory();
await subscribeAll();
// A tx that just landed can mark the current receive address used.
for (const u of state.utxos) state.used.add(key(u.entry));
let r = Number(storage.get("receiveCursor", 0)) || 0;
while (state.used.has("0/" + r)) r++;
if (r !== state.receiveIndex) { state.receiveIndex = r; watch(keys.entry(0, r)); }
} catch (e) {
state.error = e?.message || String(e);
log("refresh failed:", state.error);
} finally {
state.scanning = false;
onChange();
}
}
function scheduleRefresh(ms = 800) {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => refresh(false), ms);
}
client.onNotify = (method, params) => {
if (method === "blockchain.headers.subscribe") {
const h = params && params[0] && params[0].height;
if (h) { state.height = h; scheduleRefresh(1500); }
} else if (method === "blockchain.scripthash.subscribe") {
scheduleRefresh(800);
}
};
function nextUnusedAddress() {
let r = state.receiveIndex + 1;
while (state.used.has("0/" + r)) r++;
storage.set("receiveCursor", r);
state.receiveIndex = r;
watch(keys.entry(0, r));
client.subscribe("blockchain.scripthash.subscribe", [keys.entry(0, r).scripthash]).catch(() => {});
onChange();
return current();
}
function current() { return keys.entry(0, state.receiveIndex); }
function changeEntry() {
let i = 0;
while (state.used.has("1/" + i)) i++;
return keys.entry(1, i);
}
// targets: [{ to, value }] (value in sats; ignored for sendMax) -> unsigned plan.
function plan({ targets, feeRate = 1, sendMax = false }) {
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);
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 };
});
// Spend confirmed coins first; unconfirmed only when needed.
const spendable = state.utxos.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 })) };
}
async function signAndBroadcast(p) {
const t = { inputs: p.inputs.map((u) => ({ ...u, script: u.entry.script })), outputs: p.outputs };
const signed = tx.sign(t, (inp, _i, digest) => ({ sig: keys.sign(inp.entry, digest), publicKey: inp.entry.publicKey }));
const txid = await client.call("blockchain.transaction.broadcast", [signed.hex]);
if (typeof txid !== "string" || txid.length !== 64) throw new Error("broadcast rejected: " + JSON.stringify(txid));
log("broadcast", txid);
scheduleRefresh(1200);
return { txid, hex: signed.hex, fee: p.fee };
}
function snapshot() {
const cur = current();
return {
address: cur.address,
addressIndex: state.receiveIndex,
addressPath: cur.path,
balance: state.balance,
height: state.height,
history: state.history,
utxoCount: state.utxos.length,
scanning: state.scanning,
error: state.error,
};
}
function dispose() { clearTimeout(refreshTimer); }
return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, dispose, state };
};