theseus/bundled-addons/siawallet/lib/wallet.js
Local Dev 7931d981aa feat(theseus/siawallet): bundled Siacoin wallet add-on (walletd-backed, v2)
Second bundled wallet, same shape as bchwallet:
- keys: api.vault.derive("siawallet/mainnet/0") as the seed for walletd's
  KeyFromSeed(seed, index) (blake2b(seed||index) -> ed25519); addresses are
  standard unlock hashes, so a future walletd seed import yields the same
  addresses. Seed and keys live in memory only.
- lib/sia.js: Sia binary encoder, StandardUnlockHash, address checksum,
  v2 InputSigHash ("sia/sig/input|" + replay byte 2 + transaction
  semantics), transaction weight, walletd JSON. Address hashing and the
  sighash were verified against real mainnet v2 transactions (signatures
  from block 591853 verify under this implementation).
- lib/walletd.js: address-scoped walletd HTTP client (tip, fee, balance,
  outputs with proofs, events, broadcast). The node URL is a user setting
  with no default; hosted providers embed the access key in the path, so
  only the origin is ever displayed or logged.
- lib/wallet.js: gap-limit discovery via events, mature/immature balance,
  history deltas from v1/v2/foundation/miner events, largest-first
  selection with change to the current address, fee = walletd rate x
  weight x 1-3 multiplier, broadcast with the outputs' basis. A signed tx
  built here was accepted structurally by a live walletd (rejected only
  for the stub key not owning the parent).
- panel: Receive (QR), Send, History, Settings (node URL, derivation info,
  seed reveal behind approval, connected sites); gates for locked vault,
  no vault, no node URL.
- window.siacoin dapp bridge: getAddress (rememberable), signAndSend with
  100/1,000/10,000 SC allowances, signMessage (ed25519 over blake2b-256 of
  the message) — same approval and permission rules as the BCH wallet.
2026-09-06 18:49:13 +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 };
};