UNPKG

@arkade-os/boltz-swap

Version:

A production-ready TypeScript package that brings Boltz submarine-swaps to Arkade.

1,237 lines (1,231 loc) 211 kB
import { applyCreatedAtOrder, applySwapsFilter, hasImpossibleSwapsFilter } from "./chunk-SJQJQO7P.js"; // src/errors.ts var SwapError = class extends Error { /** Whether the swap can still be claimed (default: false). */ isClaimable; /** Whether the swap can be refunded (default: false). */ isRefundable; /** The pending swap associated with this error, if available. */ pendingSwap; constructor(options = {}) { super( options.message ?? "Error during swap.", options.cause !== void 0 ? { cause: options.cause } : void 0 ); this.name = "SwapError"; this.isClaimable = options.isClaimable ?? false; this.isRefundable = options.isRefundable ?? false; this.pendingSwap = options.pendingSwap; } }; var InvoiceExpiredError = class extends SwapError { constructor(options = {}) { super({ message: "The invoice has expired.", ...options }); this.name = "InvoiceExpiredError"; } }; var InvoiceFailedToPayError = class extends SwapError { constructor(options = {}) { super({ message: "The provider failed to pay the invoice", ...options }); this.name = "InvoiceFailedToPayError"; } }; var InsufficientFundsError = class extends SwapError { constructor(options = {}) { super({ message: "Not enough funds available", ...options }); this.name = "InsufficientFundsError"; } }; var NetworkError = class extends Error { /** HTTP status code from the failed request, if available. */ statusCode; /** Raw error payload from the Boltz API, if available. */ errorData; constructor(message, statusCode, errorData) { super(message); this.name = "NetworkError"; this.statusCode = statusCode; this.errorData = errorData; } }; var SwapNotFoundError = class extends NetworkError { /** The swap ID Boltz did not recognise. */ swapId; constructor(swapId, errorData) { super( `Boltz returned 404 for swap '${swapId}': swap unknown to this Boltz instance`, 404, errorData ); this.name = "SwapNotFoundError"; this.swapId = swapId; } }; var SchemaError = class extends SwapError { constructor(options = {}) { super({ message: "Invalid API response", ...options }); this.name = "SchemaError"; } }; var SwapExpiredError = class extends SwapError { constructor(options = {}) { super({ message: "The swap has expired", ...options }); this.name = "SwapExpiredError"; } }; var TransactionFailedError = class extends SwapError { constructor(options = {}) { super({ message: "The transaction has failed.", ...options }); this.name = "TransactionFailedError"; } }; var PreimageFetchError = class extends SwapError { constructor(options = {}) { super({ message: "The payment settled, but fetching the preimage failed.", ...options }); this.name = "PreimageFetchError"; } }; var TransactionLockupFailedError = class extends SwapError { constructor(options = {}) { super({ message: "The transaction lockup has failed.", ...options }); this.name = "TransactionLockupFailedError"; } }; var TransactionRefundedError = class extends SwapError { constructor(options = {}) { super({ message: "The transaction has been refunded.", ...options }); this.name = "TransactionRefundedError"; } }; var QuoteRejectedError = class _QuoteRejectedError extends SwapError { reason; quotedAmount; floor; constructor(options) { super({ message: options.message ?? _QuoteRejectedError.defaultMessage(options), ...options }); this.name = "QuoteRejectedError"; this.reason = options.reason; this.quotedAmount = "quotedAmount" in options ? options.quotedAmount : void 0; this.floor = "floor" in options ? options.floor : void 0; } static defaultMessage(options) { switch (options.reason) { case "below_floor": return `Boltz quote ${options.quotedAmount} is below acceptable floor ${options.floor}`; case "non_positive": return `Boltz quote ${options.quotedAmount} is not positive`; case "non_safe_integer": return `Boltz quote ${options.quotedAmount} is not a safe positive satoshi integer`; case "no_baseline": return "Cannot accept quote: no minAcceptableAmount and no stored pending swap"; } } /** * Serialize into a plain `Error` whose `.message` carries the full * rejection payload as JSON behind a marker prefix. Structured clone * (used by `postMessage` between page and service worker) preserves * `Error.message` reliably but strips custom `.name` and own properties, * so we move the typed data into the message field for transport. */ toTransportError() { return new Error( QUOTE_REJECTION_TRANSPORT_PREFIX + JSON.stringify({ reason: this.reason, message: this.message, quotedAmount: this.quotedAmount, floor: this.floor }) ); } /** * Inverse of `toTransportError`. Returns a real `QuoteRejectedError` if * `error` carries the transport prefix, else `null`. */ static fromTransportError(error) { if (!(error instanceof Error) || !error.message.startsWith(QUOTE_REJECTION_TRANSPORT_PREFIX)) { return null; } const payload = error.message.slice(QUOTE_REJECTION_TRANSPORT_PREFIX.length); let data; try { data = JSON.parse(payload); } catch { return null; } if (typeof data.reason !== "string" || !QUOTE_REJECTION_REASONS.has(data.reason)) { return null; } const message = typeof data.message === "string" ? data.message : void 0; const reason = data.reason; const quotedAmount = typeof data.quotedAmount === "number" ? data.quotedAmount : null; const floor = typeof data.floor === "number" ? data.floor : null; switch (reason) { case "below_floor": if (quotedAmount === null || floor === null) return null; return new _QuoteRejectedError({ reason, quotedAmount, floor, message }); case "non_positive": case "non_safe_integer": if (quotedAmount === null) return null; return new _QuoteRejectedError({ reason, quotedAmount, message }); case "no_baseline": return new _QuoteRejectedError({ reason, message }); } } }; var QUOTE_REJECTION_TRANSPORT_PREFIX = "QUOTE_REJECTED::"; var QUOTE_REJECTION_REASONS = /* @__PURE__ */ new Set([ "below_floor", "non_positive", "non_safe_integer", "no_baseline" ]); var BoltzRefundError = class extends Error { constructor(message, cause) { super(message); this.cause = cause; this.name = "BoltzRefundError"; } }; // src/boltz-swap-provider.ts import { Transaction } from "@arkade-os/sdk"; import { base64 } from "@scure/base"; var isSubmarineFailedStatus = (status) => { return ["invoice.failedToPay", "transaction.lockupFailed", "swap.expired"].includes(status); }; var isSubmarineFinalStatus = (status) => { return ["invoice.failedToPay", "transaction.claimed", "swap.expired"].includes(status); }; var isSubmarinePendingStatus = (status) => { return [ "swap.created", "transaction.mempool", "transaction.confirmed", "invoice.set", "invoice.pending", "invoice.paid", "transaction.claim.pending" ].includes(status); }; var isSubmarineRefundableStatus = (status) => { return ["invoice.failedToPay", "transaction.lockupFailed", "swap.expired"].includes(status); }; var SUBMARINE_STATUS_PROGRESSION = [ "swap.created", "invoice.set", "transaction.mempool", "transaction.confirmed", "invoice.pending", "invoice.paid", "transaction.claim.pending", "transaction.claimed" ]; var hasSubmarineStatusReached = (status, target) => { const progression = SUBMARINE_STATUS_PROGRESSION; const statusIndex = progression.indexOf(status); const targetIndex = progression.indexOf(target); return statusIndex >= 0 && targetIndex >= 0 && statusIndex >= targetIndex; }; var isSubmarineSuccessStatus = (status) => { return status === "transaction.claimed"; }; var isReverseFailedStatus = (status) => { return [ "invoice.expired", "transaction.failed", "transaction.refunded", "swap.expired" ].includes(status); }; var isReverseFinalStatus = (status) => { return [ "transaction.refunded", "transaction.failed", "invoice.settled", // normal status for completed swaps "invoice.expired", "swap.expired" ].includes(status); }; var isReversePendingStatus = (status) => { return ["swap.created", "transaction.mempool", "transaction.confirmed"].includes(status); }; var isReverseClaimableStatus = (status) => { return ["transaction.mempool", "transaction.confirmed"].includes(status); }; var isReverseSuccessStatus = (status) => { return status === "invoice.settled"; }; var isChainFailedStatus = (status) => { return ["transaction.failed", "swap.expired"].includes(status); }; var isChainClaimableStatus = (status) => { return ["transaction.server.mempool", "transaction.server.confirmed"].includes(status); }; var isChainFinalStatus = (status) => { return [ "transaction.refunded", "transaction.failed", "transaction.claimed", // normal status for completed swaps "swap.expired" ].includes(status); }; var isChainPendingStatus = (status) => { return [ "swap.created", "transaction.mempool", "transaction.confirmed", "transaction.lockupFailed", "transaction.server.mempool", "transaction.server.confirmed" ].includes(status); }; var isChainRefundableStatus = (status) => { return ["swap.expired"].includes(status); }; var isChainSignableStatus = (status) => { return ["transaction.claim.pending"].includes(status); }; var isChainSuccessStatus = (status) => { return status === "transaction.claimed"; }; var isPendingReverseSwap = (swap) => { return swap.type === "reverse"; }; var isPendingSubmarineSwap = (swap) => { return swap.type === "submarine"; }; var isPendingChainSwap = (swap) => { return swap.type === "chain"; }; var isSubmarineSwapRefundable = (swap) => { return isSubmarineRefundableStatus(swap.status) && isPendingSubmarineSwap(swap) && swap.refundable !== false && swap.refunded !== true; }; var isChainSwapRefundable = (swap) => { return isChainRefundableStatus(swap.status) && isPendingChainSwap(swap) && swap.request.from === "ARK"; }; var isReverseSwapClaimable = (swap) => { return isReverseClaimableStatus(swap.status) && isPendingReverseSwap(swap); }; var isChainSwapClaimable = (swap) => { return isChainClaimableStatus(swap.status) && isPendingChainSwap(swap); }; var isTimeoutBlockHeights = (data) => { return data && typeof data === "object" && typeof data.refund === "number" && typeof data.unilateralClaim === "number" && typeof data.unilateralRefund === "number" && typeof data.unilateralRefundWithoutReceiver === "number"; }; var isGetReverseSwapTxIdResponse = (data) => { return data && typeof data === "object" && typeof data.id === "string" && typeof data.timeoutBlockHeight === "number"; }; var isGetSwapStatusResponse = (data) => { return data && typeof data === "object" && typeof data.status === "string" && (data.zeroConfRejected === void 0 || typeof data.zeroConfRejected === "boolean") && (data.transaction === void 0 || data.transaction && typeof data.transaction === "object" && typeof data.transaction.id === "string" && (data.transaction.confirmed === void 0 || typeof data.transaction.confirmed === "boolean") && (data.transaction.eta === void 0 || typeof data.transaction.eta === "number") && (data.transaction.hex === void 0 || typeof data.transaction.hex === "string") && (data.transaction.preimage === void 0 || typeof data.transaction.preimage === "string")); }; var isGetSubmarinePairsResponse = (data) => { return data && typeof data === "object" && data.ARK && typeof data.ARK === "object" && data.ARK.BTC && typeof data.ARK.BTC === "object" && typeof data.ARK.BTC.hash === "string" && typeof data.ARK.BTC.rate === "number" && data.ARK.BTC.limits && typeof data.ARK.BTC.limits === "object" && typeof data.ARK.BTC.limits.maximal === "number" && typeof data.ARK.BTC.limits.minimal === "number" && typeof data.ARK.BTC.limits.maximalZeroConf === "number" && data.ARK.BTC.fees && typeof data.ARK.BTC.fees === "object" && typeof data.ARK.BTC.fees.percentage === "number" && typeof data.ARK.BTC.fees.minerFees === "number"; }; var isGetReversePairsResponse = (data) => { return data && typeof data === "object" && data.BTC && typeof data.BTC === "object" && data.BTC.ARK && typeof data.BTC.ARK === "object" && data.BTC.ARK.hash && typeof data.BTC.ARK.hash === "string" && typeof data.BTC.ARK.rate === "number" && data.BTC.ARK.limits && typeof data.BTC.ARK.limits === "object" && typeof data.BTC.ARK.limits.maximal === "number" && typeof data.BTC.ARK.limits.minimal === "number" && data.BTC.ARK.fees && typeof data.BTC.ARK.fees === "object" && typeof data.BTC.ARK.fees.percentage === "number" && typeof data.BTC.ARK.fees.minerFees === "object" && typeof data.BTC.ARK.fees.minerFees.claim === "number" && typeof data.BTC.ARK.fees.minerFees.lockup === "number"; }; var isCreateSubmarineSwapResponse = (data) => { return data && typeof data === "object" && typeof data.id === "string" && typeof data.expectedAmount === "number" && (data.address === void 0 || typeof data.address === "string") && (data.claimPublicKey === void 0 || typeof data.claimPublicKey === "string") && (data.acceptZeroConf === void 0 || typeof data.acceptZeroConf === "boolean") && (data.timeoutBlockHeight === void 0 || typeof data.timeoutBlockHeight === "number") && (data.timeoutBlockHeights === void 0 || isTimeoutBlockHeights(data.timeoutBlockHeights)); }; var isGetSwapPreimageResponse = (data) => { return data && typeof data === "object" && typeof data.preimage === "string"; }; var isCreateReverseSwapResponse = (data) => { return data && typeof data === "object" && typeof data.id === "string" && typeof data.invoice === "string" && (data.onchainAmount === void 0 || typeof data.onchainAmount === "number") && (data.lockupAddress === void 0 || typeof data.lockupAddress === "string") && (data.refundPublicKey === void 0 || typeof data.refundPublicKey === "string") && (data.timeoutBlockHeight === void 0 || typeof data.timeoutBlockHeight === "number") && (data.timeoutBlockHeights === void 0 || isTimeoutBlockHeights(data.timeoutBlockHeights)); }; var isRefundSubmarineSwapResponse = (data) => { return data && typeof data === "object" && typeof data.transaction === "string" && typeof data.checkpoint === "string"; }; var isRefundChainSwapResponse = (data) => { return data && typeof data === "object" && typeof data.transaction === "string" && typeof data.checkpoint === "string"; }; var isGetChainPairsResponse = (data) => { return data && typeof data === "object" && data.ARK && data.BTC && typeof data.ARK === "object" && typeof data.BTC === "object" && data.ARK.BTC && data.BTC.ARK && typeof data.ARK.BTC === "object" && typeof data.BTC.ARK === "object" && typeof data.ARK.BTC.hash === "string" && typeof data.BTC.ARK.hash === "string" && typeof data.ARK.BTC.rate === "number" && typeof data.BTC.ARK.rate === "number" && data.ARK.BTC.limits && data.BTC.ARK.limits && typeof data.ARK.BTC.limits === "object" && typeof data.BTC.ARK.limits === "object" && typeof data.ARK.BTC.limits.maximal === "number" && typeof data.BTC.ARK.limits.maximal === "number" && typeof data.ARK.BTC.limits.minimal === "number" && typeof data.BTC.ARK.limits.minimal === "number" && typeof data.ARK.BTC.limits.maximalZeroConf === "number" && typeof data.BTC.ARK.limits.maximalZeroConf === "number" && data.ARK.BTC.fees && data.BTC.ARK.fees && typeof data.ARK.BTC.fees === "object" && typeof data.BTC.ARK.fees === "object" && typeof data.ARK.BTC.fees.percentage === "number" && typeof data.BTC.ARK.fees.percentage === "number" && typeof data.ARK.BTC.fees.minerFees === "object" && typeof data.BTC.ARK.fees.minerFees === "object" && typeof data.ARK.BTC.fees.minerFees.server === "number" && typeof data.BTC.ARK.fees.minerFees.server === "number" && data.ARK.BTC.fees.minerFees.user && data.BTC.ARK.fees.minerFees.user && typeof data.ARK.BTC.fees.minerFees.user === "object" && typeof data.BTC.ARK.fees.minerFees.user === "object" && typeof data.ARK.BTC.fees.minerFees.user.claim === "number" && typeof data.BTC.ARK.fees.minerFees.user.claim === "number" && typeof data.ARK.BTC.fees.minerFees.user.lockup === "number" && typeof data.BTC.ARK.fees.minerFees.user.lockup === "number"; }; var isSwapTree = (data) => { return data && typeof data === "object" && typeof data.claimLeaf === "object" && typeof data.claimLeaf.version === "number" && typeof data.claimLeaf.output === "string" && typeof data.refundLeaf === "object" && typeof data.refundLeaf.version === "number" && typeof data.refundLeaf.output === "string"; }; var isChainSwapDetailsResponse = (data) => { return data && typeof data === "object" && typeof data.amount === "number" && typeof data.lockupAddress === "string" && typeof data.serverPublicKey === "string" && typeof data.timeoutBlockHeight === "number" && (data.swapTree === void 0 || isSwapTree(data.swapTree)) && (data.timeouts === void 0 || isTimeoutBlockHeights(data.timeouts)) && (data.bip21 === void 0 || typeof data.bip21 === "string"); }; var isCreateChainSwapResponse = (data) => { return data && typeof data === "object" && typeof data.id === "string" && isChainSwapDetailsResponse(data.claimDetails) && isChainSwapDetailsResponse(data.lockupDetails); }; var isGetChainClaimDetailsResponse = (data) => { return data && typeof data === "object" && typeof data.pubNonce === "string" && typeof data.publicKey === "string" && typeof data.transactionHash === "string"; }; var isPostChainClaimDetailsResponse = (data) => { return data && typeof data === "object" && (typeof data.pubNonce === "string" && typeof data.partialSignature === "string" || typeof data.pubNonce === "undefined" && typeof data.partialSignature === "undefined"); }; var isGetChainQuoteResponse = (data) => { return data && typeof data === "object" && typeof data.amount === "number"; }; var isPostChainQuoteResponse = (data) => { return data && typeof data === "object" && Object.keys(data).length === 0 && data.constructor === Object; }; var isPostBtcTransactionResponse = (data) => { return data && typeof data === "object" && typeof data.id === "string"; }; var isLeaf = (data) => { return data && typeof data === "object" && typeof data.version === "number" && typeof data.output === "string"; }; var isTree = (data) => { return data && typeof data === "object" && isLeaf(data.claimLeaf) && isLeaf(data.refundLeaf) && (data.covenantClaimLeaf === void 0 || isLeaf(data.covenantClaimLeaf)) && (data.refundWithoutBoltzLeaf === void 0 || isLeaf(data.refundWithoutBoltzLeaf)) && (data.unilateralClaimLeaf === void 0 || isLeaf(data.unilateralClaimLeaf)) && (data.unilateralRefundLeaf === void 0 || isLeaf(data.unilateralRefundLeaf)) && (data.unilateralRefundWithoutBoltzLeaf === void 0 || isLeaf(data.unilateralRefundWithoutBoltzLeaf)); }; var isDetails = (data) => { return data && typeof data === "object" && isTree(data.tree) && (data.amount === void 0 || typeof data.amount === "number") && typeof data.keyIndex === "number" && (data.transaction === void 0 || data.transaction && typeof data.transaction === "object" && typeof data.transaction.id === "string" && typeof data.transaction.vout === "number") && typeof data.lockupAddress === "string" && typeof data.serverPublicKey === "string" && (data.timeoutBlockHeight === void 0 || typeof data.timeoutBlockHeight === "number") && (data.timeoutBlockHeights === void 0 || isTimeoutBlockHeights(data.timeoutBlockHeights)) && (data.preimageHash === void 0 || typeof data.preimageHash === "string"); }; var isRestoredChainSwap = (data) => { return data && typeof data === "object" && typeof data.id === "string" && data.type === "chain" && typeof data.status === "string" && typeof data.createdAt === "number" && (data.from === "ARK" || data.from === "BTC") && (data.to === "ARK" || data.to === "BTC") && (data.preimageHash === void 0 || typeof data.preimageHash === "string") && (data.invoice === void 0 || typeof data.invoice === "string") && (data.refundDetails === void 0 || isDetails(data.refundDetails)) && (data.claimDetails === void 0 || isDetails(data.claimDetails)); }; var isRestoredSubmarineSwap = (data) => { return data && typeof data === "object" && data.to === "BTC" && typeof data.id === "string" && data.from === "ARK" && data.type === "submarine" && typeof data.createdAt === "number" && (data.preimageHash === void 0 || typeof data.preimageHash === "string") && typeof data.status === "string" && isDetails(data.refundDetails) && (data.invoice === void 0 || typeof data.invoice === "string"); }; var isRestoredReverseSwap = (data) => { return data && typeof data === "object" && data.to === "ARK" && typeof data.id === "string" && data.from === "BTC" && data.type === "reverse" && typeof data.createdAt === "number" && (data.preimageHash === void 0 || typeof data.preimageHash === "string") && typeof data.status === "string" && isDetails(data.claimDetails) && (data.invoice === void 0 || typeof data.invoice === "string"); }; var isCreateSwapsRestoreResponse = (data) => { return Array.isArray(data) && data.every( (item) => isRestoredChainSwap(item) || isRestoredReverseSwap(item) || isRestoredSubmarineSwap(item) ); }; var BASE_URLS = { bitcoin: "https://api.boltz.exchange", mutinynet: "https://api.boltz.mutinynet.arkade.sh", regtest: "http://localhost:9069" }; var isSwapNotFoundBody = (error) => { const needle = "could not find swap"; const fromJson = error.errorData?.error; if (typeof fromJson === "string" && fromJson.toLowerCase().includes(needle)) return true; return error.message.toLowerCase().includes(needle); }; var BoltzSwapProvider = class { wsUrl; apiUrl; network; referralId; inflightGets = /* @__PURE__ */ new Map(); /** @param config Provider configuration with network and optional API URL. */ constructor(config) { this.network = config.network; this.referralId = config.referralId ?? "arkade-ts-sdk"; const apiUrl = config.apiUrl || BASE_URLS[config.network]; if (!apiUrl) throw new Error(`API URL is required for network: ${config.network}`); this.apiUrl = apiUrl; this.wsUrl = this.apiUrl.replace(/^http(s)?:\/\//, "ws$1://").replace("9069", "9004") + "/v2/ws"; } /** Returns the Boltz API base URL. */ getApiUrl() { return this.apiUrl; } /** Returns the Boltz WebSocket URL (derived from apiUrl). */ getWsUrl() { return this.wsUrl; } /** Returns the configured network. */ getNetwork() { return this.network; } /** Returns current Lightning swap fees (submarine + reverse) from Boltz. */ async getFees() { const [submarine, reverse] = await Promise.all([ this.request("/v2/swap/submarine", "GET"), this.request("/v2/swap/reverse", "GET") ]); if (!isGetSubmarinePairsResponse(submarine)) throw new SchemaError({ message: "error fetching submarine fees" }); if (!isGetReversePairsResponse(reverse)) throw new SchemaError({ message: "error fetching reverse fees" }); return { submarine: { percentage: submarine.ARK.BTC.fees.percentage, minerFees: submarine.ARK.BTC.fees.minerFees }, reverse: { percentage: reverse.BTC.ARK.fees.percentage, minerFees: reverse.BTC.ARK.fees.minerFees } }; } /** Returns current Lightning swap min/max limits from Boltz. */ async getLimits() { const response = await this.request("/v2/swap/submarine", "GET"); if (!isGetSubmarinePairsResponse(response)) throw new SchemaError({ message: "error fetching limits" }); return { min: response.ARK.BTC.limits.minimal, max: response.ARK.BTC.limits.maximal }; } /** Returns the current BTC chain tip height from Boltz. */ async getChainHeight() { const response = await this.request("/v2/chain/heights", "GET"); if (typeof response?.BTC !== "number") throw new SchemaError({ message: "error fetching chain heights" }); return response.BTC; } /** Gets the lockup transaction ID for a reverse swap. */ async getReverseSwapTxId(id) { const res = await this.request( `/v2/swap/reverse/${id}/transaction`, "GET" ); if (!isGetReverseSwapTxIdResponse(res)) throw new SchemaError({ message: `error fetching txid for swap: ${id}` }); return res; } /** * Queries the current status of a swap by ID. * * @throws {SwapNotFoundError} when Boltz responds with HTTP 404 and a body * matching the "could not find swap" pattern. Distinct from a generic 404 * so callers (e.g. SwapManager polling) can drive a per-swap unknown-to- * provider counter without tripping on transient route or proxy errors. */ async getSwapStatus(id) { let response; try { response = await this.request(`/v2/swap/${id}`, "GET"); } catch (error) { if (error instanceof NetworkError && error.statusCode === 404 && isSwapNotFoundBody(error)) { throw new SwapNotFoundError(id, error.errorData); } throw error; } if (!isGetSwapStatusResponse(response)) throw new SchemaError({ message: `error fetching status for swap: ${id}` }); return response; } /** Gets the preimage for a settled submarine swap (proof of payment). */ async getSwapPreimage(id) { const res = await this.request( `/v2/swap/submarine/${id}/preimage`, "GET" ); if (!isGetSwapPreimageResponse(res)) throw new SchemaError({ message: `error fetching preimage for swap: ${id}` }); return res; } /** Creates a submarine swap (Arkade → Lightning) on Boltz. */ async createSubmarineSwap({ invoice, refundPublicKey }) { if (refundPublicKey.length != 66) { throw new SwapError({ message: "refundPublicKey must be a compressed public key" }); } const requestBody = { from: "ARK", to: "BTC", invoice, refundPublicKey, ...this.referralId ? { referralId: this.referralId } : {} }; const response = await this.request( "/v2/swap/submarine", "POST", requestBody ); if (!isCreateSubmarineSwapResponse(response)) throw new SchemaError({ message: "Error creating submarine swap" }); return response; } /** Creates a reverse swap (Lightning → Arkade) on Boltz. */ async createReverseSwap({ invoiceAmount, claimPublicKey, preimageHash, description, descriptionHash }) { if (claimPublicKey.length != 66) { throw new SwapError({ message: "claimPublicKey must be a compressed public key" }); } if (descriptionHash !== void 0 && !/^[0-9a-fA-F]{64}$/.test(descriptionHash)) { throw new SwapError({ message: "descriptionHash must be a 32-byte SHA256 (64 hex chars)" }); } const requestBody = { from: "BTC", to: "ARK", invoiceAmount, claimPublicKey, preimageHash, ...descriptionHash ? { descriptionHash } : { description: description?.trim() || "Send to Arkade address" }, ...this.referralId ? { referralId: this.referralId } : {} }; const response = await this.request( "/v2/swap/reverse", "POST", requestBody ); if (!isCreateReverseSwapResponse(response)) throw new SchemaError({ message: "Error creating reverse swap" }); return response; } /** Creates a chain swap (ARK ↔ BTC) on Boltz. */ async createChainSwap({ to, from, preimageHash, feeSatsPerByte, claimPublicKey, refundPublicKey, serverLockAmount, userLockAmount }) { if (["BTC", "ARK"].indexOf(to) === -1) throw new SwapError({ message: "Invalid 'to' chain" }); if (["BTC", "ARK"].indexOf(from) === -1) throw new SwapError({ message: "Invalid 'from' chain" }); if (to === from) throw new SwapError({ message: "Invalid swap direction" }); if (!preimageHash || preimageHash.length != 64) throw new SwapError({ message: "Invalid preimageHash" }); if (feeSatsPerByte <= 0) throw new SwapError({ message: "Invalid feeSatsPerByte" }); if (serverLockAmount !== void 0 && userLockAmount !== void 0 || serverLockAmount === void 0 && userLockAmount === void 0) throw new SwapError({ message: "Either serverLockAmount or userLockAmount must be provided" }); if (userLockAmount !== void 0 && userLockAmount <= 0) throw new SwapError({ message: "Invalid userLockAmount" }); if (serverLockAmount !== void 0 && serverLockAmount <= 0) throw new SwapError({ message: "Invalid serverLockAmount" }); if (claimPublicKey.length != 66) { throw new SwapError({ message: "claimPublicKey must be a compressed public key" }); } if (refundPublicKey.length != 66) { throw new SwapError({ message: "refundPublicKey must be a compressed public key" }); } const requestBody = { to, from, preimageHash, feeSatsPerByte, claimPublicKey, refundPublicKey, serverLockAmount, userLockAmount, ...this.referralId ? { referralId: this.referralId } : {} }; const response = await this.request( "/v2/swap/chain", "POST", requestBody ); if (!isCreateChainSwapResponse(response)) throw new SchemaError({ message: "Error creating chain swap" }); return response; } /** Requests Boltz co-signature for a submarine swap refund. Returns signed transaction + checkpoint. */ async refundSubmarineSwap(swapId, transaction, checkpoint) { const requestBody = { checkpoint: base64.encode(checkpoint.toPSBT()), transaction: base64.encode(transaction.toPSBT()) }; const response = await this.request( `/v2/swap/submarine/${swapId}/refund/ark`, "POST", requestBody ); if (!isRefundSubmarineSwapResponse(response)) throw new SchemaError({ message: "Error refunding submarine swap" }); return { transaction: Transaction.fromPSBT(base64.decode(response.transaction)), checkpoint: Transaction.fromPSBT(base64.decode(response.checkpoint)) }; } /** Requests Boltz co-signature for a chain swap refund. Returns signed transaction + checkpoint. */ async refundChainSwap(swapId, transaction, checkpoint) { const requestBody = { checkpoint: base64.encode(checkpoint.toPSBT()), transaction: base64.encode(transaction.toPSBT()) }; const response = await this.request( `/v2/swap/chain/${swapId}/refund/ark`, "POST", requestBody ); if (!isRefundChainSwapResponse(response)) throw new SchemaError({ message: "Error refunding chain swap" }); return { transaction: Transaction.fromPSBT(base64.decode(response.transaction)), checkpoint: Transaction.fromPSBT(base64.decode(response.checkpoint)) }; } /** * Monitors swap status updates and forwards them to the update callback. * Prefers a WebSocket subscription; on connection error or premature close * it falls back to REST polling so callers don't fail when the WS endpoint * is flaky (observed in CI). Resolves when the swap reaches a terminal * status. */ async monitorSwap(swapId, update) { return new Promise((resolve, reject) => { let settled = false; let lastStatus = null; let pollTimer = null; let webSocket = null; let connectionTimeout = null; const cleanup = () => { if (connectionTimeout) { clearTimeout(connectionTimeout); connectionTimeout = null; } if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } if (webSocket) { try { webSocket.close(); } catch { } webSocket = null; } }; const finish = (err) => { if (settled) return; settled = true; cleanup(); if (err) reject(err); else resolve(); }; const handleStatus = (status, data) => { if (settled) return; if (status === lastStatus) return; lastStatus = status; const negotiable = status === "transaction.lockupFailed" && data?.failureDetails?.actual !== void 0 && data?.failureDetails?.expected !== void 0; switch (status) { case "invoice.settled": case "transaction.claimed": case "transaction.refunded": case "invoice.expired": case "invoice.failedToPay": case "transaction.failed": case "swap.expired": update(status, data); finish(); return; case "transaction.lockupFailed": update(status, data); if (!negotiable) finish(); return; case "invoice.paid": case "invoice.pending": case "invoice.set": case "swap.created": case "transaction.mempool": case "transaction.confirmed": case "transaction.claim.pending": case "transaction.server.mempool": case "transaction.server.confirmed": update(status, data); return; } }; const startPolling = () => { if (settled || pollTimer) return; const poll = async () => { if (settled) return; try { const result = await this.getSwapStatus(swapId); handleStatus(result.status, { ...result, id: swapId }); } catch { } }; pollTimer = setInterval(poll, 1e3); poll(); }; try { webSocket = new globalThis.WebSocket(this.wsUrl); connectionTimeout = setTimeout(() => { try { webSocket?.close(); } catch { } webSocket = null; startPolling(); }, 5e3); webSocket.onerror = () => { if (connectionTimeout) { clearTimeout(connectionTimeout); connectionTimeout = null; } webSocket = null; startPolling(); }; webSocket.onopen = () => { if (connectionTimeout) { clearTimeout(connectionTimeout); connectionTimeout = null; } webSocket?.send( JSON.stringify({ op: "subscribe", channel: "swap.update", args: [swapId] }) ); }; webSocket.onclose = () => { if (connectionTimeout) { clearTimeout(connectionTimeout); connectionTimeout = null; } if (!settled && !pollTimer) startPolling(); }; webSocket.onmessage = (rawMsg) => { try { const msg = JSON.parse(rawMsg.data); if (msg.event !== "update" || msg.args?.[0]?.id !== swapId) return; if (msg.args[0].error) { finish(new SwapError({ message: msg.args[0].error })); return; } handleStatus(msg.args[0].status, msg.args[0]); } catch { } }; } catch { webSocket = null; startPolling(); } }); } /** Returns current chain swap fees for a given pair (e.g. ARK→BTC). */ async getChainFees(from, to) { if (from === to) { throw new SwapError({ message: "Invalid chain pair" }); } const response = await this.request("/v2/swap/chain", "GET"); if (!isGetChainPairsResponse(response)) throw new SchemaError({ message: "error fetching fees" }); if (!response[from]?.[to]) { throw new SchemaError({ message: `unsupported chain pair: ${from} -> ${to}` }); } return response[from][to].fees; } /** Returns current chain swap min/max limits for a given pair. */ async getChainLimits(from, to) { if (from === to) { throw new SwapError({ message: "Invalid chain pair" }); } const response = await this.request("/v2/swap/chain", "GET"); if (!isGetChainPairsResponse(response)) throw new SchemaError({ message: "error fetching limits" }); if (!response[from]?.[to]) { throw new SchemaError({ message: `unsupported chain pair: ${from} -> ${to}` }); } return { min: response[from][to].limits.minimal, max: response[from][to].limits.maximal }; } /** Gets claim details (pubNonce, publicKey, transactionHash) for cooperative chain swap claiming. */ async getChainClaimDetails(swapId) { const response = await this.request( `/v2/swap/chain/${swapId}/claim`, "GET" ); if (!isGetChainClaimDetailsResponse(response)) throw new SchemaError({ message: `error fetching claim details for swap: ${swapId}` }); return response; } /** Gets a renegotiated quote for a chain swap when lockup amount differs from expected. */ async getChainQuote(swapId) { const response = await this.request( `/v2/swap/chain/${swapId}/quote`, "GET" ); if (!isGetChainQuoteResponse(response)) throw new SchemaError({ message: `error fetching quote for swap: ${swapId}` }); return response; } /** Accepts a renegotiated quote amount for a chain swap. */ async postChainQuote(swapId, request) { const response = await this.request( `/v2/swap/chain/${swapId}/quote`, "POST", request ); if (!isPostChainQuoteResponse(response)) throw new SchemaError({ message: `error posting quote for swap: ${swapId}` }); return response; } /** Broadcasts a raw BTC transaction through Boltz. */ async postBtcTransaction(hex9) { const requestBody = { hex: hex9 }; const response = await this.request( "/v2/chain/BTC/transaction", "POST", requestBody ); if (!isPostBtcTransactionResponse(response)) throw new SchemaError({ message: "error posting BTC transaction" }); return response; } /** Posts claim details (preimage + signing data) or cooperative signature for a chain swap. */ async postChainClaimDetails(swapId, request) { const response = await this.request( `/v2/swap/chain/${swapId}/claim`, "POST", request ); if (!isPostChainClaimDetailsResponse(response)) throw new SchemaError({ message: `error posting claim details for swap: ${swapId}` }); return response; } /** Restores swaps from Boltz API using the wallet's public key. */ async restoreSwaps(publicKey) { const requestBody = { publicKey }; const response = await this.request( "/v2/swap/restore", "POST", requestBody ); if (!isCreateSwapsRestoreResponse(response)) throw new SchemaError({ message: "Invalid schema in response for swap restoration" }); return response; } async request(path, method, body) { if (method === "GET") { const inflight = this.inflightGets.get(path); if (inflight) return inflight; const p = this.doRequest(path, method).finally(() => { this.inflightGets.delete(path); }); this.inflightGets.set(path, p); return p; } return this.doRequest(path, method, body); } async doRequest(path, method, body) { const url = `${this.apiUrl}${path}`; try { const response = await globalThis.fetch(url, { method, headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : void 0 }); if (!response.ok) { const errorBody = await response.text(); let errorData; try { errorData = JSON.parse(errorBody); } catch { } const message = `Boltz API error: ${response.status} ${errorBody}`; throw new NetworkError(message, response.status, errorData); } if (response.headers.get("content-length") === "0") { throw new NetworkError("Empty response from Boltz API"); } return await response.json(); } catch (error) { if (error instanceof NetworkError) throw error; throw new NetworkError(`Request to ${url} failed: ${error.message}`); } } }; // src/utils/decoding.ts import bolt11 from "light-bolt11-decoder"; import { ArkAddress } from "@arkade-os/sdk"; var decodeInvoice = (invoice) => { const decoded = bolt11.decode(invoice); const millisats = BigInt(decoded.sections.find((s) => s.name === "amount")?.value ?? "0"); return { expiry: decoded.expiry ?? 3600, // The timestamp fallback to 0 is just to satisfy the type checker. // In practice, the timestamp is always present in well-formatted BOLT11 invoices. timestamp: decoded.sections.find((s) => s.name === "timestamp")?.value ?? 0, amountSats: Number(millisats / 1000n), description: decoded.sections.find((s) => s.name === "description")?.value ?? "", // description_hash (BOLT11 `h`) is missing from light-bolt11-decoder's // Section union even though the decoder emits it. Widen just this lookup // rather than patch the library's types (separate repo, out of scope). descriptionHash: decoded.sections.find( (s) => s.name === "description_hash" )?.value ?? "", paymentHash: decoded.sections.find((s) => s.name === "payment_hash")?.value ?? "" }; }; var getInvoiceSatoshis = (invoice) => { return decodeInvoice(invoice).amountSats; }; var getInvoicePaymentHash = (invoice) => { return decodeInvoice(invoice).paymentHash; }; var isValidArkAddress = (address) => { try { ArkAddress.decode(address); return true; } catch (e) { return false; } }; // src/utils/signatures.ts import { verifyTapscriptSignatures } from "@arkade-os/sdk"; import { hex } from "@scure/base"; var verifySignatures = (tx, inputIndex, requiredSigners, expectedLeafHash) => { try { verifyTapscriptSignatures(tx, inputIndex, requiredSigners); const input = tx.getInput(inputIndex); const expectedHex = hex.encode(expectedLeafHash); return requiredSigners.every( (signer) => input.tapScriptSig?.some( ([{ pubKey, leafHash }]) => hex.encode(pubKey) === signer && hex.encode(leafHash) === expectedHex ) ); } catch (_) { return false; } }; var normalizeToXOnlyKey = (someKey, keyName = "", swapId = "") => { const keyBytes = typeof someKey === "string" ? hex.decode(someKey) : someKey; if (keyBytes.length === 33) { return keyBytes.slice(1); } if (keyBytes.length !== 32) { throw new Error( `Invalid ${keyName} key length: ${keyBytes.length} ${swapId ? "for swap " + swapId : ""}` ); } return keyBytes; }; // src/logger.ts var logger = console; function setLogger(customLogger) { logger = customLogger; } // src/swap-manager.ts var SwapManager = class _SwapManager { /** * Number of consecutive Boltz 404s for a single swap ID before the * polling loop gives up and transitions the swap to a terminal state. * At the default 30s poll cadence this is roughly a 5-minute grace * window — long enough to ride out a transient Boltz blip, short * enough that a real "swap unknown to this provider" surfaces quickly. */ static NOT_FOUND_THRESHOLD = 10; /** * Delay between re-attempts of a chain refund that left VTXOs deferred * (e.g. pre-CLTV recoverable VTXO, or Boltz 3-of-3 rejected before CLTV * has elapsed). Boltz won't send another status update once the swap * is `swap.expired`, so the manager owns the local retry cadence. */ static REFUND_RETRY_DELAY_MS = 6e4; swapProvider; config; // Event listeners storage (supports multiple listeners per event) swapUpdateListeners = /* @__PURE__ */ new Set(); swapCompletedListeners = /* @__PURE__ */ new Set(); swapFailedListeners = /* @__PURE__ */ new Set(); actionExecutedListeners = /* @__PURE__ */ new Set(); wsConnectedListeners = /* @__PURE__ */ new Set(); wsDisconnectedListeners = /* @__PURE__ */ new Set(); // State websocket = null; monitoredSwaps = /* @__PURE__ */ new Map(); initialSwaps = /* @__PURE__ */ new Map(); // All swaps passed to start(), including completed ones pollTimer = null; reconnectTimer = null; initialPollTimer = null; pollRetryTimers = /* @__PURE__ */ new Map(); // Per-swap retry timers for chain refunds that left work undone // (refundArk returned `skipped > 0`). The swap is held in // `monitoredSwaps` past its terminal Boltz status until the local // refund completes or the manager stops. refundRetryTimers = /* @__PURE__ */ new Map(); // Per-swap counter of consecutive `SwapNotFoundError` responses from // `getSwapStatus`. Reset on any successful poll. Once a swap reaches // `NOT_FOUND_THRESHOLD` consecutive 404s the safety net trips and the // swap is transitioned to `swap.expired` (terminal) and dropped from // monitoring — typically the canonical failure mode after a Boltz // endpoint switch, where old swap IDs are unknown to the new instance. notFoundCounts = /* @__PURE__ */ new Map(); isRunning = false; currentReconnectDelay; currentPollRetryDelay; usePollingFallback = false; isReconnecting = false; webSocketUnavailable = false; // Race condition prevention swapsInProgress = /* @__PURE__ */ new Set(); // Per-swap subscriptions for UI hooks swapSubscriptions = /* @__PURE__ */ new Map(); // In-flight (or settled) chain-swap claim promises, keyed by swap id. The // manager performs chain claims itself, so the claim tx it broadcasts is // the swap's on-chain completion; getSwapStatus does not surface that txid // at transaction.claimed. resolveClaimedTxid awaits the stored promise so a // transaction.claimed update that races an in-flight claim still resolves // the real txid instead of falling back to the provider and failing. chainClaimPromises = /* @__PURE__ */ new Map(); // Action callbacks (injected via setCallbacks) claimCallback = null; refundCallback = null; claimArkCallback = null; claimBtcCallback = null; refundArkCallback = null; signServerClaimCallback = null; saveSwapCallback = null; constructor(swapProvider, config = {}) { this.swapProvider = swapProvider; this.config = { enableAutoActions: config.enableAutoActions ?? true, pollInterval: config.pollInterval ?? 3e4, reconnectDelayMs: config.reconnectDelayMs ?? 1e3, maxReconnectDelayMs: config.maxReconnectDelayMs ?? 6e4, pollRetryDelayMs: config.pollRetryDelayMs ?? 5e3, maxPollRetryDelayMs: config.maxPollRetryDelayMs ?? 3e5, maxPollIntervalMs: config.maxPollIntervalMs ?? 3e5, events: config.events ?? {} }; if (config.events?.onSwapUpdate) { this.swapUpdateListeners.add(config.events.onSwapUpdate); } if (config.events?.onSwapCompleted) { this.swapCompletedListeners.add(config.events.onSwapCompleted); } if (config.events?.onSwapFailed) { this.swapFailedListeners.add(config.events.onSwapFailed); } if (config.events?.onActionExecuted) { this.actionExecutedListeners.add(config.events.onActionExecuted); } if (config.events?.onWebSocketConnected) { this.wsConnectedListeners.add(config.events.onWebSocketConnected); } if (config.events?.onWebSocketDisconnected) { this.wsDisconnectedListeners.add(config.events.onWebSocketDisconnected); } this.currentReconnectDelay = this.config.reconnectDelayMs; this.currentPollRetryDelay = this.config.pollRetryDelayMs; } /** * Set callbacks for claim, refund, and save operations. * These are called by the manager when autonomous actions are needed. */ setCallbacks(callbacks) { this.claimCallback = callbacks.claim; this.refundCallback = callbacks.refund; this.claimArkCallback = callbacks.claimArk; this.claimBtcCallback = callbacks.claimBtc; this.refundArkCallback = callbacks.refundArk; this.signServerClaimCallback = callbacks.signServerClaim ?? null; this.saveSwapCallback = callbacks.saveSwap; } /** * Add an event listener for swap updates * @returns Unsubscribe function */ async onSwapUpdate(listener) { this.swapUpdateListeners.add(listener); return () => this.swapUpdateListeners.delete(listener); } /** * Add an event listener for swap completion * @returns Unsubscribe function */ async onSwapCompleted(listener) { this.swapCompletedListeners.add(listener); return () => this.swapCompletedListeners.delete(listener); } /** * Add an event listener for swap failures * @returns Unsubscribe function */ async onSwapFailed(listener) { this.swapFailedListeners.add(listener); return () => this.swapFailedListeners.delete(listener); } /** * Add an event listener for executed actions (claim/refund) * @returns Unsubscribe function */ async onActionExecuted(listener) { this.actionExecutedListeners.add(listener); return () => this.actionExecutedListeners.delete(listener); } /** * Add an event listener for WebSocket connection * @returns Unsubscribe function */ async onWebSocketConnected(listener) { this.wsConnectedListeners.add(listener); return () => this.wsConnectedListeners.delete(listener); } /** * Add an event listener for WebSocket disconnection * @returns Unsubscribe function */ async onWebSocketDisconnected(listener) { this.wsDisconnectedListeners.add(listener); return () => this.wsDisconnectedListeners.delete(listener); } /** Remove a swap update listener */ offSwapUpdate(listener) { this.swapUpdateListene