157 lines
6.5 KiB
JavaScript
157 lines
6.5 KiB
JavaScript
|
|
// 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 };
|
||
|
|
};
|