UNPKG

@atomiqlabs/sdk

Version:

atomiq labs SDK for cross-chain swaps between smart chains and bitcoin

443 lines (442 loc) 27.9 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SwapperApi = void 0; const ApiTypes_1 = require("./ApiTypes"); const SerializedAction_1 = require("./SerializedAction"); const FeeType_1 = require("../enums/FeeType"); const SwapSide_1 = require("../enums/SwapSide"); const SwapType_1 = require("../enums/SwapType"); const FromBTCLNSwap_1 = require("../swaps/escrow_swaps/frombtc/ln/FromBTCLNSwap"); const FromBTCLNAutoSwap_1 = require("../swaps/escrow_swaps/frombtc/ln_auto/FromBTCLNAutoSwap"); const IEscrowSwap_1 = require("../swaps/escrow_swaps/IEscrowSwap"); const ToBTCLNSwap_1 = require("../swaps/escrow_swaps/tobtc/ln/ToBTCLNSwap"); const SwapUtils_1 = require("../utils/SwapUtils"); function requiresSecretRevealForApi(swap, state) { if (swap instanceof FromBTCLNSwap_1.FromBTCLNSwap) { if (swap.hasSecretPreimage()) return false; return state === FromBTCLNSwap_1.FromBTCLNSwapState.PR_PAID || state === FromBTCLNSwap_1.FromBTCLNSwapState.CLAIM_COMMITED; } if (swap instanceof FromBTCLNAutoSwap_1.FromBTCLNAutoSwap) { if (swap.hasSecretPreimage()) return false; return state === FromBTCLNAutoSwap_1.FromBTCLNAutoSwapState.CLAIM_COMMITED; } } function createSwapOutputBase(swap, steps, stateInfo) { const input = swap.getInput(); const output = swap.getOutput(); const feeBreakdown = swap.getFeeBreakdown(); // Build fees from breakdown const swapFeeEntry = feeBreakdown.find(f => f.type === FeeType_1.FeeType.SWAP); const networkFeeEntry = feeBreakdown.find(f => f.type === FeeType_1.FeeType.NETWORK_OUTPUT); const flags = {}; if ((0, SwapUtils_1.isSwapType)(swap, SwapType_1.SwapType.TO_BTCLN)) { flags.lightningRecipientIsNonCustodialWallet = swap.isPayingToNonCustodialWallet(); flags.lightningPaymentWillLikelyFail = swap.willLikelyFail(); flags.lightningPaymentWithLongHTLCExpiration = swap.hasLongExpiration(); } return { swapId: swap.getId(), swapType: SwapType_1.SwapType[swap.getType()], state: { number: stateInfo.state, name: stateInfo.name, description: stateInfo.description }, quote: { inputAmount: (0, ApiTypes_1.toApiAmount)(input), outputAmount: (0, ApiTypes_1.toApiAmount)(output), fees: { swap: swapFeeEntry ? (0, ApiTypes_1.toApiAmount)(swapFeeEntry.fee.amountInSrcToken) : { amount: "0", rawAmount: "0", decimals: 0, symbol: "", chain: "" }, ...(networkFeeEntry ? { networkOutput: (0, ApiTypes_1.toApiAmount)(networkFeeEntry.fee.amountInSrcToken) } : {}) }, expiry: swap.getQuoteExpiry(), outputAddress: swap.getOutputAddress() }, flags, createdAt: swap.createdAt, steps, ...(swap instanceof ToBTCLNSwap_1.ToBTCLNSwap && swap.isLNURL() ? { lnurl: { pay: swap.getLNURL(), successAction: swap.getSuccessAction() ?? undefined } } : (swap instanceof FromBTCLNSwap_1.FromBTCLNSwap || swap instanceof FromBTCLNAutoSwap_1.FromBTCLNAutoSwap) && swap.isLNURL() ? { lnurl: { withdraw: swap.getLNURL(), } } : {}) }; } function createListSwapOutput(swap, steps, stateInfo) { return { ...createSwapOutputBase(swap, steps, stateInfo), isFinished: swap.isFinished(), isSuccess: swap.isSuccessful(), isFailed: swap.isFailed(), isExpired: swap.isQuoteExpired() }; } function parseSwapSide(side) { return side === "INPUT" ? SwapSide_1.SwapSide.INPUT : SwapSide_1.SwapSide.OUTPUT; } class SwapperApi { constructor(swapper, config) { this.swapper = swapper; this.config = config; this.config ??= {}; this.config.syncOnGetStatus ??= true; this.endpoints = { createSwap: (0, ApiTypes_1.createApiEndpoint)("POST", "Create a new cross-chain atomic swap. Returns a swap object with swapId, swapType, state, createdAt, quote (with inputAmount and outputAmount as ApiAmount objects each having amount/rawAmount/decimals/symbol/chain, a fees breakdown {swap, networkOutput?} of ApiAmount values, expiry, and outputAddress), a steps array, and optional lnurl. After creation, poll getSwapStatus periodically to get the next required action.", this.createSwap.bind(this), { srcToken: { type: "string", required: true, description: "Source token ticker (e.g. 'BITCOIN-BTC', 'LIGHTNING-BTC', 'STARKNET-STRK', 'SOLANA-SOL')" }, dstToken: { type: "string", required: true, description: "Destination token ticker" }, amount: { type: "bigint", required: true, description: "Amount in base units as an integer" }, amountType: { type: "string", required: true, description: "EXACT_IN or EXACT_OUT", allowedValues: ["EXACT_IN", "EXACT_OUT"] }, srcAddress: { type: "string", required: false, description: "Source address (only required for Smart chain -> BTC/Lightning swaps)" }, dstAddress: { type: "string", required: true, description: "Destination address" }, gasAmount: { type: "bigint", required: false, description: "Gas token amount to receive on destination chain, in base units" }, paymentHash: { type: "string", required: false, description: "Custom payment hash for Lightning swaps" }, lightningInvoiceDescription: { type: "string", required: false, description: "Description for Lightning invoice" }, lightningInvoiceDescriptionHash: { type: "string", required: false, description: "Description hash for Lightning invoice (hex)" }, lightningPaymentHTLCTimeout: { type: "number", required: false, description: "Custom expiry time in seconds" }, lightningPaymentMaxHTLCTimeout: { type: "number", required: false, description: "Custom maximum expiry time in seconds" } }), listSwaps: (0, ApiTypes_1.createApiEndpoint)("GET", "List all swaps for a given signer address. Returns an array of swap objects, each with swapId, swapType, state, quote, steps, and terminal state flags (isFinished, isSuccess, isFailed, isExpired). Optionally filter by smart chain.", this.listSwaps.bind(this), { signer: { type: "string", required: true, description: "Smart chain signer address to filter swaps for" }, chainId: { type: "string", required: false, description: "Optional smart chain identifier to filter swaps" } }), listPendingSwaps: (0, ApiTypes_1.createApiEndpoint)("GET", "List swaps that require user action for a given signer address. Returns an array of swap objects with the same structure as listSwaps.", this.listPendingSwaps.bind(this), { signer: { type: "string", required: true, description: "Smart chain signer address to filter pending swaps for" }, chainId: { type: "string", required: false, description: "Optional smart chain identifier to filter pending swaps" } }), getSupportedTokens: (0, ApiTypes_1.createApiEndpoint)("GET", "List all tokens available as swap input or output. Returns an array of ApiToken objects, each with id (e.g. BITCOIN-BTC, LIGHTNING-BTC, STARKNET-STRK), chainId, ticker, name, decimals, and address.", this.getSupportedTokens.bind(this), { side: { type: "string", required: true, description: "Whether to list valid source tokens (INPUT) or destination tokens (OUTPUT)", allowedValues: ["INPUT", "OUTPUT"] } }), getSwapCounterTokens: (0, ApiTypes_1.createApiEndpoint)("GET", "Get tokens that can be swapped against a given token. Returns an array of ApiToken objects (id, chainId, ticker, name, decimals, address). Use to discover valid trading pairs.", this.getSwapCounterTokens.bind(this), { token: { type: "string", required: true, description: "Token identifier accepted by the API, e.g. BITCOIN-BTC, LIGHTNING-BTC, STARKNET-STRK, or a token address" }, side: { type: "string", required: true, description: "Treat the provided token as a source token (INPUT) or destination token (OUTPUT)", allowedValues: ["INPUT", "OUTPUT"] } }), getSwapLimits: (0, ApiTypes_1.createApiEndpoint)("GET", "Get minimum and maximum swap amounts for a source/destination token pair. Returns {input: {min, max?}, output: {min, max?}} where each value is an ApiAmount object with amount (decimal string), rawAmount (base units string), decimals, symbol, and chain.", this.getSwapLimits.bind(this), { srcToken: { type: "string", required: true, description: "Source token identifier accepted by the API, e.g. BITCOIN-BTC, LIGHTNING-BTC, STARKNET-STRK" }, dstToken: { type: "string", required: true, description: "Destination token identifier accepted by the API, e.g. BITCOIN-BTC, LIGHTNING-BTC, STARKNET-STRK" } }), parseAddress: (0, ApiTypes_1.createApiEndpoint)("GET", "Parse and validate an address, Lightning invoice, LNURL, or Bitcoin URI. Returns {address, type} and optionally lnurl (ApiLNURL with pay/withdraw details), min/max/amount (as ApiAmount objects).", this.parseAddress.bind(this), { address: { type: "string", required: true, description: "Address, invoice, LNURL, or URI string to parse" } }), getSpendableBalance: (0, ApiTypes_1.createApiEndpoint)("GET", "Get the spendable balance for a wallet address and token, accounting for chain fees. Returns {balance: ApiAmount, feeRate?} where ApiAmount has amount (decimal string), rawAmount (base units string), decimals, symbol, and chain.", this.getSpendableBalance.bind(this), { wallet: { type: "string", required: true, description: "Wallet address to query" }, token: { type: "string", required: true, description: "Token identifier accepted by the API, e.g. BITCOIN-BTC, STARKNET-STRK, or a token address" }, targetChain: { type: "string", required: false, description: "Destination smart chain for Bitcoin SPV-vault fee estimation" }, gasDrop: { type: "boolean", required: false, description: "Whether to include gas-drop footprint when estimating Bitcoin SPV-vault spendable balance" }, feeRate: { type: "string", required: false, description: "Manual fee rate override" }, minBitcoinFeeRate: { type: "number", required: false, description: "Minimum Bitcoin fee rate to enforce" }, feeMultiplier: { type: "number", required: false, description: "Multiplier applied to smart-chain native token commit fee estimate" } }), getSwapStatus: (0, ApiTypes_1.createApiEndpoint)("GET", "Get the current status and next required action for a swap. Returns swap state, terminal flags (isFinished, isSuccess, isFailed, isExpired), and currentAction (an action object, or null when no action is currently required). For Lightning-to-smart-chain swaps it may also return requiresSecretReveal: true — when set, pass the secret parameter in subsequent getSwapStatus calls so the HTLC can be claimed. Handle each action type: SignPSBT — ask user to sign the Bitcoin PSBT with their wallet, then submit via submitTransaction. SignSmartChainTransaction — ask user to sign with their Solana/Starknet/EVM wallet, then submit via submitTransaction. SendToAddress — show the address and amount to the user, they pay externally, keep polling. Wait — poll again after pollTimeSeconds. Poll repeatedly until isFinished is true.", this.getSwapStatus.bind(this), { swapId: { type: "string", required: true, description: "The swap identifier" }, secret: { type: "string", required: false, description: "Revealed swap secret pre-image (in hexadecimal format) for lightning network swaps" }, bitcoinAddress: { type: "string", required: false, description: "Bitcoin wallet address to obtain funded PSBT" }, bitcoinPublicKey: { type: "string", required: false, description: "Bitcoin wallet public key (in hexadecimal format) to obtain funded PSBT" }, bitcoinFeeRate: { type: "number", required: false, description: "Fee rate to use when creating a funded PSBT" }, signer: { type: "string", required: false, description: "Alternative different smart chain signer to use for refunds and manual settlement" } }), submitTransaction: (0, ApiTypes_1.createApiEndpoint)("POST", "Submit signed transaction(s) for a swap. Call this after the user has signed the transaction returned by getSwapStatus. Returns {txHashes: string[]} with the submitted transaction hashes. After submission, continue polling getSwapStatus.", this.submitTransaction.bind(this), { swapId: { type: "string", required: true, description: "The swap identifier" }, signedTxs: { type: "array", required: true, description: "Array of signed transaction data", items: { type: "string", required: true, description: "Single string-serialized & signed transaction" } } }), settleWithLnurl: (0, ApiTypes_1.createApiEndpoint)("POST", "Settle a Lightning Network swap using an LNURL-withdraw link. Returns {paymentHash: string} on success.", this.settleWithLnurl.bind(this), { swapId: { type: "string", required: true, description: "The swap identifier" }, lnurlWithdraw: { type: "string", required: false, description: "LNURL-withdraw link to use to settle the Lightning network swap, if the swap was already created with the LNURL-withdraw link, this is optional" } }) }; } txSerializer(chainId, tx) { const chain = this.swapper._chains[chainId]; if (chain == null) throw new Error("Unknown chain: " + chainId); return chain.chainInterface.serializeTx(tx); } async init() { await this.swapper.init(); } /** * Should be ran periodically, this synchronizes the swap's state with the on-chain data and also purges * expired swaps from the persistent storage */ async sync() { await this.swapper._syncSwaps(); } /** * Optionally good to run this periodically, such that any LPs that are dropped off because they are unresponsive * can be found again. */ async reloadLps() { await this.swapper.intermediaryDiscovery.reloadIntermediaries(); } async createSwap(input) { const exactIn = input.amountType === "EXACT_IN"; // Build options from input const options = {}; if (input.gasAmount != null) options.gasAmount = input.gasAmount; if (input.paymentHash != null) options.paymentHash = Buffer.from(input.paymentHash, "hex"); if (input.lightningInvoiceDescription != null) options.description = input.lightningInvoiceDescription; if (input.lightningInvoiceDescriptionHash != null) options.descriptionHash = Buffer.from(input.lightningInvoiceDescriptionHash, "hex"); if (input.lightningPaymentHTLCTimeout != null) options.expirySeconds = input.lightningPaymentHTLCTimeout; if (input.lightningPaymentMaxHTLCTimeout != null) options.maxExpirySeconds = input.lightningPaymentMaxHTLCTimeout; // swapper.swap() handles routing based on token types const swap = await this.swapper.swap(input.srcToken, input.dstToken, input.amount, exactIn, input.srcAddress, input.dstAddress, Object.keys(options).length > 0 ? options : undefined); const { steps, stateInfo } = await swap.getExecutionStatus({ skipBuildingAction: true }); return createSwapOutputBase(swap, steps, stateInfo); } validateSwapListInput(input) { if (input.chainId != null && !this.swapper.getSmartChains().includes(input.chainId)) { throw new Error("Unknown chainId: " + input.chainId); } if (!this.swapper.Utils.isValidSmartChainAddress(input.signer, input.chainId)) { throw new Error(input.chainId != null ? `Invalid ${input.chainId} signer address: ` + input.signer : `Invalid smart chain signer address: ` + input.signer); } } async createListedSwapOutputs(swaps) { return Promise.all(swaps .filter(swap => swap.getType() !== SwapType_1.SwapType.TRUSTED_FROM_BTC) .map(async (swap) => { const { steps, stateInfo } = await swap.getExecutionStatus({ skipBuildingAction: true }); return createListSwapOutput(swap, steps, stateInfo); })); } async listSwaps(input) { this.validateSwapListInput(input); const swaps = await this.swapper.getAllSwaps(input.chainId, input.signer); return this.createListedSwapOutputs(swaps); } async listPendingSwaps(input) { this.validateSwapListInput(input); const swaps = await this.swapper.getPendingSwaps(input.chainId, input.signer); return this.createListedSwapOutputs(swaps); } async getSupportedTokens(input) { return this.swapper.getSupportedTokens(parseSwapSide(input.side)).map(ApiTypes_1.toApiToken); } async getSwapCounterTokens(input) { const token = this.swapper.getToken(input.token); return this.swapper.getSwapCounterTokens(token, parseSwapSide(input.side)).map(ApiTypes_1.toApiToken); } async getSwapLimits(input) { const srcToken = this.swapper.getToken(input.srcToken); const dstToken = this.swapper.getToken(input.dstToken); let limits = this.swapper.getSwapLimits(srcToken, dstToken); if (dstToken.chainId !== "LIGHTNING") { if (limits.input.min.rawAmount === 1n || limits.output.min.rawAmount === 1n) { // Execute a dummy swap to get the proper limits try { await this.swapper.swap(srcToken, dstToken, 1n, limits.input.min.rawAmount === 1n, srcToken.chainId === "LIGHTNING" ? undefined : this.swapper.Utils.randomAddress(srcToken.chainId), this.swapper.Utils.randomAddress(dstToken.chainId)); } catch (e) { } limits = this.swapper.getSwapLimits(srcToken, dstToken); } } return { input: { min: (0, ApiTypes_1.toApiAmount)(limits.input.min), ...(limits.input.max != null ? { max: (0, ApiTypes_1.toApiAmount)(limits.input.max) } : {}) }, output: { min: (0, ApiTypes_1.toApiAmount)(limits.output.min), ...(limits.output.max != null ? { max: (0, ApiTypes_1.toApiAmount)(limits.output.max) } : {}) } }; } async parseAddress(input) { const result = await this.swapper.Utils.parseAddress(input.address); if (result == null) throw new Error("Invalid address"); return { address: result.address, type: result.type, ...(result.lnurl != null ? { lnurl: (0, ApiTypes_1.toApiLNURL)(result.lnurl, this.swapper) } : {}), ...(result.min != null ? { min: (0, ApiTypes_1.toApiAmount)(result.min) } : {}), ...(result.max != null ? { max: (0, ApiTypes_1.toApiAmount)(result.max) } : {}), ...(result.amount != null ? { amount: (0, ApiTypes_1.toApiAmount)(result.amount) } : {}) }; } async getSpendableBalance(input) { const token = this.swapper.getToken(input.token); if (token.chainId === "LIGHTNING") throw new Error("Lightning wallet spendable balance is not supported by this endpoint."); if (input.feeRate != null && input.feeMultiplier != null) throw new Error("`feeMultiplier` cannot be specified alongside the `feeRate` parameter."); if (token.chainId === "BITCOIN") { if (input.targetChain != null && !this.swapper.getSmartChains().includes(input.targetChain)) { throw new Error("Unknown targetChain: " + input.targetChain); } if (!this.swapper.Utils.isValidBitcoinAddress(input.wallet)) throw new Error(`Invalid BITCOIN wallet address: ` + input.wallet); let btcFeeRate; if (input.feeRate != null) { btcFeeRate = parseFloat(input.feeRate); if (isNaN(btcFeeRate) || btcFeeRate <= 0) throw new Error("Bitcoin `feeRate` must be a valid positive number!"); } else btcFeeRate = await this.swapper._bitcoinRpc.getFeeRate(); if (input.feeMultiplier != null) btcFeeRate *= input.feeMultiplier; const { balance, feeRate } = await this.swapper.Utils.getBitcoinSpendableBalance(input.wallet, input.targetChain, { gasDrop: input.gasDrop, feeRate: btcFeeRate, minFeeRate: input.minBitcoinFeeRate }); return { balance: (0, ApiTypes_1.toApiAmount)(balance), feeRate }; } if (input.gasDrop === true) throw new Error("`gasDrop` is only supported for Bitcoin balances."); if (input.minBitcoinFeeRate != null) throw new Error("`minBitcoinFeeRate` is only supported for Bitcoin balances."); if (!this.swapper.Utils.isValidSmartChainAddress(input.wallet, token.chainId)) throw new Error(`Invalid ${token.chainId} wallet address: ` + input.wallet); const balance = await this.swapper.Utils.getSpendableBalance(input.wallet, token, { feeMultiplier: input.feeMultiplier, feeRate: input.feeRate }); return { balance: (0, ApiTypes_1.toApiAmount)(balance) }; } async getSwapStatus(input) { const swap = await this.swapper.getSwapById(input.swapId); if (swap == null) { throw new Error("Swap not found: " + input.swapId); } if (input.signer != null && !this.swapper.Utils.isValidSmartChainAddress(input.signer, swap.chainIdentifier)) { throw new Error(`Invalid ${swap.chainIdentifier} signer address: ` + input.signer); } if (input.secret != null) { try { Buffer.from(input.secret, "hex"); } catch (e) { throw new Error(`Invalid secret passed, has to be a hexadecimal string!`); } } let bitcoinWallet; if (input.bitcoinAddress != null && input.bitcoinPublicKey != null) { bitcoinWallet = { publicKey: input.bitcoinPublicKey, address: input.bitcoinAddress }; } else if (input.bitcoinAddress != null || input.bitcoinPublicKey != null) { throw new Error("When specifying bitcoin wallet you have to pass both `bitcoinAddress` and `bitcoinPublicKey` params!"); } if (input.bitcoinFeeRate != null) { if (isNaN(input.bitcoinFeeRate)) throw new Error("Bitcoin fee rate passed cannot be NaN!"); if (input.bitcoinFeeRate <= 0) throw new Error("Bitcoin fee rate passed cannot be negative or 0!"); } if (this.config?.syncOnGetStatus) await swap._sync(true); const { steps, stateInfo, currentAction } = await swap.getExecutionStatus({ secret: input.secret, bitcoinWallet, bitcoinFeeRate: input.bitcoinFeeRate, manualSettlementSmartChainSigner: input.signer, refundSmartChainSigner: input.signer }); return { ...createListSwapOutput(swap, steps, stateInfo), currentAction: currentAction ? await (0, SerializedAction_1.serializeAction)(currentAction, this.txSerializer.bind(this)) : null, requiresSecretReveal: requiresSecretRevealForApi(swap, stateInfo.state), escrow: swap instanceof IEscrowSwap_1.IEscrowSwap && swap._data != null ? { data: swap._data.getEscrowStruct(), initTxId: swap._commitTxId } : undefined }; } async submitTransaction(input, abortSignal) { const swap = await this.swapper.getSwapById(input.swapId); if (swap == null) { throw new Error("Swap not found: " + input.swapId); } return { txHashes: await swap._submitExecutionTransactions(input.signedTxs, abortSignal, undefined, this.config?.idempotentTxSubmission) }; } async settleWithLnurl(input, abortSignal) { const swap = await this.swapper.getSwapById(input.swapId); if (swap == null) throw new Error("Swap not found: " + input.swapId); if (swap instanceof FromBTCLNAutoSwap_1.FromBTCLNAutoSwap) { if (swap._state !== FromBTCLNAutoSwap_1.FromBTCLNAutoSwapState.PR_CREATED) throw new Error("Invalid swap state, must be in PR_CREATED state!"); } else if (swap instanceof FromBTCLNSwap_1.FromBTCLNSwap) { if (swap._state !== FromBTCLNSwap_1.FromBTCLNSwapState.PR_CREATED) throw new Error("Invalid swap state, must be in PR_CREATED state!"); } else { throw new Error("Endpoint only supports swaps from Lightning"); } if (!swap.isLNURL()) { if (input.lnurlWithdraw == null) throw new Error("The swap is not configured to use LNURL, please pass the `lnurlWithdraw` parameter!"); if (!this.swapper.Utils.isValidLNURL(input.lnurlWithdraw)) throw new Error("Invalid LNURL-withdraw link provided: " + input.lnurlWithdraw); await swap.settleWithLNURLWithdraw(input.lnurlWithdraw); } else { if (input.lnurlWithdraw != null) throw new Error("The swap is already configured with an LNURL link, don't pass the `lnurlWithdraw` parameter!"); } let success; if (swap instanceof FromBTCLNAutoSwap_1.FromBTCLNAutoSwap) { // For non-legacy swap, we don't need to wait till the swap advances all the way to committed state success = await swap._waitForLpPaymentReceived(2, abortSignal); } else { // For legacy swap waitForPayment waits just for the swap to transition into PR_PAID success = await swap.waitForPayment(undefined, 2, abortSignal); } if (!success) throw new Error("Failed to settle the swap with the LNURL-withdraw link!"); return { paymentHash: swap.getInputTxId() }; } } exports.SwapperApi = SwapperApi;