theseus/bundled-addons/aegis/lib/sia/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

201 lines
9.4 KiB
JavaScript

// Sia wallet state: address discovery, balance, history and v2 sends over a
// walletd client. Keys are the addon's derived key tree; nothing here touches
// UI or IPC. Amounts are BigInt hastings throughout.
module.exports = function makeWallet({ client, keys, sia, storage, log = () => {}, onChange = () => {} }) {
const GAP = 10;
const HISTORY_LIMIT = 25;
const state = {
used: new Set(), // indexes with any event
height: 0,
balance: { confirmed: 0n, immature: 0n },
outputs: [], // spendable SiacoinElements with { index }
basis: null,
history: [],
receiveIndex: 0,
scanning: false,
error: null,
feePerByte: 0n,
};
// Outputs we just spent stay hidden until walletd stops listing them.
const pendingSpent = new Map(); // id -> timestamp
async function isUsed(entry) {
const ev = await client.events(entry.address, 1, 0);
return Array.isArray(ev) && ev.length > 0;
}
async function scan() {
const cursor = Number(storage.get("receiveCursor", 0)) || 0;
let gap = 0, i = 0;
while (gap < GAP || i < cursor + GAP) {
const e = keys.entry(i);
if (await isUsed(e)) { state.used.add(i); gap = 0; } else gap++;
i++;
}
let r = cursor;
while (state.used.has(r)) r++;
state.receiveIndex = r;
}
function watched() {
const idx = new Set(state.used); idx.add(state.receiveIndex);
return [...idx].map((i) => keys.entry(i));
}
async function loadOutputs() {
const tip = await client.tip();
state.height = tip.height || 0;
let confirmed = 0n, immature = 0n; const outs = []; let basis = null;
for (const e of watched()) {
for (let offset = 0; ; offset += 100) {
const r = await client.outputs(e.address, 100, offset);
basis = r.basis || basis;
const list = Array.isArray(r.outputs) ? r.outputs : [];
for (const o of list) {
const v = BigInt(o.siacoinOutput.value);
if (o.maturityHeight > state.height) { immature += v; continue; }
if (pendingSpent.has(o.id)) continue;
confirmed += v;
outs.push({ element: o, id: o.id, value: v, entry: e });
}
if (list.length < 100) break;
}
}
for (const [id, t] of pendingSpent) if (Date.now() - t > 20 * 60 * 1000) pendingSpent.delete(id);
state.outputs = outs; state.basis = basis;
state.balance = { confirmed, immature };
try { state.feePerByte = await client.feePerByte(); } catch (e) { log("fee lookup failed:", e?.message); }
}
// One row per event: net change for our addresses, type, confirmations.
async function loadHistory() {
const ours = new Set(watched().map((e) => e.address));
const seen = new Map();
for (const e of watched()) {
if (!state.used.has(e.index)) continue;
const evs = await client.events(e.address, HISTORY_LIMIT, 0);
for (const ev of Array.isArray(evs) ? evs : []) if (!seen.has(ev.id)) seen.set(ev.id, ev);
}
const rows = [];
for (const ev of seen.values()) {
let received = 0n, spent = 0n, to = null;
const d = ev.data || {};
const type = String(ev.type || "").toLowerCase();
if (type === "v2transaction" && d.transaction) {
for (const i of d.transaction.siacoinInputs || []) if (ours.has(i.parent.siacoinOutput.address)) spent += BigInt(i.parent.siacoinOutput.value);
for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; }
} else if (type === "v1transaction" && d.transaction) {
for (const s of d.spentSiacoinElements || []) if (ours.has(s.siacoinOutput.address)) spent += BigInt(s.siacoinOutput.value);
for (const o of d.transaction.siacoinOutputs || []) { if (ours.has(o.address)) received += BigInt(o.value); else if (!to) to = o.address; }
} else if (d.siacoinElement) {
if (ours.has(d.siacoinElement.siacoinOutput.address)) received += BigInt(d.siacoinElement.siacoinOutput.value);
}
rows.push({
id: ev.id, type: ev.type, height: ev.index ? ev.index.height : 0, confirmations: ev.confirmations || 0,
time: ev.timestamp ? Math.floor(Date.parse(ev.timestamp) / 1000) : 0,
delta: (received - spent).toString(), to: spent > received ? to : null,
maturityHeight: ev.maturityHeight || 0,
});
}
rows.sort((a, b) => (b.height || Infinity) - (a.height || Infinity) || b.time - a.time);
state.history = rows.slice(0, HISTORY_LIMIT);
}
async function refresh(full = false) {
if (state.scanning) return;
state.scanning = true; state.error = null; onChange();
try {
if (full || !state.used.size && state.receiveIndex === 0) await scan();
else { let r = Number(storage.get("receiveCursor", 0)) || 0; while (state.used.has(r)) r++; state.receiveIndex = r; }
await loadOutputs();
await loadHistory();
for (const o of state.outputs) state.used.add(o.entry.index);
let r = Number(storage.get("receiveCursor", 0)) || 0;
while (state.used.has(r)) r++;
state.receiveIndex = r;
} catch (e) {
state.error = e?.message || String(e);
log("refresh failed:", state.error);
} finally { state.scanning = false; onChange(); }
}
let pollTimer = null;
function startPolling(ms = 60000) { stopPolling(); pollTimer = setInterval(() => refresh(false), ms); }
function stopPolling() { clearInterval(pollTimer); pollTimer = null; }
function current() { return keys.entry(state.receiveIndex); }
function nextUnusedAddress() {
let r = state.receiveIndex + 1;
while (state.used.has(r)) r++;
storage.set("receiveCursor", r);
state.receiveIndex = r;
onChange();
return current();
}
// targets: [{ to, value: BigInt }]; feeMultiplier 1-3 over walletd's rate.
function plan({ targets, feeMultiplier = 1, sendMax = false }) {
const mult = BigInt(Math.min(3, Math.max(1, Math.round(Number(feeMultiplier) || 1))));
const rate = state.feePerByte > 0n ? state.feePerByte * mult : 10n ** 19n * mult;
const outs = targets.map((t) => {
const a = sia.parseAddress(t.to);
return { value: BigInt(t.value || 0), address32: a.bytes, to: a.address };
});
const sorted = state.outputs.slice().sort((a, b) => (b.value > a.value ? 1 : b.value < a.value ? -1 : 0));
const total = sorted.reduce((a, o) => a + o.value, 0n);
const change = current();
const txOf = (inputs, outputs, fee) => ({
inputs: inputs.map((o) => ({
parentId: o.id, element: o.element, value: o.value, address32: o.entry.address32, pub: o.entry.pub,
leafIndex: o.element.stateElement.leafIndex, merkleProof: o.element.stateElement.merkleProof || [], maturityHeight: o.element.maturityHeight || 0, entry: o.entry,
})),
outputs, minerFee: fee,
});
if (sendMax) {
if (outs.length !== 1) throw new Error("send max needs exactly one recipient");
if (!sorted.length) throw new Error("no spendable balance");
let fee = 0n;
for (let k = 0; k < 3; k++) fee = rate * BigInt(sia.weight(txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee)));
if (total <= fee) throw new Error("balance does not cover the fee");
const tx = txOf(sorted, [{ value: total - fee, address32: outs[0].address32 }], fee);
return { tx, fee, recipients: [{ to: outs[0].to, value: (total - fee).toString() }], change: 0n, feePerByte: rate };
}
const want = outs.reduce((a, o) => a + o.value, 0n);
for (const o of outs) if (o.value <= 0n) throw new Error("amount must be positive");
const chosen = []; let sum = 0n;
for (const o of sorted) {
chosen.push(o); sum += o.value;
const withChange = [...outs, { value: 0n, address32: change.address32 }];
const fee = rate * BigInt(sia.weight(txOf(chosen, withChange, 1n)));
if (sum >= want + fee) {
const rest = sum - want - fee;
const outputs = rest > 0n ? [...outs, { value: rest, address32: change.address32 }] : outs.slice();
const tx = txOf(chosen, outputs, fee);
return { tx, fee, recipients: outs.map((o) => ({ to: o.to, value: o.value.toString() })), change: rest, feePerByte: rate };
}
}
throw new Error("insufficient funds");
}
async function signAndBroadcast(p) {
const h = sia.inputSigHash(p.tx);
const sigs = p.tx.inputs.map((i) => keys.sign(i.entry, h));
const json = sia.toJson(p.tx, sigs);
if (!state.basis) throw new Error("no chain basis for the outputs; refresh first");
const r = await client.broadcast(state.basis, json);
const txid = r && r.v2transactions && r.v2transactions[0] && r.v2transactions[0].id;
for (const i of p.tx.inputs) pendingSpent.set(i.parentId, Date.now());
log("broadcast", txid || "(no id returned)");
setTimeout(() => refresh(false), 3000);
return { txid: txid || null, fee: p.fee.toString() };
}
function snapshot() {
const cur = current();
return {
address: cur.address, addressIndex: state.receiveIndex,
balance: { confirmed: state.balance.confirmed.toString(), immature: state.balance.immature.toString() },
height: state.height, history: state.history, outputCount: state.outputs.length,
feePerByte: state.feePerByte.toString(), scanning: state.scanning, error: state.error,
};
}
function dispose() { stopPolling(); }
return { refresh, snapshot, nextUnusedAddress, current, plan, signAndBroadcast, startPolling, dispose, state };
};