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