// 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 }; };