UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

518 lines (517 loc) 21.5 kB
import { isFetchTimeoutError } from './ark.js'; import { eventSourceIterator } from './utils.js'; export var IndexerTxType; (function (IndexerTxType) { IndexerTxType[IndexerTxType["INDEXER_TX_TYPE_UNSPECIFIED"] = 0] = "INDEXER_TX_TYPE_UNSPECIFIED"; IndexerTxType[IndexerTxType["INDEXER_TX_TYPE_RECEIVED"] = 1] = "INDEXER_TX_TYPE_RECEIVED"; IndexerTxType[IndexerTxType["INDEXER_TX_TYPE_SENT"] = 2] = "INDEXER_TX_TYPE_SENT"; })(IndexerTxType || (IndexerTxType = {})); export var ChainTxType; (function (ChainTxType) { ChainTxType["UNSPECIFIED"] = "INDEXER_CHAINED_TX_TYPE_UNSPECIFIED"; ChainTxType["COMMITMENT"] = "INDEXER_CHAINED_TX_TYPE_COMMITMENT"; ChainTxType["ARK"] = "INDEXER_CHAINED_TX_TYPE_ARK"; ChainTxType["TREE"] = "INDEXER_CHAINED_TX_TYPE_TREE"; ChainTxType["CHECKPOINT"] = "INDEXER_CHAINED_TX_TYPE_CHECKPOINT"; })(ChainTxType || (ChainTxType = {})); /** * REST-based Indexer provider implementation. * @see https://buf.build/arkade-os/arkd/docs/main:ark.v1#ark.v1.IndexerService * @example * ```typescript * const provider = new RestIndexerProvider('https://ark.indexer.example.com'); * const commitmentTx = await provider.getCommitmentTx("6686af8f3be3517880821f62e6c3d749b9d6713736a1d8e229a55daa659446b2"); * ``` */ export class RestIndexerProvider { constructor(serverUrl) { this.serverUrl = serverUrl; } async getVtxoTree(batchOutpoint, opts) { let url = `${this.serverUrl}/v1/indexer/batch/${batchOutpoint.txid}/${batchOutpoint.vout}/tree`; const params = new URLSearchParams(); if (opts) { if (opts.pageIndex !== undefined) params.append("page.index", opts.pageIndex.toString()); if (opts.pageSize !== undefined) params.append("page.size", opts.pageSize.toString()); } if (params.toString()) { url += "?" + params.toString(); } const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch vtxo tree: ${res.statusText}`); } const data = await res.json(); if (!Response.isVtxoTreeResponse(data)) { throw new Error("Invalid vtxo tree data received"); } data.vtxoTree.forEach((tx) => { tx.children = Object.fromEntries(Object.entries(tx.children).map(([key, value]) => [ Number(key), value, ])); }); return data; } async getVtxoTreeLeaves(batchOutpoint, opts) { let url = `${this.serverUrl}/v1/indexer/batch/${batchOutpoint.txid}/${batchOutpoint.vout}/tree/leaves`; const params = new URLSearchParams(); if (opts) { if (opts.pageIndex !== undefined) params.append("page.index", opts.pageIndex.toString()); if (opts.pageSize !== undefined) params.append("page.size", opts.pageSize.toString()); } if (params.toString()) { url += "?" + params.toString(); } const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch vtxo tree leaves: ${res.statusText}`); } const data = await res.json(); if (!Response.isVtxoTreeLeavesResponse(data)) { throw new Error("Invalid vtxos tree leaves data received"); } return data; } async getBatchSweepTransactions(batchOutpoint) { const url = `${this.serverUrl}/v1/indexer/batch/${batchOutpoint.txid}/${batchOutpoint.vout}/sweepTxs`; const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch batch sweep transactions: ${res.statusText}`); } const data = await res.json(); if (!Response.isBatchSweepTransactionsResponse(data)) { throw new Error("Invalid batch sweep transactions data received"); } return data; } async getCommitmentTx(txid) { const url = `${this.serverUrl}/v1/indexer/commitmentTx/${txid}`; const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch commitment tx: ${res.statusText}`); } const data = await res.json(); if (!Response.isCommitmentTx(data)) { throw new Error("Invalid commitment tx data received"); } return data; } async getCommitmentTxConnectors(txid, opts) { let url = `${this.serverUrl}/v1/indexer/commitmentTx/${txid}/connectors`; const params = new URLSearchParams(); if (opts) { if (opts.pageIndex !== undefined) params.append("page.index", opts.pageIndex.toString()); if (opts.pageSize !== undefined) params.append("page.size", opts.pageSize.toString()); } if (params.toString()) { url += "?" + params.toString(); } const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch commitment tx connectors: ${res.statusText}`); } const data = await res.json(); if (!Response.isConnectorsResponse(data)) { throw new Error("Invalid commitment tx connectors data received"); } data.connectors.forEach((tx) => { tx.children = Object.fromEntries(Object.entries(tx.children).map(([key, value]) => [ Number(key), value, ])); }); return data; } async getCommitmentTxForfeitTxs(txid, opts) { let url = `${this.serverUrl}/v1/indexer/commitmentTx/${txid}/forfeitTxs`; const params = new URLSearchParams(); if (opts) { if (opts.pageIndex !== undefined) params.append("page.index", opts.pageIndex.toString()); if (opts.pageSize !== undefined) params.append("page.size", opts.pageSize.toString()); } if (params.toString()) { url += "?" + params.toString(); } const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch commitment tx forfeitTxs: ${res.statusText}`); } const data = await res.json(); if (!Response.isForfeitTxsResponse(data)) { throw new Error("Invalid commitment tx forfeitTxs data received"); } return data; } async *getSubscription(subscriptionId, abortSignal) { const url = `${this.serverUrl}/v1/indexer/script/subscription/${subscriptionId}`; while (!abortSignal?.aborted) { try { const eventSource = new EventSource(url); // Set up abort handling const abortHandler = () => { eventSource.close(); }; abortSignal?.addEventListener("abort", abortHandler); try { for await (const event of eventSourceIterator(eventSource)) { if (abortSignal?.aborted) break; try { const data = JSON.parse(event.data); if (data.event) { yield { txid: data.event.txid, scripts: data.event.scripts || [], newVtxos: (data.event.newVtxos || []).map(convertVtxo), spentVtxos: (data.event.spentVtxos || []).map(convertVtxo), sweptVtxos: (data.event.sweptVtxos || []).map(convertVtxo), tx: data.event.tx, checkpointTxs: data.event.checkpointTxs, }; } } catch (err) { console.error("Failed to parse subscription event:", err); throw err; } } } finally { abortSignal?.removeEventListener("abort", abortHandler); eventSource.close(); } } catch (error) { if (error instanceof Error && error.name === "AbortError") { break; } // ignore timeout errors, they're expected when the server is not sending anything for 5 min if (isFetchTimeoutError(error)) { console.debug("Timeout error ignored"); continue; } console.error("Subscription error:", error); throw error; } } } async getVirtualTxs(txids, opts) { let url = `${this.serverUrl}/v1/indexer/virtualTx/${txids.join(",")}`; const params = new URLSearchParams(); if (opts) { if (opts.pageIndex !== undefined) params.append("page.index", opts.pageIndex.toString()); if (opts.pageSize !== undefined) params.append("page.size", opts.pageSize.toString()); } if (params.toString()) { url += "?" + params.toString(); } const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch virtual txs: ${res.statusText}`); } const data = await res.json(); if (!Response.isVirtualTxsResponse(data)) { throw new Error("Invalid virtual txs data received"); } return data; } async getVtxoChain(vtxoOutpoint, opts) { let url = `${this.serverUrl}/v1/indexer/vtxo/${vtxoOutpoint.txid}/${vtxoOutpoint.vout}/chain`; const params = new URLSearchParams(); if (opts) { if (opts.pageIndex !== undefined) params.append("page.index", opts.pageIndex.toString()); if (opts.pageSize !== undefined) params.append("page.size", opts.pageSize.toString()); } if (params.toString()) { url += "?" + params.toString(); } const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch vtxo chain: ${res.statusText}`); } const data = await res.json(); if (!Response.isVtxoChainResponse(data)) { throw new Error("Invalid vtxo chain data received"); } return data; } async getVtxos(opts) { // scripts and outpoints are mutually exclusive if (opts?.scripts && opts?.outpoints) { throw new Error("scripts and outpoints are mutually exclusive options"); } if (!opts?.scripts && !opts?.outpoints) { throw new Error("Either scripts or outpoints must be provided"); } let url = `${this.serverUrl}/v1/indexer/vtxos`; const params = new URLSearchParams(); // Handle scripts with multi collection format if (opts?.scripts && opts.scripts.length > 0) { opts.scripts.forEach((script) => { params.append("scripts", script); }); } // Handle outpoints with multi collection format if (opts?.outpoints && opts.outpoints.length > 0) { opts.outpoints.forEach((outpoint) => { params.append("outpoints", `${outpoint.txid}:${outpoint.vout}`); }); } if (opts) { if (opts.spendableOnly !== undefined) params.append("spendableOnly", opts.spendableOnly.toString()); if (opts.spentOnly !== undefined) params.append("spentOnly", opts.spentOnly.toString()); if (opts.recoverableOnly !== undefined) params.append("recoverableOnly", opts.recoverableOnly.toString()); if (opts.pageIndex !== undefined) params.append("page.index", opts.pageIndex.toString()); if (opts.pageSize !== undefined) params.append("page.size", opts.pageSize.toString()); } if (params.toString()) { url += "?" + params.toString(); } const res = await fetch(url); if (!res.ok) { throw new Error(`Failed to fetch vtxos: ${res.statusText}`); } const data = await res.json(); if (!Response.isVtxosResponse(data)) { throw new Error("Invalid vtxos data received"); } return { vtxos: data.vtxos.map(convertVtxo), page: data.page, }; } async subscribeForScripts(scripts, subscriptionId) { const url = `${this.serverUrl}/v1/indexer/script/subscribe`; const res = await fetch(url, { headers: { "Content-Type": "application/json", }, method: "POST", body: JSON.stringify({ scripts, subscriptionId }), }); if (!res.ok) { const errorText = await res.text(); throw new Error(`Failed to subscribe to scripts: ${errorText}`); } const data = await res.json(); if (!data.subscriptionId) throw new Error(`Subscription ID not found`); return data.subscriptionId; } async unsubscribeForScripts(subscriptionId, scripts) { const url = `${this.serverUrl}/v1/indexer/script/unsubscribe`; const res = await fetch(url, { headers: { "Content-Type": "application/json", }, method: "POST", body: JSON.stringify({ subscriptionId, scripts }), }); if (!res.ok) { const errorText = await res.text(); console.warn(`Failed to unsubscribe to scripts: ${errorText}`); } } } function convertVtxo(vtxo) { return { txid: vtxo.outpoint.txid, vout: vtxo.outpoint.vout, value: Number(vtxo.amount), status: { confirmed: !vtxo.isSwept && !vtxo.isPreconfirmed, }, virtualStatus: { state: vtxo.isSwept ? "swept" : vtxo.isPreconfirmed ? "preconfirmed" : "settled", commitmentTxIds: vtxo.commitmentTxids, batchExpiry: vtxo.expiresAt ? Number(vtxo.expiresAt) * 1000 : undefined, }, spentBy: vtxo.spentBy ?? "", settledBy: vtxo.settledBy, arkTxId: vtxo.arkTxid, createdAt: new Date(Number(vtxo.createdAt) * 1000), isUnrolled: vtxo.isUnrolled, isSpent: vtxo.isSpent, }; } // Unexported namespace for type guards only var Response; (function (Response) { function isBatchInfo(data) { return (typeof data === "object" && typeof data.totalOutputAmount === "string" && typeof data.totalOutputVtxos === "number" && typeof data.expiresAt === "string" && typeof data.swept === "boolean"); } function isChain(data) { return (typeof data === "object" && typeof data.txid === "string" && typeof data.expiresAt === "string" && Object.values(ChainTxType).includes(data.type) && Array.isArray(data.spends) && data.spends.every((spend) => typeof spend === "string")); } function isCommitmentTx(data) { return (typeof data === "object" && typeof data.startedAt === "string" && typeof data.endedAt === "string" && typeof data.totalInputAmount === "string" && typeof data.totalInputVtxos === "number" && typeof data.totalOutputAmount === "string" && typeof data.totalOutputVtxos === "number" && typeof data.batches === "object" && Object.values(data.batches).every(isBatchInfo)); } Response.isCommitmentTx = isCommitmentTx; function isOutpoint(data) { return (typeof data === "object" && typeof data.txid === "string" && typeof data.vout === "number"); } Response.isOutpoint = isOutpoint; function isOutpointArray(data) { return Array.isArray(data) && data.every(isOutpoint); } Response.isOutpointArray = isOutpointArray; function isTx(data) { return (typeof data === "object" && typeof data.txid === "string" && typeof data.children === "object" && Object.values(data.children).every(isTxid) && Object.keys(data.children).every((k) => Number.isInteger(Number(k)))); } function isTxsArray(data) { return Array.isArray(data) && data.every(isTx); } Response.isTxsArray = isTxsArray; function isTxHistoryRecord(data) { return (typeof data === "object" && typeof data.amount === "string" && typeof data.createdAt === "string" && typeof data.isSettled === "boolean" && typeof data.settledBy === "string" && Object.values(IndexerTxType).includes(data.type) && ((!data.commitmentTxid && typeof data.virtualTxid === "string") || (typeof data.commitmentTxid === "string" && !data.virtualTxid))); } function isTxHistoryRecordArray(data) { return Array.isArray(data) && data.every(isTxHistoryRecord); } Response.isTxHistoryRecordArray = isTxHistoryRecordArray; function isTxid(data) { return typeof data === "string" && data.length === 64; } function isTxidArray(data) { return Array.isArray(data) && data.every(isTxid); } Response.isTxidArray = isTxidArray; function isVtxo(data) { return (typeof data === "object" && isOutpoint(data.outpoint) && typeof data.createdAt === "string" && (data.expiresAt === null || typeof data.expiresAt === "string") && typeof data.amount === "string" && typeof data.script === "string" && typeof data.isPreconfirmed === "boolean" && typeof data.isSwept === "boolean" && typeof data.isUnrolled === "boolean" && typeof data.isSpent === "boolean" && (!data.spentBy || typeof data.spentBy === "string") && (!data.settledBy || typeof data.settledBy === "string") && (!data.arkTxid || typeof data.arkTxid === "string") && Array.isArray(data.commitmentTxids) && data.commitmentTxids.every(isTxid)); } function isPageResponse(data) { return (typeof data === "object" && typeof data.current === "number" && typeof data.next === "number" && typeof data.total === "number"); } function isVtxoTreeResponse(data) { return (typeof data === "object" && Array.isArray(data.vtxoTree) && data.vtxoTree.every(isTx) && (!data.page || isPageResponse(data.page))); } Response.isVtxoTreeResponse = isVtxoTreeResponse; function isVtxoTreeLeavesResponse(data) { return (typeof data === "object" && Array.isArray(data.leaves) && data.leaves.every(isOutpoint) && (!data.page || isPageResponse(data.page))); } Response.isVtxoTreeLeavesResponse = isVtxoTreeLeavesResponse; function isConnectorsResponse(data) { return (typeof data === "object" && Array.isArray(data.connectors) && data.connectors.every(isTx) && (!data.page || isPageResponse(data.page))); } Response.isConnectorsResponse = isConnectorsResponse; function isForfeitTxsResponse(data) { return (typeof data === "object" && Array.isArray(data.txids) && data.txids.every(isTxid) && (!data.page || isPageResponse(data.page))); } Response.isForfeitTxsResponse = isForfeitTxsResponse; function isSweptCommitmentTxResponse(data) { return (typeof data === "object" && Array.isArray(data.sweptBy) && data.sweptBy.every(isTxid)); } Response.isSweptCommitmentTxResponse = isSweptCommitmentTxResponse; function isBatchSweepTransactionsResponse(data) { return (typeof data === "object" && Array.isArray(data.sweptBy) && data.sweptBy.every(isTxid)); } Response.isBatchSweepTransactionsResponse = isBatchSweepTransactionsResponse; function isVirtualTxsResponse(data) { return (typeof data === "object" && Array.isArray(data.txs) && data.txs.every((tx) => typeof tx === "string") && (!data.page || isPageResponse(data.page))); } Response.isVirtualTxsResponse = isVirtualTxsResponse; function isVtxoChainResponse(data) { return (typeof data === "object" && Array.isArray(data.chain) && data.chain.every(isChain) && (!data.page || isPageResponse(data.page))); } Response.isVtxoChainResponse = isVtxoChainResponse; function isVtxosResponse(data) { return (typeof data === "object" && Array.isArray(data.vtxos) && data.vtxos.every(isVtxo) && (!data.page || isPageResponse(data.page))); } Response.isVtxosResponse = isVtxosResponse; })(Response || (Response = {}));