theseus/bundled-addons/aegis/lib/sia/siascan.js

157 lines
6.5 KiB
JavaScript
Raw Normal View History

// 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 };
};