UNPKG

@arkade-os/sdk

Version:

Bitcoin wallet SDK with Taproot and Ark integration

504 lines (503 loc) 19.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.RestArkProvider = exports.SettlementEventType = void 0; exports.isFetchTimeoutError = isFetchTimeoutError; const base_1 = require("@scure/base"); var SettlementEventType; (function (SettlementEventType) { SettlementEventType["BatchStarted"] = "batch_started"; SettlementEventType["BatchFinalization"] = "batch_finalization"; SettlementEventType["BatchFinalized"] = "batch_finalized"; SettlementEventType["BatchFailed"] = "batch_failed"; SettlementEventType["TreeSigningStarted"] = "tree_signing_started"; SettlementEventType["TreeNoncesAggregated"] = "tree_nonces_aggregated"; SettlementEventType["TreeTx"] = "tree_tx"; SettlementEventType["TreeSignature"] = "tree_signature"; })(SettlementEventType || (exports.SettlementEventType = SettlementEventType = {})); /** * REST-based Ark provider implementation. * @see https://buf.build/arkade-os/arkd/docs/main:ark.v1#ark.v1.ArkService * @example * ```typescript * const provider = new RestArkProvider('https://ark.example.com'); * const info = await provider.getInfo(); * ``` */ class RestArkProvider { constructor(serverUrl) { this.serverUrl = serverUrl; } async getInfo() { const url = `${this.serverUrl}/v1/info`; const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to get server info: ${response.statusText}`); } const fromServer = await response.json(); return { ...fromServer, vtxoTreeExpiry: BigInt(fromServer.vtxoTreeExpiry ?? 0), unilateralExitDelay: BigInt(fromServer.unilateralExitDelay ?? 0), roundInterval: BigInt(fromServer.roundInterval ?? 0), dust: BigInt(fromServer.dust ?? 0), utxoMinAmount: BigInt(fromServer.utxoMinAmount ?? 0), utxoMaxAmount: BigInt(fromServer.utxoMaxAmount ?? -1), vtxoMinAmount: BigInt(fromServer.vtxoMinAmount ?? 0), vtxoMaxAmount: BigInt(fromServer.vtxoMaxAmount ?? -1), boardingExitDelay: BigInt(fromServer.boardingExitDelay ?? 0), marketHour: "marketHour" in fromServer && fromServer.marketHour != null ? { nextStartTime: BigInt(fromServer.marketHour.nextStartTime ?? 0), nextEndTime: BigInt(fromServer.marketHour.nextEndTime ?? 0), period: BigInt(fromServer.marketHour.period ?? 0), roundInterval: BigInt(fromServer.marketHour.roundInterval ?? 0), } : undefined, }; } async submitTx(signedArkTx, checkpointTxs) { const url = `${this.serverUrl}/v1/tx/submit`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ signedArkTx: signedArkTx, checkpointTxs: checkpointTxs, }), }); if (!response.ok) { const errorText = await response.text(); try { const grpcError = JSON.parse(errorText); // gRPC errors usually have a message and code field throw new Error(`Failed to submit virtual transaction: ${grpcError.message || grpcError.error || errorText}`); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_) { // If JSON parse fails, use the raw error text throw new Error(`Failed to submit virtual transaction: ${errorText}`); } } const data = await response.json(); return { arkTxid: data.arkTxid, finalArkTx: data.finalArkTx, signedCheckpointTxs: data.signedCheckpointTxs, }; } async finalizeTx(arkTxid, finalCheckpointTxs) { const url = `${this.serverUrl}/v1/tx/finalize`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ arkTxid, finalCheckpointTxs, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to finalize offchain transaction: ${errorText}`); } } async registerIntent(intent) { const url = `${this.serverUrl}/v1/batch/registerIntent`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ intent: { signature: intent.signature, message: intent.message, }, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to register intent: ${errorText}`); } const data = await response.json(); return data.intentId; } async deleteIntent(intent) { const url = `${this.serverUrl}/v1/batch/deleteIntent`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ proof: { signature: intent.signature, message: intent.message, }, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to delete intent: ${errorText}`); } } async confirmRegistration(intentId) { const url = `${this.serverUrl}/v1/batch/ack`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ intentId, }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to confirm registration: ${errorText}`); } } async submitTreeNonces(batchId, pubkey, nonces) { const url = `${this.serverUrl}/v1/batch/tree/submitNonces`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ batchId, pubkey, treeNonces: encodeMusig2Nonces(nonces), }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to submit tree nonces: ${errorText}`); } } async submitTreeSignatures(batchId, pubkey, signatures) { const url = `${this.serverUrl}/v1/batch/tree/submitSignatures`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ batchId, pubkey, treeSignatures: encodeMusig2Signatures(signatures), }), }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to submit tree signatures: ${errorText}`); } } async submitSignedForfeitTxs(signedForfeitTxs, signedCommitmentTx) { const url = `${this.serverUrl}/v1/batch/submitForfeitTxs`; const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ signedForfeitTxs: signedForfeitTxs, signedCommitmentTx: signedCommitmentTx, }), }); if (!response.ok) { throw new Error(`Failed to submit forfeit transactions: ${response.statusText}`); } } async *getEventStream(signal, topics) { const url = `${this.serverUrl}/v1/batch/events`; const queryParams = topics.length > 0 ? `?${topics.map((topic) => `topics=${encodeURIComponent(topic)}`).join("&")}` : ""; while (!signal?.aborted) { try { const response = await fetch(url + queryParams, { headers: { Accept: "application/json", }, signal, }); if (!response.ok) { throw new Error(`Unexpected status ${response.status} when fetching event stream`); } if (!response.body) { throw new Error("Response body is null"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!signal?.aborted) { const { done, value } = await reader.read(); if (done) { break; } // Append new data to buffer and split by newlines buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); // Process all complete lines for (let i = 0; i < lines.length - 1; i++) { const line = lines[i].trim(); if (!line) continue; try { const data = JSON.parse(line); const event = this.parseSettlementEvent(data.result); if (event) { yield event; } } catch (err) { console.error("Failed to parse event:", err); throw err; } } // Keep the last partial line in the buffer buffer = lines[lines.length - 1]; } } 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 // these timeouts are set by builtin fetch function if (isFetchTimeoutError(error)) { console.debug("Timeout error ignored"); continue; } console.error("Event stream error:", error); throw error; } } } async *getTransactionsStream(signal) { const url = `${this.serverUrl}/v1/txs`; while (!signal?.aborted) { try { const response = await fetch(url, { headers: { Accept: "application/json", }, signal, }); if (!response.ok) { throw new Error(`Unexpected status ${response.status} when fetching transaction stream`); } if (!response.body) { throw new Error("Response body is null"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (!signal?.aborted) { const { done, value } = await reader.read(); if (done) { break; } // Append new data to buffer and split by newlines buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); // Process all complete lines for (let i = 0; i < lines.length - 1; i++) { const line = lines[i].trim(); if (!line) continue; const data = JSON.parse(line); const txNotification = this.parseTransactionNotification(data.result); if (txNotification) { yield txNotification; } } // Keep the last partial line in the buffer buffer = lines[lines.length - 1]; } } 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 // these timeouts are set by builtin fetch function if (isFetchTimeoutError(error)) { console.debug("Timeout error ignored"); continue; } console.error("Address subscription error:", error); throw error; } } } parseSettlementEvent(data) { // Check for BatchStarted event if (data.batchStarted) { return { type: SettlementEventType.BatchStarted, id: data.batchStarted.id, intentIdHashes: data.batchStarted.intentIdHashes, batchExpiry: BigInt(data.batchStarted.batchExpiry), }; } // Check for BatchFinalization event if (data.batchFinalization) { return { type: SettlementEventType.BatchFinalization, id: data.batchFinalization.id, commitmentTx: data.batchFinalization.commitmentTx, }; } // Check for BatchFinalized event if (data.batchFinalized) { return { type: SettlementEventType.BatchFinalized, id: data.batchFinalized.id, commitmentTxid: data.batchFinalized.commitmentTxid, }; } // Check for BatchFailed event if (data.batchFailed) { return { type: SettlementEventType.BatchFailed, id: data.batchFailed.id, reason: data.batchFailed.reason, }; } // Check for TreeSigningStarted event if (data.treeSigningStarted) { return { type: SettlementEventType.TreeSigningStarted, id: data.treeSigningStarted.id, cosignersPublicKeys: data.treeSigningStarted.cosignersPubkeys, unsignedCommitmentTx: data.treeSigningStarted.unsignedCommitmentTx, }; } // Check for TreeNoncesAggregated event if (data.treeNoncesAggregated) { return { type: SettlementEventType.TreeNoncesAggregated, id: data.treeNoncesAggregated.id, treeNonces: decodeMusig2Nonces(data.treeNoncesAggregated.treeNonces), }; } // Check for TreeTx event if (data.treeTx) { const children = Object.fromEntries(Object.entries(data.treeTx.children).map(([outputIndex, txid]) => { return [parseInt(outputIndex), txid]; })); return { type: SettlementEventType.TreeTx, id: data.treeTx.id, topic: data.treeTx.topic, batchIndex: data.treeTx.batchIndex, chunk: { txid: data.treeTx.txid, tx: data.treeTx.tx, children, }, }; } if (data.treeSignature) { return { type: SettlementEventType.TreeSignature, id: data.treeSignature.id, topic: data.treeSignature.topic, batchIndex: data.treeSignature.batchIndex, txid: data.treeSignature.txid, signature: data.treeSignature.signature, }; } console.warn("Unknown event type:", data); return null; } parseTransactionNotification(data) { if (data.commitmentTx) { return { commitmentTx: { txid: data.commitmentTx.txid, tx: data.commitmentTx.tx, spentVtxos: data.commitmentTx.spentVtxos.map(mapVtxo), spendableVtxos: data.commitmentTx.spendableVtxos.map(mapVtxo), checkpointTxs: data.commitmentTx.checkpointTxs, }, }; } if (data.arkTx) { return { arkTx: { txid: data.arkTx.txid, tx: data.arkTx.tx, spentVtxos: data.arkTx.spentVtxos.map(mapVtxo), spendableVtxos: data.arkTx.spendableVtxos.map(mapVtxo), checkpointTxs: data.arkTx.checkpointTxs, }, }; } console.warn("Unknown transaction notification type:", data); return null; } } exports.RestArkProvider = RestArkProvider; function encodeMusig2Nonces(nonces) { const noncesObject = {}; for (const [txid, nonce] of nonces) { noncesObject[txid] = base_1.hex.encode(nonce.pubNonce); } return JSON.stringify(noncesObject); } function encodeMusig2Signatures(signatures) { const sigObject = {}; for (const [txid, sig] of signatures) { sigObject[txid] = base_1.hex.encode(sig.encode()); } return JSON.stringify(sigObject); } function decodeMusig2Nonces(str) { const noncesObject = JSON.parse(str); return new Map(Object.entries(noncesObject).map(([txid, nonce]) => { if (typeof nonce !== "string") { throw new Error("invalid nonce"); } return [txid, { pubNonce: base_1.hex.decode(nonce) }]; })); } function isFetchTimeoutError(err) { const checkError = (error) => { if (!(error instanceof Error)) return false; // TODO: get something more robust than this const isCloudflare524 = error.name === "TypeError" && error.message === "Failed to fetch"; return (isCloudflare524 || error.name === "HeadersTimeoutError" || error.name === "BodyTimeoutError" || error.code === "UND_ERR_HEADERS_TIMEOUT" || error.code === "UND_ERR_BODY_TIMEOUT"); }; return checkError(err) || checkError(err.cause); } function mapVtxo(vtxo) { return { outpoint: { txid: vtxo.outpoint.txid, vout: vtxo.outpoint.vout, }, amount: vtxo.amount, script: vtxo.script, createdAt: vtxo.createdAt, expiresAt: vtxo.expiresAt, commitmentTxids: vtxo.commitmentTxids, isPreconfirmed: vtxo.isPreconfirmed, isSwept: vtxo.isSwept, isUnrolled: vtxo.isUnrolled, isSpent: vtxo.isSpent, spentBy: vtxo.spentBy, settledBy: vtxo.settledBy, arkTxid: vtxo.arkTxid, }; }