UNPKG

@arkade-os/boltz-swap

Version:

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

975 lines (968 loc) 36.7 kB
// src/utils/polling.ts function poll(fn, condition, delayMs, maxAttempts) { let attempts = 0; return new Promise((resolve, reject) => { const executePoll = async () => { attempts++; try { const result = await fn(); if (condition(result)) { return resolve(result); } else if (attempts >= maxAttempts) { return reject(new Error("Polling timed out.")); } else { setTimeout(executePoll, delayMs); } } catch (error) { if (attempts >= maxAttempts) { return reject(error); } setTimeout(executePoll, delayMs); } }; executePoll(); }); } // src/errors.ts var SwapError = class extends Error { isClaimable; isRefundable; pendingSwap; constructor(options = {}) { super(options.message ?? "Error during swap."); 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 { constructor(message) { super(message); this.name = "NetworkError"; } }; 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 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"; } }; // src/arkade-lightning.ts import { ArkAddress, buildOffchainTx, ConditionWitness, CSVMultisigTapscript, setArkPsbtField, VHTLC } from "@arkade-os/sdk"; import { sha256 } from "@noble/hashes/sha2"; import { base64, hex } from "@scure/base"; import { randomBytes } from "@noble/hashes/utils"; import { Transaction } from "@scure/btc-signer"; import { ripemd160 } from "@noble/hashes/legacy"; // src/utils/decoding.ts import bolt11 from "light-bolt11-decoder"; var decodeInvoice = (invoice) => { const decoded = bolt11.decode(invoice); const millisats = Number(decoded.sections.find((s) => s.name === "amount")?.value ?? "0"); return { expiry: decoded.expiry ?? 3600, amountSats: Math.floor(millisats / 1e3), description: decoded.sections.find((s) => s.name === "description")?.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; }; // src/arkade-lightning.ts var DEFAULT_TIMEOUT_CONFIG = { swapExpiryBlocks: 144, invoiceExpirySeconds: 3600, claimDelayBlocks: 10 }; var DEFAULT_FEE_CONFIG = { maxMinerFeeSats: 5e3, maxSwapFeeSats: 1e3 }; var DEFAULT_RETRY_CONFIG = { maxAttempts: 5, delayMs: 2e3 }; var ArkadeLightning = class { wallet; arkProvider; swapProvider; storageProvider; indexerProvider; config; constructor(config) { if (!config.wallet) throw new Error("Wallet is required."); if (!config.arkProvider) throw new Error("Ark provider is required."); if (!config.swapProvider) throw new Error("Swap provider is required."); if (!config.indexerProvider) throw new Error("Indexer provider is required."); this.wallet = config.wallet; this.arkProvider = config.arkProvider; this.swapProvider = config.swapProvider; this.storageProvider = config.storageProvider ?? null; this.indexerProvider = config.indexerProvider; this.config = { ...config, refundHandler: config.refundHandler ?? { onRefundNeeded: async () => { } }, timeoutConfig: { ...DEFAULT_TIMEOUT_CONFIG, ...config.timeoutConfig }, feeConfig: { ...DEFAULT_FEE_CONFIG, ...config.feeConfig }, retryConfig: { ...DEFAULT_RETRY_CONFIG, ...config.retryConfig } }; } // receive from lightning = reverse submarine swap // // 1. create invoice by creating a reverse swap // 2. monitor incoming payment by waiting for the hold invoice to be paid // 3. claim the VHTLC by creating a virtual transaction that spends the VHTLC output // 4. return the preimage and the swap info async createLightningInvoice(args) { return new Promise((resolve, reject) => { this.createReverseSwap(args).then((pendingSwap) => { this.storageProvider?.savePendingReverseSwap(pendingSwap); const decodedInvoice = decodeInvoice(pendingSwap.response.invoice); resolve({ amount: pendingSwap.response.onchainAmount, expiry: decodedInvoice.expiry, invoice: pendingSwap.response.invoice, paymentHash: decodedInvoice.paymentHash, pendingSwap, preimage: pendingSwap.preimage }); }).catch(reject); }); } /** * Sends a Lightning payment. * 1. decode the invoice to get the amount and destination * 2. create submarine swap with the decoded invoice * 3. send the swap address and expected amount to the wallet to create a transaction * 4. wait for the swap settlement and return the preimage and txid * @param args - The arguments for sending a Lightning payment. * @returns The result of the payment. */ async sendLightningPayment(args) { return new Promise((resolve, reject) => { this.createSubmarineSwap(args).then((pendingSwap) => { if (args.maxFeeSats) { const invoiceAmount = decodeInvoice(args.invoice).amountSats; const fees = pendingSwap.response.expectedAmount - invoiceAmount; if (fees > args.maxFeeSats) { reject(new SwapError({ message: `Swap fees ${fees} exceed max allowed ${args.maxFeeSats}` })); } } this.storageProvider?.savePendingSubmarineSwap(pendingSwap); this.wallet.sendBitcoin({ address: pendingSwap.response.address, amount: pendingSwap.response.expectedAmount }).then((txid) => { this.waitForSwapSettlement(pendingSwap).then(async () => { this.storageProvider?.deletePendingSubmarineSwap(pendingSwap.response.id); const finalStatus = await this.swapProvider.getSwapStatus(pendingSwap.response.id); const preimage = finalStatus.transaction?.preimage ?? ""; resolve({ amount: pendingSwap.response.expectedAmount, preimage, txid }); }).catch(({ isRefundable }) => { if (isRefundable) { this.refundVHTLC(pendingSwap).then(reject).catch(reject).finally(() => { this.storageProvider?.deletePendingSubmarineSwap(pendingSwap.response.id); }); } else { reject(new TransactionFailedError()); } }); }).catch(reject); }); }); } // create reverse submarine swap async createSubmarineSwap(args) { const refundPublicKey = hex.encode(this.wallet.xOnlyPublicKey()); if (!refundPublicKey) throw new SwapError({ message: "Failed to get refund public key from wallet" }); const invoice = args.invoice; if (!invoice) throw new SwapError({ message: "Invoice is required" }); const swapRequest = { invoice, refundPublicKey }; const swapResponse = await this.swapProvider.createSubmarineSwap(swapRequest); return { request: swapRequest, response: swapResponse, status: "invoice.set" }; } // create reverse submarine swap async createReverseSwap(args) { if (args.amount <= 0) throw new SwapError({ message: "Amount must be greater than 0" }); const claimPublicKey = hex.encode(this.wallet.xOnlyPublicKey()); if (!claimPublicKey) throw new SwapError({ message: "Failed to get claim public key from wallet" }); const preimage = randomBytes(32); const preimageHash = hex.encode(sha256(preimage)); if (!preimageHash) throw new SwapError({ message: "Failed to get preimage hash" }); const swapRequest = { invoiceAmount: args.amount, claimPublicKey, preimageHash }; const swapResponse = await this.swapProvider.createReverseSwap(swapRequest); return { preimage: hex.encode(preimage), request: swapRequest, response: swapResponse, status: "swap.created" }; } // claim the VHTLC by creating a virtual transaction that spends the VHTLC output async claimVHTLC(pendingSwap) { const preimage = hex.decode(pendingSwap.preimage); const aspInfo = await this.arkProvider.getInfo(); const address = await this.wallet.getAddress(); let receiverXOnlyPublicKey = this.wallet.xOnlyPublicKey(); if (receiverXOnlyPublicKey.length == 33) { receiverXOnlyPublicKey = receiverXOnlyPublicKey.slice(1); } else if (receiverXOnlyPublicKey.length !== 32) { throw new Error(`Invalid receiver public key length: ${receiverXOnlyPublicKey.length}`); } let serverXOnlyPublicKey = hex.decode(aspInfo.signerPubkey); if (serverXOnlyPublicKey.length == 33) { serverXOnlyPublicKey = serverXOnlyPublicKey.slice(1); } else if (serverXOnlyPublicKey.length !== 32) { throw new Error(`Invalid server public key length: ${serverXOnlyPublicKey.length}`); } const { vhtlcScript, vhtlcAddress } = this.createVHTLCScript({ network: aspInfo.network, preimageHash: sha256(preimage), receiverPubkey: hex.encode(receiverXOnlyPublicKey), senderPubkey: pendingSwap.response.refundPublicKey, serverPubkey: hex.encode(serverXOnlyPublicKey), timeoutBlockHeights: pendingSwap.response.timeoutBlockHeights }); if (!vhtlcScript) throw new Error("Failed to create VHTLC script for reverse swap"); if (vhtlcAddress !== pendingSwap.response.lockupAddress) throw new Error("Boltz is trying to scam us"); const spendableVtxos = await this.indexerProvider.getVtxos({ scripts: [hex.encode(vhtlcScript.pkScript)], spendableOnly: true }); if (spendableVtxos.vtxos.length === 0) throw new Error("No spendable virtual coins found"); const vtxo = spendableVtxos.vtxos[0]; const vhtlcIdentity = { sign: async (tx, inputIndexes) => { const cpy = tx.clone(); let signedTx = await this.wallet.sign(cpy, inputIndexes); signedTx = Transaction.fromPSBT(signedTx.toPSBT(), { allowUnknown: true }); setArkPsbtField(signedTx, 0, ConditionWitness, [preimage]); return signedTx; }, xOnlyPublicKey: receiverXOnlyPublicKey, signerSession: this.wallet.signerSession }; const serverUnrollScript = CSVMultisigTapscript.encode({ pubkeys: [serverXOnlyPublicKey], timelock: { type: aspInfo.unilateralExitDelay < 512 ? "blocks" : "seconds", value: aspInfo.unilateralExitDelay } }); const { arkTx, checkpoints } = buildOffchainTx( [ { ...spendableVtxos.vtxos[0], tapLeafScript: vhtlcScript.claim(), tapTree: vhtlcScript.encode() } ], [ { amount: BigInt(vtxo.value), script: ArkAddress.decode(address).pkScript } ], serverUnrollScript ); const signedArkTx = await vhtlcIdentity.sign(arkTx); const { arkTxid, finalArkTx, signedCheckpointTxs } = await this.arkProvider.submitTx( base64.encode(signedArkTx.toPSBT()), checkpoints.map((c) => base64.encode(c.toPSBT())) ); if (!this.validFinalArkTx(finalArkTx, serverXOnlyPublicKey, vhtlcScript.leaves)) { throw new Error("Invalid final Ark transaction"); } const finalCheckpoints = await Promise.all( signedCheckpointTxs.map(async (c) => { const tx = Transaction.fromPSBT(base64.decode(c), { allowUnknown: true }); const signedCheckpoint = await vhtlcIdentity.sign(tx, [0]); return base64.encode(signedCheckpoint.toPSBT()); }) ); await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints); this.storageProvider?.deletePendingReverseSwap(pendingSwap.response.id); } async refundVHTLC(pendingSwap) { const aspInfo = await this.arkProvider.getInfo(); const address = await this.wallet.getAddress(); if (!address) throw new Error("Failed to get ark address from service worker wallet"); let receiverXOnlyPublicKey = this.wallet.xOnlyPublicKey(); if (receiverXOnlyPublicKey.length == 33) { receiverXOnlyPublicKey = receiverXOnlyPublicKey.slice(1); } else if (receiverXOnlyPublicKey.length !== 32) { throw new Error(`Invalid receiver public key length: ${receiverXOnlyPublicKey.length}`); } let serverXOnlyPublicKey = hex.decode(aspInfo.signerPubkey); if (serverXOnlyPublicKey.length == 33) { serverXOnlyPublicKey = serverXOnlyPublicKey.slice(1); } else if (serverXOnlyPublicKey.length !== 32) { throw new Error(`Invalid server public key length: ${serverXOnlyPublicKey.length}`); } const { vhtlcScript, vhtlcAddress } = this.createVHTLCScript({ network: aspInfo.network, preimageHash: hex.decode(getInvoicePaymentHash(pendingSwap.request.invoice)), receiverPubkey: pendingSwap.response.claimPublicKey, senderPubkey: hex.encode(this.wallet.xOnlyPublicKey()), serverPubkey: aspInfo.signerPubkey, timeoutBlockHeights: pendingSwap.response.timeoutBlockHeights }); if (!vhtlcScript) throw new Error("Failed to create VHTLC script for reverse swap"); if (vhtlcAddress !== pendingSwap.response.address) throw new Error("Boltz is trying to scam us"); const spendableVtxos = await this.indexerProvider.getVtxos({ scripts: [hex.encode(vhtlcScript.pkScript)], spendableOnly: true }); if (spendableVtxos.vtxos.length === 0) { throw new Error("No spendable virtual coins found"); } const vhtlcIdentity = { sign: async (tx, inputIndexes) => { const cpy = tx.clone(); let signedTx = await this.wallet.sign(cpy, inputIndexes); return Transaction.fromPSBT(signedTx.toPSBT(), { allowUnknown: true }); }, xOnlyPublicKey: receiverXOnlyPublicKey, signerSession: this.wallet.signerSession }; const serverUnrollScript = CSVMultisigTapscript.encode({ pubkeys: [serverXOnlyPublicKey], timelock: { type: aspInfo.unilateralExitDelay < 512 ? "blocks" : "seconds", value: aspInfo.unilateralExitDelay } }); const { arkTx, checkpoints } = buildOffchainTx( [ { ...spendableVtxos.vtxos[0], tapLeafScript: vhtlcScript.refund(), tapTree: vhtlcScript.encode() } ], [ { amount: BigInt(spendableVtxos.vtxos[0].value), script: ArkAddress.decode(address).pkScript } ], serverUnrollScript ); const signedArkTx = await vhtlcIdentity.sign(arkTx); const { arkTxid, finalArkTx, signedCheckpointTxs } = await this.arkProvider.submitTx( base64.encode(signedArkTx.toPSBT()), checkpoints.map((c) => base64.encode(c.toPSBT())) ); if (!this.validFinalArkTx(finalArkTx, serverXOnlyPublicKey, vhtlcScript.leaves)) { throw new Error("Invalid final Ark transaction"); } const finalCheckpoints = await Promise.all( signedCheckpointTxs.map(async (c) => { const tx = Transaction.fromPSBT(base64.decode(c), { allowUnknown: true }); const signedCheckpoint = await vhtlcIdentity.sign(tx, [0]); return base64.encode(signedCheckpoint.toPSBT()); }) ); await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints); console.log("Successfully claimed VHTLC! Transaction ID:", arkTxid); } async waitAndClaim(pendingSwap) { return new Promise((resolve, reject) => { const onStatusUpdate = async (status) => { switch (status) { case "transaction.mempool": case "transaction.confirmed": this.claimVHTLC(pendingSwap).catch(reject); break; case "invoice.settled": { const status2 = await this.swapProvider.getSwapStatus(pendingSwap.response.id); resolve({ txid: status2.transaction?.id ?? "" }); break; } case "invoice.expired": reject(new InvoiceExpiredError({ isRefundable: true, pendingSwap })); break; case "swap.expired": reject(new SwapExpiredError({ isRefundable: true, pendingSwap })); break; case "transaction.failed": reject(new TransactionFailedError()); break; case "transaction.refunded": reject(new TransactionRefundedError()); break; default: break; } }; this.swapProvider.monitorSwap(pendingSwap.response.id, onStatusUpdate); }); } /** * Waits for the swap settlement. * @param pendingSwap - The pending swap. * @returns The status of the swap settlement. */ async waitForSwapSettlement(pendingSwap) { return new Promise((resolve, reject) => { const onStatusUpdate = async (status) => { switch (status) { case "swap.expired": reject(new SwapExpiredError({ isRefundable: true, pendingSwap })); break; case "invoice.failedToPay": reject(new InvoiceFailedToPayError({ isRefundable: true, pendingSwap })); break; case "transaction.lockupFailed": reject(new TransactionLockupFailedError({ isRefundable: true, pendingSwap })); break; case "transaction.claimed": resolve(); break; default: break; } }; this.swapProvider.monitorSwap(pendingSwap.response.id, onStatusUpdate); }); } /** * Waits for the swap settlement. * @param swapData - The swap data to wait for. * @returns The status of the swap settlement. */ async waitForSwapSettlementold(swapData) { const status = await poll( () => this.swapProvider.getSwapStatus(swapData.response.id), (status2) => status2.status === "transaction.claimed", this.config.retryConfig.delayMs ?? 2e3, this.config.retryConfig.maxAttempts ?? 5 ).catch((err) => { this.config.refundHandler.onRefundNeeded(swapData); throw new SwapError({ message: `Swap settlement failed: ${err.message}`, isRefundable: true, pendingSwap: { ...swapData } }); }); if (status.status === "transaction.claimed" && this.storageProvider) { await this.storageProvider.deletePendingSubmarineSwap(swapData.response.id); } return status; } // validators /** * Validates the final Ark transaction. * checks that all inputs have a signature for the given pubkey * and the signature is correct for the given tapscript leaf * TODO: This is a simplified check, we should verify the actual signatures * @param finalArkTx The final Ark transaction in PSBT format. * @param _pubkey The public key of the user. * @param _tapLeaves The taproot script leaves. * @returns True if the final Ark transaction is valid, false otherwise. */ validFinalArkTx = (finalArkTx, _pubkey, _tapLeaves) => { const tx = Transaction.fromPSBT(base64.decode(finalArkTx), { allowUnknown: true }); if (!tx) return false; const inputs = []; for (let i = 0; i < tx.inputsLength; i++) { inputs.push(tx.getInput(i)); } return inputs.every((input) => input.witnessUtxo); }; /** * Creates a VHTLC script for the swap. * works for submarine swaps and reverse swaps * it creates a VHTLC script that can be used to claim or refund the swap * it validates the receiver, sender and server public keys are x-only * it validates the VHTLC script matches the expected lockup address * @param param0 - The parameters for creating the VHTLC script. * @returns The created VHTLC script. */ createVHTLCScript({ network, preimageHash, receiverPubkey, senderPubkey, serverPubkey, timeoutBlockHeights }) { let receiverXOnlyPublicKey = hex.decode(receiverPubkey); if (receiverXOnlyPublicKey.length == 33) { receiverXOnlyPublicKey = receiverXOnlyPublicKey.slice(1); } else if (receiverXOnlyPublicKey.length !== 32) { throw new Error(`Invalid receiver public key length: ${receiverXOnlyPublicKey.length}`); } let senderXOnlyPublicKey = hex.decode(senderPubkey); if (senderXOnlyPublicKey.length == 33) { senderXOnlyPublicKey = senderXOnlyPublicKey.slice(1); } else if (senderXOnlyPublicKey.length !== 32) { throw new Error(`Invalid sender public key length: ${senderXOnlyPublicKey.length}`); } let serverXOnlyPublicKey = hex.decode(serverPubkey); if (serverXOnlyPublicKey.length == 33) { serverXOnlyPublicKey = serverXOnlyPublicKey.slice(1); } else if (serverXOnlyPublicKey.length !== 32) { throw new Error(`Invalid server public key length: ${serverXOnlyPublicKey.length}`); } const vhtlcScript = new VHTLC.Script({ preimageHash: ripemd160(preimageHash), sender: senderXOnlyPublicKey, receiver: receiverXOnlyPublicKey, server: serverXOnlyPublicKey, refundLocktime: BigInt(timeoutBlockHeights.refund), unilateralClaimDelay: { type: "blocks", value: BigInt(timeoutBlockHeights.unilateralClaim) }, unilateralRefundDelay: { type: "blocks", value: BigInt(timeoutBlockHeights.unilateralRefund) }, unilateralRefundWithoutReceiverDelay: { type: "blocks", value: BigInt(timeoutBlockHeights.unilateralRefundWithoutReceiver) } }); if (!vhtlcScript) throw new Error("Failed to create VHTLC script"); const hrp = network === "bitcoin" ? "ark" : "tark"; const vhtlcAddress = vhtlcScript.address(hrp, serverXOnlyPublicKey).encode(); return { vhtlcScript, vhtlcAddress }; } /** * Retrieves all pending submarine swaps from the storage provider. * This method filters the pending swaps to return only those with a status of 'invoice.set'. * It is useful for checking the status of all pending submarine swaps in the system. * @returns PendingSubmarineSwap[] or null if no storage provider is set. * If no swaps are found, it returns an empty array. */ getPendingSubmarineSwaps() { if (!this.storageProvider) return null; const swaps = this.storageProvider.getPendingSubmarineSwaps(); if (!swaps) return []; return swaps.filter((swap) => swap.status === "invoice.set"); } /** * Retrieves all pending reverse swaps from the storage provider. * This method filters the pending swaps to return only those with a status of 'swap.created'. * It is useful for checking the status of all pending reverse swaps in the system. * @returns PendingReverseSwap[] or null if no storage provider is set. * If no swaps are found, it returns an empty array. */ getPendingReverseSwaps() { if (!this.storageProvider) return null; const swaps = this.storageProvider.getPendingReverseSwaps(); if (!swaps) return []; return swaps.filter((swap) => swap.status === "swap.created"); } }; // src/boltz-swap-provider.ts 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" && typeof data.transaction.hex === "string" && (data.transaction.preimage === void 0 || typeof data.transaction.preimage === "string")); }; var isGetPairsResponse = (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 isCreateSubmarineSwapResponse = (data) => { return data && typeof data === "object" && typeof data.id === "string" && typeof data.address === "string" && typeof data.expectedAmount === "number" && typeof data.claimPublicKey === "string" && typeof data.acceptZeroConf === "boolean" && data.timeoutBlockHeights && typeof data.timeoutBlockHeights === "object" && typeof data.timeoutBlockHeights.unilateralClaim === "number" && typeof data.timeoutBlockHeights.unilateralRefund === "number" && typeof data.timeoutBlockHeights.unilateralRefundWithoutReceiver === "number"; }; var isCreateReverseSwapResponse = (data) => { return data && typeof data === "object" && typeof data.id === "string" && typeof data.invoice === "string" && typeof data.onchainAmount === "number" && typeof data.lockupAddress === "string" && typeof data.refundPublicKey === "string" && data.timeoutBlockHeights && typeof data.timeoutBlockHeights === "object" && typeof data.timeoutBlockHeights.refund === "number" && typeof data.timeoutBlockHeights.unilateralClaim === "number" && typeof data.timeoutBlockHeights.unilateralRefund === "number" && typeof data.timeoutBlockHeights.unilateralRefundWithoutReceiver === "number"; }; var BASE_URLS = { bitcoin: "https://boltz.arkade.sh", mutinynet: "https://boltz.mutinynet.arkade.sh", testnet: "https://boltz.testnet.arkade.sh", regtest: "http://localhost:9069" }; var BoltzSwapProvider = class { wsUrl; apiUrl; network; constructor(config) { this.network = config.network; this.apiUrl = config.apiUrl || BASE_URLS[config.network]; this.wsUrl = this.apiUrl.replace(/^http(s)?:\/\//, "ws$1://").replace("9069", "9004") + "/v2/ws"; } getNetwork() { return this.network; } async getLimits() { const response = await this.request("/v2/swap/submarine", "GET"); if (!isGetPairsResponse(response)) throw new SchemaError({ message: "error fetching limits" }); return { min: response.ARK.BTC.limits.minimal, max: response.ARK.BTC.limits.maximal }; } async getSwapStatus(id) { const response = await this.request(`/v2/swap/${id}`, "GET"); if (!isGetSwapStatusResponse(response)) throw new SchemaError({ message: `error fetching status for swap: ${id}` }); return response; } async createSubmarineSwap({ invoice, refundPublicKey }) { if (refundPublicKey.length == 64) refundPublicKey = "02" + refundPublicKey; const response = await this.request("/v2/swap/submarine", "POST", { from: "ARK", to: "BTC", invoice, refundPublicKey }); if (!isCreateSubmarineSwapResponse(response)) throw new SchemaError({ message: "Error creating submarine swap" }); return response; } async createReverseSwap({ invoiceAmount, claimPublicKey, preimageHash }) { if (claimPublicKey.length == 64) claimPublicKey = "02" + claimPublicKey; const response = await this.request("/v2/swap/reverse", "POST", { from: "BTC", to: "ARK", invoiceAmount, claimPublicKey, preimageHash }); if (!isCreateReverseSwapResponse(response)) throw new SchemaError({ message: "Error creating reverse swap" }); return response; } async monitorSwap(swapId, update) { return new Promise((resolve, reject) => { const webSocket = new globalThis.WebSocket(this.wsUrl); const connectionTimeout = setTimeout(() => { webSocket.close(); reject(new NetworkError("WebSocket connection timeout")); }, 3e4); webSocket.onerror = (error) => { clearTimeout(connectionTimeout); reject(new NetworkError(`WebSocket error: ${error.message}`)); }; webSocket.onopen = () => { clearTimeout(connectionTimeout); webSocket.send( JSON.stringify({ op: "subscribe", channel: "swap.update", args: [swapId] }) ); }; webSocket.onclose = () => { clearTimeout(connectionTimeout); resolve(); }; webSocket.onmessage = async (rawMsg) => { const msg = JSON.parse(rawMsg.data); if (msg.event !== "update" || msg.args[0].id !== swapId) return; if (msg.args[0].error) { webSocket.close(); reject(new SwapError({ message: msg.args[0].error })); } const status = msg.args[0].status; switch (status) { case "invoice.settled": case "transaction.claimed": case "transaction.refunded": case "invoice.expired": case "invoice.failedToPay": case "transaction.failed": case "transaction.lockupFailed": case "swap.expired": webSocket.close(); update(status); break; case "invoice.paid": case "invoice.pending": case "invoice.set": case "swap.created": case "transaction.claim.pending": case "transaction.confirmed": case "transaction.mempool": update(status); } }; }); } async request(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(); throw new NetworkError(`Boltz API error: ${response.status} ${errorBody}`); } 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/storage-provider.ts import * as fs from "fs/promises"; var KEY_REVERSE_SWAPS = "reverseSwaps"; var KEY_SUBMARINE_SWAPS = "submarineSwaps"; var Storage = class _Storage { storage; isBrowser; storagePath; localStorage; initPromise; constructor(options = {}) { this.storage = null; this.storagePath = options.storagePath || "./storage.json"; this.isBrowser = typeof globalThis !== "undefined" && typeof globalThis.window !== "undefined" && typeof globalThis.window.localStorage !== "undefined"; if (this.isBrowser) { this.localStorage = globalThis.window.localStorage; } } static async create(options = {}) { const storage = new _Storage(options); if (!storage.isBrowser) await storage.initializeFileStorage(); return storage; } async initializeFileStorage() { try { await fs.access(this.storagePath); const data = await fs.readFile(this.storagePath, "utf8"); this.storage = JSON.parse(data); } catch { this.storage = {}; await this.save(); } } async getItem(key) { if (this.isBrowser) { return this.localStorage.getItem(key); } else { await this.ensureInitialized(); return this.storage[key] || null; } } async setItem(key, value) { if (this.isBrowser) { this.localStorage.setItem(key, value); } else { await this.ensureInitialized(); this.storage[key] = value; await this.save(); } } async removeItem(key) { if (this.isBrowser) { this.localStorage.removeItem(key); } else { await this.ensureInitialized(); delete this.storage[key]; await this.save(); } } async clear() { if (this.isBrowser) { this.localStorage.clear(); } else { await this.ensureInitialized(); this.storage = {}; await this.save(); } } async save() { if (!this.isBrowser) { try { await fs.writeFile(this.storagePath, JSON.stringify(this.storage, null, 2)); } catch (error) { throw new Error(`Failed to save storage: ${error}`); } } } async ensureInitialized() { if (!this.isBrowser && !this.storage) { if (!this.initPromise) { this.initPromise = this.initializeFileStorage(); } await this.initPromise; } } }; var StorageProvider = class _StorageProvider { storageInstance; storage; constructor(instance) { this.storageInstance = instance; this.storage = { reverseSwaps: [], submarineSwaps: [] }; } static async create(options = {}) { const storageInstance = await Storage.create(options); const storage = new _StorageProvider(storageInstance); await storage.initializeStorage(); return storage; } getPendingReverseSwaps() { return this.storage[KEY_REVERSE_SWAPS]; } async savePendingReverseSwap(swap) { return this.savePendingSwap(KEY_REVERSE_SWAPS, swap); } async deletePendingReverseSwap(id) { return this.deletePendingSwap(KEY_REVERSE_SWAPS, id); } getPendingSubmarineSwaps() { return this.storage[KEY_SUBMARINE_SWAPS]; } async savePendingSubmarineSwap(swap) { return this.savePendingSwap(KEY_SUBMARINE_SWAPS, swap); } async deletePendingSubmarineSwap(id) { return this.deletePendingSwap(KEY_SUBMARINE_SWAPS, id); } async savePendingSwap(kind, swap) { const swaps = this.storage[kind]; const found = swaps.findIndex((s) => s.response.id === swap.response.id); if (found !== -1) { swaps[found].status = swap.status; } else { if (kind === KEY_REVERSE_SWAPS) { swaps.push(swap); } if (kind === KEY_SUBMARINE_SWAPS) { swaps.push(swap); } } await this.setSwaps(kind, swaps); } async deletePendingSwap(kind, id) { const swaps = this.storage[kind]; const updatedSwaps = swaps.filter((s) => s.response.id !== id); if (kind === KEY_REVERSE_SWAPS) { await this.setSwaps(kind, updatedSwaps); } else if (kind === KEY_SUBMARINE_SWAPS) { await this.setSwaps(kind, updatedSwaps); } } async setSwaps(kind, swaps) { this.storage = { ...this.storage, [kind]: swaps }; await this.set(kind, swaps); } async set(kind, swaps) { const val = swaps ? JSON.stringify(swaps) : ""; await this.storageInstance.setItem(kind, val); } async get(kind) { const item = await this.storageInstance.getItem(kind); if (!item) return []; try { const swaps = JSON.parse(item); return swaps; } catch (error) { console.error(`Failed to parse stored data for key ${kind}:`, error); return []; } } async initializeStorage() { try { this.storage = { reverseSwaps: await this.get(KEY_REVERSE_SWAPS), submarineSwaps: await this.get(KEY_SUBMARINE_SWAPS) }; } catch (error) { console.error("Failed to initialize storage:", error); this.storage = { reverseSwaps: [], submarineSwaps: [] }; } } }; export { ArkadeLightning, BoltzSwapProvider, InsufficientFundsError, InvoiceExpiredError, NetworkError, StorageProvider, SwapError, decodeInvoice, getInvoicePaymentHash, getInvoiceSatoshis };