@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
JavaScript
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