202 lines
9.4 KiB
JavaScript
202 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 };
|
||
|
|
};
|