// Siascan (SiaFoundation/explored) public read-only client. Used by // SiaWallet when the user has NOT pointed Aegis at their own walletd // URL — with a live siascan endpoint we can render balance, unspent // outputs, transaction history, and the chain tip without any hosting // on the user's side. // // Endpoints (from SiaFoundation/explored api/server.go): // GET /consensus/tip // GET /addresses/{addr}/balance // GET /addresses/{addr}/events // GET /addresses/{addr}/events/unconfirmed // GET /addresses/{addr}/utxos/siacoin // POST /txpool/broadcast — broadcast a v1 tx // POST /v2/transactions — batch fetch (not broadcast; broadcast is v1) // // Broadcast (send) still requires walletd today: the tx we build is a v2 // transaction and siascan's broadcast is currently v1-only. Once the v2 // broadcast endpoint lands upstream this same client picks it up. const DEFAULT_BASE = "https://api.siascan.com"; module.exports = function makeSiascan({ log = () => {} } = {}) { class SiascanClient { constructor(baseUrl) { this._base = String(baseUrl || DEFAULT_BASE).replace(/\/+$/, ""); } get displayUrl() { return this._base; } setBase(url) { this._base = String(url || DEFAULT_BASE).replace(/\/+$/, ""); } async _get(path) { const url = this._base + path; const r = await fetch(url, { cache: "no-store" }); if (!r.ok) { const body = await r.text().catch(() => ""); throw new Error(`siascan ${r.status} ${path}: ${body.slice(0, 200)}`); } return r.json(); } // Chain tip. Used to compute confirmations on history events. async tip() { const j = await this._get("/consensus/tip"); return { height: Number(j?.height || 0), id: String(j?.id || "") }; } // Wallet-agnostic balance for one address. Returns hastings as decimal // strings so the panel keeps the BigInt-safe wire format the walletd // path already emits. async balance(address) { const j = await this._get(`/addresses/${encodeURIComponent(address)}/balance`); // explored shape: { siacoins, immatureSiacoins, siafunds } // Each is a hastings string (v2 currency serialisation). return { confirmed: String(j?.siacoins || "0"), immature: String(j?.immatureSiacoins || "0"), // Aegis's panel treats "unconfirmed" as "not yet spendable". Explored // lumps immature payout there; a strict unconfirmed number would // need the /events/unconfirmed sum instead — added below. unconfirmed: String(j?.immatureSiacoins || "0"), siafunds: Number(j?.siafunds || 0), }; } // Confirmed history events. Each event carries a type ("v2Transaction", // "siacoinInput", "minerPayout", …), the amount delta from THIS address's // perspective, and a maturity/block height. async events(address, { limit = 25, offset = 0 } = {}) { const q = `?limit=${limit}&offset=${offset}`; const list = await this._get(`/addresses/${encodeURIComponent(address)}/events${q}`); return Array.isArray(list) ? list : []; } async unconfirmedEvents(address) { const list = await this._get(`/addresses/${encodeURIComponent(address)}/events/unconfirmed`); return Array.isArray(list) ? list : []; } // Unspent Siacoin outputs. { id, siacoinOutput: {value, address}, maturityHeight } async siacoinUtxos(address) { const list = await this._get(`/addresses/${encodeURIComponent(address)}/utxos/siacoin`); return Array.isArray(list) ? list : []; } // Broadcast a v2 transaction (or a set). explored's POST /txpool/broadcast // takes { transactions: [v1…], v2Transactions: [v2…] } — we only ever // send the v2 form (Aegis's tx builder is v2-only). Returns nothing // on success; a 200 means "accepted into the pool". async broadcastV2(v2TxOrSet) { const set = Array.isArray(v2TxOrSet) ? v2TxOrSet : [v2TxOrSet]; const url = this._base + "/txpool/broadcast"; const body = JSON.stringify({ transactions: [], v2Transactions: set }); const r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body, }); if (!r.ok) { const errBody = await r.text().catch(() => ""); throw new Error(`siascan broadcast ${r.status}: ${errBody.slice(0, 250)}`); } // explored responds 200 with an empty body on success; nothing to // parse. Caller derives the txid client-side from the signed tx. return true; } } // Compute a per-event delta for the SUBJECT address. explored returns // rich event structures; we normalise to Aegis's { txid, delta, to, // confirmations, time } row shape. delta is a signed BigInt-safe string. // Positive = received, negative = spent. function normaliseEvents(rawEvents, subjectAddress, tipHeight) { const out = []; for (const ev of rawEvents || []) { const kind = String(ev?.type || ""); const height = Number(ev?.index?.height || ev?.maturityHeight || 0); const confirmations = height && tipHeight ? Math.max(0, tipHeight - height + 1) : 0; // Sum outputs to us minus inputs from us. let received = 0n, spent = 0n, other = null; const dat = ev?.data || {}; const outputs = dat?.siacoinOutputs || dat?.transaction?.siacoinOutputs || []; const inputs = dat?.siacoinInputs || dat?.transaction?.siacoinInputs || []; for (const o of outputs) { const addr = o?.siacoinOutput?.address || o?.address || null; const val = toBigStr(o?.siacoinOutput?.value || o?.value); if (addr === subjectAddress) received += BigInt(val); else if (!other) other = addr; } for (const i of inputs) { const addr = i?.parent?.siacoinOutput?.address || i?.address || null; const val = toBigStr(i?.parent?.siacoinOutput?.value || i?.value); if (addr === subjectAddress) spent += BigInt(val); } const delta = (received - spent).toString(); out.push({ txid: String(ev?.id || ""), delta, to: (BigInt(delta) < 0n && other) ? other : null, from: null, fee: null, time: Number(ev?.timestamp || 0), confirmations, status: confirmations > 0 ? "confirmed" : "pending", kind, }); } return out; } function toBigStr(x) { if (typeof x === "string") return x; if (typeof x === "bigint") return x.toString(); return String(x || "0"); } return { SiascanClient, normaliseEvents, DEFAULT_BASE }; };