@arkade-os/boltz-swap
Version:
A production-ready TypeScript package that brings Boltz submarine-swaps to Arkade.
1,014 lines (1,005 loc) • 39.3 kB
JavaScript
"use strict";
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var index_exports = {};
__export(index_exports, {
ArkadeLightning: () => ArkadeLightning,
BoltzSwapProvider: () => BoltzSwapProvider,
InsufficientFundsError: () => InsufficientFundsError,
InvoiceExpiredError: () => InvoiceExpiredError,
NetworkError: () => NetworkError,
StorageProvider: () => StorageProvider,
SwapError: () => SwapError,
decodeInvoice: () => decodeInvoice,
getInvoicePaymentHash: () => getInvoicePaymentHash,
getInvoiceSatoshis: () => getInvoiceSatoshis
});
module.exports = __toCommonJS(index_exports);
// 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
var import_sdk = require("@arkade-os/sdk");
var import_sha2 = require("@noble/hashes/sha2");
var import_base = require("@scure/base");
var import_utils = require("@noble/hashes/utils");
var import_btc_signer = require("@scure/btc-signer");
var import_legacy = require("@noble/hashes/legacy");
// src/utils/decoding.ts
var import_light_bolt11_decoder = __toESM(require("light-bolt11-decoder"), 1);
var decodeInvoice = (invoice) => {
const decoded = import_light_bolt11_decoder.default.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 = import_base.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 = import_base.hex.encode(this.wallet.xOnlyPublicKey());
if (!claimPublicKey) throw new SwapError({ message: "Failed to get claim public key from wallet" });
const preimage = (0, import_utils.randomBytes)(32);
const preimageHash = import_base.hex.encode((0, import_sha2.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: import_base.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 = import_base.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 = import_base.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: (0, import_sha2.sha256)(preimage),
receiverPubkey: import_base.hex.encode(receiverXOnlyPublicKey),
senderPubkey: pendingSwap.response.refundPublicKey,
serverPubkey: import_base.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: [import_base.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 = import_btc_signer.Transaction.fromPSBT(signedTx.toPSBT(), { allowUnknown: true });
(0, import_sdk.setArkPsbtField)(signedTx, 0, import_sdk.ConditionWitness, [preimage]);
return signedTx;
},
xOnlyPublicKey: receiverXOnlyPublicKey,
signerSession: this.wallet.signerSession
};
const serverUnrollScript = import_sdk.CSVMultisigTapscript.encode({
pubkeys: [serverXOnlyPublicKey],
timelock: {
type: aspInfo.unilateralExitDelay < 512 ? "blocks" : "seconds",
value: aspInfo.unilateralExitDelay
}
});
const { arkTx, checkpoints } = (0, import_sdk.buildOffchainTx)(
[
{
...spendableVtxos.vtxos[0],
tapLeafScript: vhtlcScript.claim(),
tapTree: vhtlcScript.encode()
}
],
[
{
amount: BigInt(vtxo.value),
script: import_sdk.ArkAddress.decode(address).pkScript
}
],
serverUnrollScript
);
const signedArkTx = await vhtlcIdentity.sign(arkTx);
const { arkTxid, finalArkTx, signedCheckpointTxs } = await this.arkProvider.submitTx(
import_base.base64.encode(signedArkTx.toPSBT()),
checkpoints.map((c) => import_base.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 = import_btc_signer.Transaction.fromPSBT(import_base.base64.decode(c), {
allowUnknown: true
});
const signedCheckpoint = await vhtlcIdentity.sign(tx, [0]);
return import_base.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 = import_base.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: import_base.hex.decode(getInvoicePaymentHash(pendingSwap.request.invoice)),
receiverPubkey: pendingSwap.response.claimPublicKey,
senderPubkey: import_base.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: [import_base.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 import_btc_signer.Transaction.fromPSBT(signedTx.toPSBT(), { allowUnknown: true });
},
xOnlyPublicKey: receiverXOnlyPublicKey,
signerSession: this.wallet.signerSession
};
const serverUnrollScript = import_sdk.CSVMultisigTapscript.encode({
pubkeys: [serverXOnlyPublicKey],
timelock: {
type: aspInfo.unilateralExitDelay < 512 ? "blocks" : "seconds",
value: aspInfo.unilateralExitDelay
}
});
const { arkTx, checkpoints } = (0, import_sdk.buildOffchainTx)(
[
{
...spendableVtxos.vtxos[0],
tapLeafScript: vhtlcScript.refund(),
tapTree: vhtlcScript.encode()
}
],
[
{
amount: BigInt(spendableVtxos.vtxos[0].value),
script: import_sdk.ArkAddress.decode(address).pkScript
}
],
serverUnrollScript
);
const signedArkTx = await vhtlcIdentity.sign(arkTx);
const { arkTxid, finalArkTx, signedCheckpointTxs } = await this.arkProvider.submitTx(
import_base.base64.encode(signedArkTx.toPSBT()),
checkpoints.map((c) => import_base.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 = import_btc_signer.Transaction.fromPSBT(import_base.base64.decode(c), {
allowUnknown: true
});
const signedCheckpoint = await vhtlcIdentity.sign(tx, [0]);
return import_base.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 = import_btc_signer.Transaction.fromPSBT(import_base.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 = import_base.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 = import_base.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 = import_base.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 import_sdk.VHTLC.Script({
preimageHash: (0, import_legacy.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
var fs = __toESM(require("fs/promises"), 1);
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: []
};
}
}
};
// Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = {
ArkadeLightning,
BoltzSwapProvider,
InsufficientFundsError,
InvoiceExpiredError,
NetworkError,
StorageProvider,
SwapError,
decodeInvoice,
getInvoicePaymentHash,
getInvoiceSatoshis
});