// Sia (SC) chain adapter — v2 walletd-backed. Constructs a wallet from a // 32-byte root and a user-supplied walletd URL. Amounts are hastings // (1 SC = 10^24 hastings) and cross the panel boundary as decimal strings // so the panel never has to touch BigInt precision. // // Deps: sia.js / keys.js / wallet.js / walletd.js — copied from the // standalone siawallet addon on 2026-09-07 when Aegis absorbed it, so the // key derivation + tx layout are byte-identical to the older addon. // Existing on-chain funds carry over via the vault-derive absorb (aegis's // manifest lists "siawallet" under absorbs, so the legacy purpose // "siawallet/mainnet/0" resolves to the same seed inside Aegis). const SC = 10n ** 24n; const EXPLORER_TX = "https://siascan.com/tx/"; const EXPLORER_ADDR = "https://siascan.com/address/"; module.exports = function makeSiaAdapter({ ed25519, blake2b }) { if (!ed25519 || !blake2b) throw new Error("chain-sia: missing dep"); const sia = require("./sia/sia.js")({ ed25519, blake2b }); const keysLib = require("./sia/keys.js")({ sia }); const walletd = require("./sia/walletd.js")({ log: () => {} }); const walletFactory = require("./sia/wallet.js"); const siascanLib = require("./sia/siascan.js")({ log: () => {} }); function scopedStorage(storage, keyPrefix) { const k = (key) => keyPrefix + key; return { get: (key, fallback = null) => storage.get(k(key), fallback), set: (key, value) => storage.set(k(key), value), }; } class SiaWallet { constructor(root32, { walletId, storage, log = () => {}, onChange = () => {}, walletdUrl = "", siascanUrl = "", } = {}) { if (!walletId) throw new Error("chain-sia: walletId required"); this.walletId = walletId; this.chain = "sc"; this.network = "mainnet"; this.log = log; this.onChange = onChange; this.storage = scopedStorage(storage, `wallets/${walletId}/`); this._root = new Uint8Array(root32); this._keys = new keysLib.WalletKeys(root32); this._walletdUrl = String(walletdUrl || "").trim(); this._siascanUrl = String(siascanUrl || "").trim() || siascanLib.DEFAULT_BASE; this._client = null; this._wallet = null; // 0.7.5: siascan is the read-only fallback when no walletd URL is // set. Users get balance + history + broadcast (v2) with zero // hosting on their side, and can still point at their own walletd // if they want to run everything sovereign. this._siascan = new siascanLib.SiascanClient(this._siascanUrl); this._siascanState = { balance: { confirmed: "0", unconfirmed: "0", immature: "0" }, history: [], height: 0, addressIndex: 0, scanning: false, error: null, }; this._siascanTimer = null; if (this._walletdUrl) this._build(); else this._startSiascanPoll(); } _stopSiascanPoll() { clearTimeout(this._siascanTimer); this._siascanTimer = null; } _startSiascanPoll(intervalMs = 45_000) { this._stopSiascanPoll(); const tick = async () => { try { await this._refreshFromSiascan(); } catch (e) { this._siascanState.error = e?.message || String(e); this._emitChange(); } this._siascanTimer = setTimeout(tick, intervalMs); }; // Initial fire is immediate — users see a balance without a poll wait. this._siascanTimer = setTimeout(tick, 200); } _emitChange() { try { this.onChange(); } catch {} } async _refreshFromSiascan() { // Poll for the current receive index (default 0). SiaWallet's own // "nextAddress" logic is walletd-scoped; without walletd we track // the index in storage so the snapshot address stays stable. const idx = Number(this.storage.get("siascan/receiveIndex", 0)) || 0; const entry = this._keys.entry(idx); this._siascanState.scanning = true; this._emitChange(); try { const [tip, bal, events] = await Promise.all([ this._siascan.tip().catch(() => ({ height: 0 })), this._siascan.balance(entry.address), this._siascan.events(entry.address, { limit: 25 }).catch(() => []), ]); this._siascanState.height = tip.height; this._siascanState.balance = { confirmed: bal.confirmed, unconfirmed: bal.unconfirmed, immature: bal.immature, }; this._siascanState.history = siascanLib.normaliseEvents(events, entry.address, tip.height); this._siascanState.addressIndex = idx; this._siascanState.error = null; } finally { this._siascanState.scanning = false; this._emitChange(); } } _build() { if (this._wallet) { try { this._wallet.dispose(); } catch {} this._wallet = null; } if (this._client) this._client.setBase(this._walletdUrl); else this._client = new walletd.Client(this._walletdUrl); this._wallet = walletFactory({ client: this._client, keys: this._keys, sia, storage: this.storage, log: (...a) => this.log(...a), onChange: () => { try { this.onChange(); } catch {} }, }); } setWalletdUrl(url) { const v = String(url || "").trim(); if (v === this._walletdUrl) return; this._walletdUrl = v; if (v) { // Switching to walletd: stop siascan polling — walletd owns the // read path now. this._stopSiascanPoll(); this._build(); } else { // Dropping walletd URL: shut down the walletd wallet and resume // siascan polling so the panel keeps a live balance. try { this._wallet && this._wallet.dispose(); } catch {} this._wallet = null; this._startSiascanPoll(); } } // The panel treats Sia amounts as decimal strings of hastings; the // display layer picks how many SC-precision digits to show. snapshot() { const w = this._wallet && this._wallet.snapshot(); const usingSiascan = !this._wallet; const siascanIdx = this._siascanState.addressIndex; const siascanEntry = usingSiascan ? this._keys.entry(siascanIdx) : null; const base = { chain: "sc", network: "mainnet", ticker: "SC", decimals: 24, address: null, addressIndex: 0, addressPath: `KeyFromSeed(seed, ${w?.addressIndex || siascanIdx || 0})`, balance: { confirmed: "0", unconfirmed: "0" }, height: 0, history: [], scanning: false, error: null, // Server line the panel prints under the balance. When walletd is // set that's the walletd URL; otherwise it's the siascan endpoint // (which reads as public infrastructure, matching what's happening // under the hood — no seed-material leaves the machine). server: this._client ? this._client.displayUrl : (usingSiascan ? this._siascanUrl : null), walletdUrl: this._walletdUrl, // 0.7.5: needsWalletdUrl no longer gates the wallet. Siascan handles // read + broadcast automatically; the field stays for callers that // want to nudge users toward self-hosted infrastructure. needsWalletdUrl: false, siascanUrl: usingSiascan ? this._siascanUrl : null, readMode: usingSiascan ? "siascan" : "walletd", explorerTx: EXPLORER_TX, explorerAddr: EXPLORER_ADDR, faucet: null, }; if (w) { base.address = w.address; base.addressIndex = w.addressIndex; // wallet.js exposes confirmed / immature; the panel's shared shape // is confirmed / unconfirmed, so map immature → unconfirmed for // visual parity with BCH and TRX (small semantic bend, but the // number is the "not yet spendable" one either way). base.balance = { confirmed: w.balance.confirmed, unconfirmed: w.balance.immature }; base.height = w.height; base.history = (w.history || []).map((r) => ({ txid: r.id, delta: r.delta, to: r.to, from: null, fee: null, time: r.time || 0, confirmations: r.confirmations || 0, status: r.confirmations > 0 ? "confirmed" : "pending", kind: r.type, })); base.scanning = w.scanning; base.error = w.error; } else if (siascanEntry) { base.address = siascanEntry.address; base.addressIndex = siascanIdx; base.balance = { confirmed: this._siascanState.balance.confirmed, unconfirmed: this._siascanState.balance.unconfirmed, }; base.height = this._siascanState.height; base.history = this._siascanState.history; base.scanning = this._siascanState.scanning; base.error = this._siascanState.error; } return base; } async refresh(full) { if (this._wallet) return this._wallet.refresh(!!full); // Siascan path: fetch now, don't wait for the poll tick. return this._refreshFromSiascan(); } nextAddress() { if (this._wallet) return this._wallet.nextUnusedAddress(); // Siascan path: bump the stored receive index and re-poll. The next // snapshot round-trips through _refreshFromSiascan which reads the // updated storage value. const cur = Number(this.storage.get("siascan/receiveIndex", 0)) || 0; const nxt = cur + 1; this.storage.set("siascan/receiveIndex", nxt); this._refreshFromSiascan().catch(() => {}); const entry = this._keys.entry(nxt); return { address: entry.address, index: nxt }; } current() { if (this._wallet) return this._wallet.current(); const idx = Number(this.storage.get("siascan/receiveIndex", 0)) || 0; const entry = this._keys.entry(idx); return { address: entry.address, index: idx, path: `KeyFromSeed(seed, ${idx})`, pub: entry.pub }; } plan(spec) { if (!this._wallet) throw new Error("no walletd URL configured"); const targets = Array.isArray(spec.outputs) && spec.outputs.length ? spec.outputs.map((o) => ({ to: o.to, value: toHastings(o.amount ?? o.value) })) : [{ to: spec.to, value: toHastings(spec.amount ?? spec.value) }]; const p = this._wallet.plan({ targets, feeMultiplier: spec.feeMultiplier || spec.feeRate, sendMax: !!spec.sendMax }); // Present the plan in the common panel shape: BigInts as decimal // strings, plus `total = sum(recipients) + fee`. const sent = p.recipients.reduce((a, r) => a + BigInt(r.value), 0n); return { _sia: p, // internal handle so signAndBroadcast doesn't re-plan recipients: p.recipients, fee: p.fee.toString(), feeRate: p.feePerByte.toString(), inputs: p.tx.inputs, change: p.change.toString(), total: (sent + p.fee).toString(), }; } async signAndBroadcast(plan) { if (!this._wallet) throw new Error("no walletd URL configured"); const inner = plan && plan._sia; if (!inner) throw new Error("bad plan"); return this._wallet.signAndBroadcast(inner); } // Sia signature = ed25519 over blake2b256 of the raw message. Not a // BIP-137 style thing — dapps that want it should treat this as an // opaque {publicKey, signature} pair verified via ed25519. signMessage(message) { const entry = this.current(); const digest = sia.b256(new TextEncoder().encode(String(message))); const sig = this._keys.sign(entry, digest); return { address: entry.address, publicKey: "ed25519:" + sia.toHex(entry.pub), signature: sia.toHex(sig), }; } recovery() { // Sia has no xpub/xprv notion here; the scheme is "seed + index". return { accountPath: `KeyFromSeed(seed, i)`, xpub: sia.toHex(this._keys.entry(0).pub), // just the first pub for reference xprv: this._keys.seedHex, }; } startPolling() { if (this._wallet) this._wallet.startPolling(60_000); else this._startSiascanPoll(); } dispose() { this._stopSiascanPoll(); try { this._wallet && this._wallet.dispose(); } catch {} try { this._keys && this._keys.wipe(); } catch {} if (this._root) this._root.fill(0); } } // Amounts arrive as strings (hastings) or as small numbers. function toHastings(v) { if (typeof v === "bigint") return v; const s = String(v ?? "0").trim(); if (!/^\d+$/.test(s)) throw new Error("amount must be an integer number of hastings"); return BigInt(s); } return { SiaWallet, EXPLORER_TX, EXPLORER_ADDR }; };