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.
243 lines
9.8 KiB
JavaScript
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 };
|
|
};
|