UNPKG

@atomiqlabs/sdk

Version:

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

1,039 lines 70.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.FromBTCSwap = exports.isFromBTCSwapInit = exports.FromBTCSwapState = void 0; const IFromBTCSelfInitSwap_1 = require("../IFromBTCSelfInitSwap"); const SwapType_1 = require("../../../../enums/SwapType"); const base_1 = require("@atomiqlabs/base"); const buffer_1 = require("buffer"); const Utils_1 = require("../../../../utils/Utils"); const BitcoinUtils_1 = require("../../../../utils/BitcoinUtils"); const IBitcoinWallet_1 = require("../../../../bitcoin/wallet/IBitcoinWallet"); const btc_signer_1 = require("@scure/btc-signer"); const SingleAddressBitcoinWallet_1 = require("../../../../bitcoin/wallet/SingleAddressBitcoinWallet"); const IEscrowSelfInitSwap_1 = require("../../IEscrowSelfInitSwap"); const TokenAmount_1 = require("../../../../types/TokenAmount"); const Token_1 = require("../../../../types/Token"); const Logger_1 = require("../../../../utils/Logger"); const BitcoinWalletUtils_1 = require("../../../../utils/BitcoinWalletUtils"); /** * State enum for legacy escrow based Bitcoin -> Smart chain swaps. * * @category Swaps/Legacy/Bitcoin → Smart chain */ var FromBTCSwapState; (function (FromBTCSwapState) { /** * Bitcoin swap address has expired and the intermediary (LP) has already refunded * its funds. No BTC should be sent anymore! */ FromBTCSwapState[FromBTCSwapState["FAILED"] = -4] = "FAILED"; /** * Bitcoin swap address has expired, user should not send any BTC anymore! Though * the intermediary (LP) hasn't refunded yet. So if there is a transaction already * in-flight the swap might still succeed. */ FromBTCSwapState[FromBTCSwapState["EXPIRED"] = -3] = "EXPIRED"; /** * Swap has expired for good and there is no way how it can be executed anymore */ FromBTCSwapState[FromBTCSwapState["QUOTE_EXPIRED"] = -2] = "QUOTE_EXPIRED"; /** * A swap is almost expired, and it should be presented to the user as expired, though * there is still a chance that it will be processed */ FromBTCSwapState[FromBTCSwapState["QUOTE_SOFT_EXPIRED"] = -1] = "QUOTE_SOFT_EXPIRED"; /** * Swap quote was created, use the {@link FromBTCSwap.commit} or {@link FromBTCSwap.txsCommit} functions * to initiate it by creating the swap escrow on the destination smart chain */ FromBTCSwapState[FromBTCSwapState["PR_CREATED"] = 0] = "PR_CREATED"; /** * Swap escrow was initiated (committed) on the destination chain, user can send the BTC to the * swap address with the {@link FromBTCSwap.getFundedPsbt}, {@link FromBTCSwap.getAddress} or * {@link FromBTCSwap.getHyperlink} functions. */ FromBTCSwapState[FromBTCSwapState["CLAIM_COMMITED"] = 1] = "CLAIM_COMMITED"; /** * Input bitcoin transaction was confirmed, wait for automatic settlement by the watchtowers * using the {@link FromBTCSwap.waitTillClaimed} function or settle manually using the {@link FromBTCSwap.claim} * or {@link FromBTCSwap.txsClaim} function. */ FromBTCSwapState[FromBTCSwapState["BTC_TX_CONFIRMED"] = 2] = "BTC_TX_CONFIRMED"; /** * Swap successfully settled and funds received on the destination chain */ FromBTCSwapState[FromBTCSwapState["CLAIM_CLAIMED"] = 3] = "CLAIM_CLAIMED"; })(FromBTCSwapState = exports.FromBTCSwapState || (exports.FromBTCSwapState = {})); const FromBTCSwapStateDescription = { [FromBTCSwapState.FAILED]: "Bitcoin swap address has expired and the intermediary (LP) has already refunded its funds. No BTC should be sent anymore!", [FromBTCSwapState.EXPIRED]: "Bitcoin swap address has expired, user should not send any BTC anymore! Though the intermediary (LP) hasn't refunded yet. So if there is a transaction already in-flight the swap might still succeed.", [FromBTCSwapState.QUOTE_EXPIRED]: "Swap has expired for good and there is no way how it can be executed anymore", [FromBTCSwapState.QUOTE_SOFT_EXPIRED]: "The swap is expired, though there is still a chance that it will be processed", [FromBTCSwapState.PR_CREATED]: "Swap quote was created, initiate it by creating the swap escrow on the destination smart chain", [FromBTCSwapState.CLAIM_COMMITED]: "Swap escrow was initiated (committed) on the destination chain, user can send the BTC to the Bitcoin swap address.", [FromBTCSwapState.BTC_TX_CONFIRMED]: "Input bitcoin transaction was confirmed, wait for automatic settlement by the watchtower or settle manually.", [FromBTCSwapState.CLAIM_CLAIMED]: "Swap successfully settled and funds received on the destination chain" }; function isFromBTCSwapInit(obj) { return typeof (obj.data) === "object" && (obj.address == null || typeof (obj.address) === "string") && (obj.amount == null || typeof (obj.amount) === "bigint") && (obj.requiredConfirmations == null || typeof (obj.requiredConfirmations) === "number") && (0, IEscrowSelfInitSwap_1.isIEscrowSelfInitSwapInit)(obj); } exports.isFromBTCSwapInit = isFromBTCSwapInit; /** * Legacy escrow (PrTLC) based swap for Bitcoin -> Smart chains, requires manual initiation * of the swap escrow on the destination chain. * * @category Swaps/Legacy/Bitcoin → Smart chain */ class FromBTCSwap extends IFromBTCSelfInitSwap_1.IFromBTCSelfInitSwap { constructor(wrapper, initOrObject) { if (isFromBTCSwapInit(initOrObject) && initOrObject.url != null) initOrObject.url += "/frombtc"; super(wrapper, initOrObject); this.TYPE = SwapType_1.SwapType.FROM_BTC; /** * @internal */ this.swapStateName = (state) => FromBTCSwapState[state]; /** * @internal */ this.swapStateDescription = FromBTCSwapStateDescription; /** * @internal */ this.inputToken = Token_1.BitcoinTokens.BTC; if (isFromBTCSwapInit(initOrObject)) { this._state = FromBTCSwapState.PR_CREATED; this._data = initOrObject.data; this.feeRate = initOrObject.feeRate; this.address = initOrObject.address; this.amount = initOrObject.amount; this.requiredConfirmations = initOrObject.requiredConfirmations; } else { this.address = initOrObject.address; this.amount = (0, Utils_1.toBigInt)(initOrObject.amount); this.senderAddress = initOrObject.senderAddress; this.txId = initOrObject.txId; this.vout = initOrObject.vout; this.requiredConfirmations = initOrObject.requiredConfirmations ?? this._data.getConfirmationsHint(); this.btcTxConfirmedAt = initOrObject.btcTxConfirmedAt; } this.tryRecomputeSwapPrice(); this.logger = (0, Logger_1.getLogger)("FromBTC(" + this.getIdentifierHashString() + "): "); } /** * @inheritDoc * @internal */ getSwapData() { return this._data; } /** * @inheritDoc * @internal */ upgradeVersion() { if (this.version == null) { switch (this._state) { case -2: this._state = FromBTCSwapState.FAILED; break; case -1: this._state = FromBTCSwapState.QUOTE_EXPIRED; break; case 0: this._state = FromBTCSwapState.PR_CREATED; break; case 1: this._state = FromBTCSwapState.CLAIM_COMMITED; break; case 2: this._state = FromBTCSwapState.BTC_TX_CONFIRMED; break; case 3: this._state = FromBTCSwapState.CLAIM_CLAIMED; break; } this.version = 1; } } ////////////////////////////// //// Getters & utils /** * Returns bitcoin address where the on-chain BTC should be sent to */ getAddress() { if (this._state === FromBTCSwapState.PR_CREATED) throw new Error("Cannot get bitcoin address of non-initiated swaps! Initiate swap first with commit() or txsCommit()."); return this.address ?? ""; } /** * Unsafe bitcoin hyperlink getter, returns the address even before the swap is committed! * * @private */ _getHyperlink() { return this.address == null || this.amount == null ? "" : "bitcoin:" + this.address + "?amount=" + encodeURIComponent((Number(this.amount) / 100000000).toString(10)); } /** * @inheritDoc */ getHyperlink() { if (this._state === FromBTCSwapState.PR_CREATED) throw new Error("Cannot get bitcoin address of non-initiated swaps! Initiate swap first with commit() or txsCommit()."); return this._getHyperlink(); } /** * @inheritDoc */ getInputAddress() { return this.senderAddress ?? null; } /** * @inheritDoc */ getInputTxId() { return this.txId ?? null; } async _setSubmittedBitcoinTx(txId, psbt) { let changed = false; if (this.txId !== txId) { this.txId = txId; changed = true; } const submittedVout = this.address == null || this.amount == null || psbt == null ? undefined : (0, BitcoinUtils_1.getVoutIndex)(psbt, this.wrapper._options.bitcoinNetwork, this.address, this.amount); if (submittedVout != null && this.vout !== submittedVout) { this.vout = submittedVout; changed = true; } const submittedSenderAddress = psbt == null ? undefined : (0, BitcoinUtils_1.getSenderAddress)(psbt, this.wrapper._options.bitcoinNetwork); if (submittedSenderAddress != null && this.senderAddress !== submittedSenderAddress) { this.senderAddress = submittedSenderAddress; changed = true; } if (changed) await this._saveAndEmit(); } /** * Returns timeout time (in UNIX milliseconds) when the on-chain address will expire and no funds should be sent * to that address anymore */ getTimeoutTime() { return Number(this.wrapper._getOnchainSendTimeout(this._data, this.requiredConfirmations ?? 6)) * 1000; } /** * @inheritDoc */ requiresAction() { return this.isClaimable() || (this._state === FromBTCSwapState.CLAIM_COMMITED && this.getTimeoutTime() > Date.now() && this.txId == null); } /** * @inheritDoc */ isFinished() { return this._state === FromBTCSwapState.CLAIM_CLAIMED || this._state === FromBTCSwapState.QUOTE_EXPIRED || this._state === FromBTCSwapState.FAILED; } /** * @inheritDoc */ isClaimable() { return this._state === FromBTCSwapState.BTC_TX_CONFIRMED; } /** * @inheritDoc */ isSuccessful() { return this._state === FromBTCSwapState.CLAIM_CLAIMED; } /** * @inheritDoc */ isFailed() { return this._state === FromBTCSwapState.FAILED || this._state === FromBTCSwapState.EXPIRED; } /** * @inheritDoc */ isInProgress() { return this._state === FromBTCSwapState.CLAIM_COMMITED || this._state === FromBTCSwapState.BTC_TX_CONFIRMED; } /** * @inheritDoc */ isQuoteExpired() { return this._state === FromBTCSwapState.QUOTE_EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired() { return this._state === FromBTCSwapState.QUOTE_EXPIRED || this._state === FromBTCSwapState.QUOTE_SOFT_EXPIRED; } /** * @inheritDoc * @internal */ canCommit(skipQuoteExpiryChecks) { if (this._state !== FromBTCSwapState.PR_CREATED && (!skipQuoteExpiryChecks || this._state !== FromBTCSwapState.QUOTE_SOFT_EXPIRED)) return false; if (this.requiredConfirmations == null) return false; const expiry = this.wrapper._getOnchainSendTimeout(this._data, this.requiredConfirmations); const currentTimestamp = BigInt(Math.floor(Date.now() / 1000)); return (expiry - currentTimestamp) >= this.wrapper._options.minSendWindow; } ////////////////////////////// //// Amounts & fees /** * @inheritDoc */ getInputToken() { return Token_1.BitcoinTokens.BTC; } /** * @inheritDoc */ getInput() { return (0, TokenAmount_1.toTokenAmount)(this.amount ?? null, this.inputToken, this.wrapper._prices); } /** * Returns claimer bounty, acting as a reward for watchtowers to claim the swap automatically, * this amount is pre-funded by the user on the destination chain when the swap escrow * is initiated. For total pre-funded deposit amount see {@link getTotalDeposit}. */ getClaimerBounty() { return (0, TokenAmount_1.toTokenAmount)(this._data.getClaimerBounty(), this.wrapper._tokens[this._data.getDepositToken()], this.wrapper._prices); } ////////////////////////////// //// Bitcoin tx /** * If the required number of confirmations is not known, this function tries to infer it by looping through * possible confirmation targets and comparing the claim hashes * * @param btcTx Bitcoin transaction * @param vout Output index of the desired output in the bitcoin transaction * * @private */ inferRequiredConfirmationsCount(btcTx, vout) { const txOut = btcTx.outs[vout]; for (let i = 1; i <= 20; i++) { const computedClaimHash = this._contract.getHashForOnchain(buffer_1.Buffer.from(txOut.scriptPubKey.hex, "hex"), BigInt(txOut.value), i); if (computedClaimHash.toString("hex") === this._data.getClaimHash()) { return i; } } } /** * @inheritDoc */ getRequiredConfirmationsCount() { return this.requiredConfirmations ?? NaN; } /** * Checks whether a bitcoin payment was already made, returns the payment or `null` when no payment has been made. * * @internal */ async getBitcoinPayment() { const txoHashHint = this._data.getTxoHashHint(); if (txoHashHint == null) throw new Error("Swap data doesn't include the txo hash hint! Cannot check bitcoin transaction!"); if (this.address == null) throw new Error("Cannot check bitcoin payment, because the address is not known! This can happen after a swap is recovered."); const result = await this.wrapper._btcRpc.checkAddressTxos(this.address, buffer_1.Buffer.from(txoHashHint, "hex")); if (result == null) return null; if (this.requiredConfirmations == null) { this.requiredConfirmations = this.inferRequiredConfirmationsCount(result.tx, result.vout); } return { inputAddresses: result.tx.inputAddresses, txId: result.tx.txid, vout: result.vout, confirmations: result.tx.confirmations ?? 0, targetConfirmations: this.getRequiredConfirmationsCount() }; } /** * Used to set the txId of the bitcoin payment from the on-chain events listener * * @param txId Transaction ID that settled the swap on the smart chain * * @internal */ async _setBitcoinTxId(txId) { if (this.txId !== txId || this.address == null || this.vout == null || this.senderAddress == null || this.amount == null) { const btcTx = await this.wrapper._btcRpc.getTransaction(txId); if (btcTx == null) return; const txoHashHint = this._data.getTxoHashHint(); if (txoHashHint != null) { const expectedTxoHash = buffer_1.Buffer.from(txoHashHint, "hex"); const vout = btcTx.outs.findIndex(out => (0, Utils_1.getTxoHash)(out.scriptPubKey.hex, out.value).equals(expectedTxoHash)); if (vout !== -1) { this.vout = vout; //If amount or address are not known, parse them from the bitcoin tx // this can happen if the swap is recovered from on-chain data and // hence doesn't contain the address and amount data if (this.amount == null) this.amount = BigInt(btcTx.outs[vout].value); if (this.address == null) try { this.address = (0, BitcoinUtils_1.fromOutputScript)(this.wrapper._options.bitcoinNetwork, btcTx.outs[vout].scriptPubKey.hex); } catch (e) { this.logger.warn("_setBitcoinTxId(): Failed to parse address from output script: ", e); } if (this.requiredConfirmations == null) { this.requiredConfirmations = this.inferRequiredConfirmationsCount(btcTx, vout); } } } if (btcTx.inputAddresses != null) { this.senderAddress = btcTx.inputAddresses[0]; } } this.txId = txId; } /** * @inheritDoc * * @throws {Error} if in invalid state (must be {@link FromBTCSwapState.CLAIM_COMMITED}) */ async waitForBitcoinTransaction(updateCallback, checkIntervalSeconds, abortSignal) { if (this._state !== FromBTCSwapState.CLAIM_COMMITED && this._state !== FromBTCSwapState.EXPIRED) throw new Error("Must be in COMMITED state!"); const txoHashHint = this._data.getTxoHashHint(); if (txoHashHint == null) throw new Error("Swap data doesn't include the txo hash hint! Cannot check bitcoin transaction!"); if (this.address == null) throw new Error("Cannot check bitcoin payment, because the address is not known! This can happen after a swap is recovered."); let abortedDueToEnoughConfirmationsResult; const abortController = (0, Utils_1.extendAbortController)(abortSignal); const result = await this.wrapper._btcRpc.waitForAddressTxo(this.address, buffer_1.Buffer.from(txoHashHint, "hex"), this.requiredConfirmations ?? 6, //In case confirmation count is not known, we use a conservative estimate (btcTx, vout, txEtaMs) => { let requiredConfirmations = this.requiredConfirmations; if (btcTx != null && vout != null && requiredConfirmations == null) { requiredConfirmations = this.inferRequiredConfirmationsCount(btcTx, vout); } if (btcTx != null && (btcTx.txid !== this.txId || this.vout == null || this.senderAddress == null || (this.requiredConfirmations == null && requiredConfirmations != null))) { this.txId = btcTx.txid; this.vout = vout; this.requiredConfirmations = requiredConfirmations; if (btcTx.inputAddresses != null) this.senderAddress = btcTx.inputAddresses[0]; this._saveAndEmit().catch(e => { this.logger.error("waitForBitcoinTransaction(): Failed to save swap from within waitForAddressTxo callback:", e); }); } //Abort the loop as soon as the transaction gets enough confirmations, this is required in case // we pass a default 6 confirmations to the fn, but then are able to infer the actual confirmation // target from the prior block if (btcTx?.confirmations != null && requiredConfirmations != null && requiredConfirmations <= btcTx.confirmations && vout != null) { abortedDueToEnoughConfirmationsResult = { tx: btcTx, vout }; abortController.abort(); return; } if (updateCallback != null) updateCallback(btcTx?.txid, btcTx == null ? undefined : (btcTx?.confirmations ?? 0), requiredConfirmations ?? NaN, txEtaMs); }, abortController.signal, checkIntervalSeconds).catch(e => { //We catch the case when the loop was aborted due to the transaction getting enough confirmations if (abortedDueToEnoughConfirmationsResult != null) return abortedDueToEnoughConfirmationsResult; throw e; }); if (abortSignal != null) abortSignal.throwIfAborted(); this.txId = result.tx.txid; this.vout = result.vout; if (result.tx.inputAddresses != null) this.senderAddress = result.tx.inputAddresses[0]; if (this._state !== FromBTCSwapState.CLAIM_CLAIMED && this._state !== FromBTCSwapState.FAILED) { this.btcTxConfirmedAt ??= Date.now(); this._state = FromBTCSwapState.BTC_TX_CONFIRMED; } await this._saveAndEmit(); return result.tx.txid; } /** * Private getter of the funded PSBT that doesn't check current state * * @param _bitcoinWallet Bitcoin wallet to fund the PSBT with * @param feeRate Optional bitcoin fee rate in sats/vB * @param additionalOutputs Optional additional outputs that should also be included in the generated PSBT * * @private */ async _getFundedPsbt(_bitcoinWallet, feeRate, additionalOutputs) { if (this.address == null) throw new Error("Cannot create funded PSBT, because the address is not known! This can happen after a swap is recovered."); let bitcoinWallet; if ((0, IBitcoinWallet_1.isIBitcoinWallet)(_bitcoinWallet)) { bitcoinWallet = _bitcoinWallet; } else { bitcoinWallet = new SingleAddressBitcoinWallet_1.SingleAddressBitcoinWallet(this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork, _bitcoinWallet); } //TODO: Maybe re-introduce fee rate check here if passed from the user if (feeRate == null) { feeRate = await bitcoinWallet.getFeeRate(); } const basePsbt = new btc_signer_1.Transaction({ allowUnknownOutputs: true, allowLegacyWitnessUtxo: true }); basePsbt.addOutput({ amount: this.amount, script: (0, BitcoinUtils_1.toOutputScript)(this.wrapper._options.bitcoinNetwork, this.address) }); if (additionalOutputs != null) additionalOutputs.forEach(output => { basePsbt.addOutput({ amount: output.amount, script: output.outputScript ?? (0, BitcoinUtils_1.toOutputScript)(this.wrapper._options.bitcoinNetwork, output.address) }); }); const psbt = await bitcoinWallet.fundPsbt(basePsbt, feeRate); //Sign every input const signInputs = []; for (let i = 0; i < psbt.inputsLength; i++) { signInputs.push(i); } const serializedPsbt = buffer_1.Buffer.from(psbt.toPSBT()); return { psbt, psbtHex: serializedPsbt.toString("hex"), psbtBase64: serializedPsbt.toString("base64"), signInputs, feeRate }; } /** * @inheritDoc */ getFundedPsbt(_bitcoinWallet, feeRate, additionalOutputs) { if (this._state !== FromBTCSwapState.CLAIM_COMMITED) throw new Error("Swap not committed yet, please initiate the swap first with commit() call!"); if (this.txId != null) throw new Error("Bitcoin transaction already submitted for this swap!"); return this._getFundedPsbt(_bitcoinWallet, feeRate, additionalOutputs); } /** * @inheritDoc * * @throws {Error} if the swap is in invalid state (not in {@link FromBTCSwapState.CLAIM_COMMITED}), or if * the swap bitcoin address already expired. */ async submitPsbt(_psbt) { const psbt = (0, BitcoinUtils_1.parsePsbtTransaction)(_psbt); if (this._state !== FromBTCSwapState.CLAIM_COMMITED) throw new Error("Swap not committed yet, please initiate the swap first with commit() call!"); if (this.txId != null) throw new Error("Bitcoin transaction already submitted for this swap!"); //Ensure not expired if (this.getTimeoutTime() < Date.now()) { throw new Error("Swap address expired!"); } const output0 = psbt.getOutput(0); if (this.amount != null && output0.amount !== this.amount) throw new Error("PSBT output amount invalid, expected: " + this.amount + " got: " + output0.amount); if (this.address != null) { const expectedOutputScript = (0, BitcoinUtils_1.toOutputScript)(this.wrapper._options.bitcoinNetwork, this.address); if (output0.script == null || !expectedOutputScript.equals(output0.script)) throw new Error("PSBT output script invalid!"); } if (!psbt.isFinal) psbt.finalize(); const txId = await this.wrapper._btcRpc.sendRawTransaction(buffer_1.Buffer.from(psbt.toBytes(true, true)).toString("hex")); await this._setSubmittedBitcoinTx(txId, psbt); return txId; } /** * @inheritDoc */ async estimateBitcoinFee(_bitcoinWallet, feeRate) { if (this.address == null || this.amount == null) return null; const bitcoinWallet = (0, BitcoinWalletUtils_1.toBitcoinWallet)(_bitcoinWallet, this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork); const txFee = await bitcoinWallet.getTransactionFee(this.address, this.amount, feeRate); if (txFee == null) return null; return (0, TokenAmount_1.toTokenAmount)(BigInt(txFee), Token_1.BitcoinTokens.BTC, this.wrapper._prices); } /** * @inheritDoc */ async sendBitcoinTransaction(wallet, feeRate) { if (this.address == null || this.amount == null) throw new Error("Cannot send bitcoin transaction, because the address is not known! This can happen after a swap is recovered."); if (this._state !== FromBTCSwapState.CLAIM_COMMITED) throw new Error("Swap not committed yet, please initiate the swap first with commit() call!"); if (this.txId != null) throw new Error("Bitcoin transaction already submitted for this swap!"); //Ensure not expired if (this.getTimeoutTime() < Date.now()) { throw new Error("Swap address expired!"); } if ((0, IBitcoinWallet_1.isIBitcoinWallet)(wallet)) { const txId = await wallet.sendTransaction(this.address, this.amount, feeRate); await this._setSubmittedBitcoinTx(txId); return txId; } else { const { psbt, psbtHex, psbtBase64, signInputs } = await this.getFundedPsbt(wallet, feeRate); const signedPsbt = await wallet.signPsbt({ psbt, psbtHex, psbtBase64 }, signInputs); return await this.submitPsbt(signedPsbt); } } ////////////////////////////// //// Execution /** * Executes the swap with the provided bitcoin wallet, * * @param dstSigner Signer on the destination network, needs to have the same address as the one specified when * quote was created, this is required for legacy swaps because the destination wallet needs to actively open * a bitcoin swap address to which the BTC is then sent, this means that the address also needs to have enough * native tokens to pay for gas on the destination network * @param wallet Bitcoin wallet to use to sign the bitcoin transaction, can also be null - then the execution waits * till a transaction is received from an external wallet * @param callbacks Callbacks to track the progress of the swap * @param options Optional options for the swap like feeRate, AbortSignal, and timeouts/intervals * * @returns {boolean} Whether a swap was settled automatically by swap watchtowers or requires manual claim by the * user, in case `false` is returned the user should call `swap.claim()` to settle the swap on the destination manually */ async execute(dstSigner, wallet, callbacks, options) { if (this._state === FromBTCSwapState.FAILED) throw new Error("Swap failed!"); if (this._state === FromBTCSwapState.EXPIRED) throw new Error("Swap address expired!"); if (this._state === FromBTCSwapState.QUOTE_EXPIRED || this._state === FromBTCSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Swap quote expired!"); if (this._state === FromBTCSwapState.CLAIM_CLAIMED) throw new Error("Swap already settled!"); if (this._state === FromBTCSwapState.PR_CREATED) { await this.commit(dstSigner, options?.abortSignal, undefined, callbacks?.onDestinationCommitSent); } if (this._state === FromBTCSwapState.CLAIM_COMMITED) { if (wallet != null) { const bitcoinPaymentSent = await this.getBitcoinPayment(); if (bitcoinPaymentSent == null && this.txId == null) { //Send btc tx const txId = await this.sendBitcoinTransaction(wallet, options?.feeRate); if (callbacks?.onSourceTransactionSent != null) callbacks.onSourceTransactionSent(txId); } } const txId = await this.waitForBitcoinTransaction(callbacks?.onSourceTransactionConfirmationStatus, options?.btcTxCheckIntervalSeconds, options?.abortSignal); if (callbacks?.onSourceTransactionConfirmed != null) callbacks.onSourceTransactionConfirmed(txId); } // @ts-ignore if (this._state === FromBTCSwapState.CLAIM_CLAIMED) return true; if (this._state === FromBTCSwapState.BTC_TX_CONFIRMED) { const success = await this.waitTillClaimed(options?.maxWaitTillAutomaticSettlementSeconds ?? 60, options?.abortSignal); if (success && callbacks?.onSwapSettled != null) callbacks.onSwapSettled(this.getOutputTxId()); return success; } throw new Error("Invalid state reached!"); } /** * @internal */ async _getExecutionStatus(options) { const state = this._state; const now = Date.now(); const timeoutTime = this.getTimeoutTime(); let confirmations; let bitcoinTxId; let destinationSetupStatus = "awaiting"; let bitcoinPaymentStatus = "inactive"; let destinationSettlementStatus = "inactive"; let buildCurrentAction = async () => undefined; switch (state) { case FromBTCSwapState.PR_CREATED: { const quoteValid = await this._verifyQuoteValid(); destinationSetupStatus = quoteValid && timeoutTime >= now ? "awaiting" : "soft_expired"; if (quoteValid && timeoutTime >= now) { buildCurrentAction = this._buildInitSmartChainTxAction.bind(this); } break; } case FromBTCSwapState.QUOTE_SOFT_EXPIRED: destinationSetupStatus = "soft_expired"; break; case FromBTCSwapState.QUOTE_EXPIRED: destinationSetupStatus = "expired"; break; case FromBTCSwapState.CLAIM_COMMITED: case FromBTCSwapState.EXPIRED: case FromBTCSwapState.FAILED: const bitcoinPayment = this.address == null ? null : await this.getBitcoinPayment(); bitcoinTxId = bitcoinPayment?.txId; let bitcoinConfirmationDelay; if (bitcoinPayment != null && bitcoinPayment.confirmations < bitcoinPayment.targetConfirmations) { const tx = await this.wrapper._btcRpc.getTransaction(bitcoinPayment.txId); const result = tx == null ? null : await this.wrapper._btcRpc.getConfirmationDelay(tx, bitcoinPayment.targetConfirmations); bitcoinConfirmationDelay = result ?? -1; } destinationSetupStatus = "completed"; if (bitcoinPayment == null) { if (this.txId != null) { bitcoinPaymentStatus = state === FromBTCSwapState.FAILED ? "expired" : "received"; if (state !== FromBTCSwapState.FAILED) { buildCurrentAction = this._buildWaitBitcoinConfirmationsAction.bind(this, -1, "Wait for bitcoin transaction to be picked up by the RPC and confirmed."); } } else { bitcoinPaymentStatus = "awaiting"; if (state === FromBTCSwapState.EXPIRED) bitcoinPaymentStatus = "soft_expired"; if (state === FromBTCSwapState.FAILED) bitcoinPaymentStatus = "expired"; if (state === FromBTCSwapState.CLAIM_COMMITED && timeoutTime >= now && this.address != null && this.amount != null) { buildCurrentAction = this._buildSendToAddressOrSignPsbtAction.bind(this); } } } else if (bitcoinPayment.confirmations >= bitcoinPayment.targetConfirmations) { bitcoinPaymentStatus = "confirmed"; if (state !== FromBTCSwapState.FAILED) { buildCurrentAction = this._buildWaitBitcoinConfirmationsAction.bind(this, bitcoinConfirmationDelay ?? -1, undefined); } } else { bitcoinPaymentStatus = "received"; confirmations = { current: bitcoinPayment.confirmations, target: bitcoinPayment.targetConfirmations, etaSeconds: bitcoinConfirmationDelay ?? -1 }; if (state !== FromBTCSwapState.FAILED) { buildCurrentAction = this._buildWaitBitcoinConfirmationsAction.bind(this, bitcoinConfirmationDelay ?? -1, undefined); } } destinationSettlementStatus = state === FromBTCSwapState.FAILED ? "expired" : "inactive"; break; case FromBTCSwapState.BTC_TX_CONFIRMED: destinationSetupStatus = "completed"; bitcoinPaymentStatus = "confirmed"; if (this.btcTxConfirmedAt == null || options?.maxWaitTillAutomaticSettlementSeconds === 0 || (now - this.btcTxConfirmedAt) > (options?.maxWaitTillAutomaticSettlementSeconds ?? 60) * 1000) { destinationSettlementStatus = "awaiting_manual"; buildCurrentAction = this._buildClaimSmartChainTxAction.bind(this); } else { destinationSettlementStatus = "awaiting_automatic"; buildCurrentAction = this._buildWaitSettlementAction.bind(this, options?.maxWaitTillAutomaticSettlementSeconds); } break; case FromBTCSwapState.CLAIM_CLAIMED: destinationSetupStatus = "completed"; bitcoinPaymentStatus = "confirmed"; destinationSettlementStatus = "settled"; break; } if (bitcoinPaymentStatus === "confirmed") { const requiredConfirmations = this.getRequiredConfirmationsCount(); if (!Number.isNaN(requiredConfirmations)) { confirmations = { current: requiredConfirmations, target: requiredConfirmations, etaSeconds: 0 }; } } return { steps: [ { type: "Setup", side: "destination", chain: this.chainIdentifier, title: "Open Bitcoin swap address", description: `Create the escrow on the ${this.chainIdentifier} side to open the Bitcoin swap address`, status: destinationSetupStatus, setupTxId: this._commitTxId }, { type: "Payment", side: "source", chain: "BITCOIN", title: "Bitcoin payment", description: "Send Bitcoin to the swap address and wait for the transaction to confirm", status: bitcoinPaymentStatus, confirmations, initTxId: this.txId ?? bitcoinTxId, settleTxId: this.txId }, { type: "Settlement", side: "destination", chain: this.chainIdentifier, title: "Destination settlement", description: `Wait for automatic settlement on the ${this.chainIdentifier} side, or settle manually if it takes too long`, status: destinationSettlementStatus, initTxId: this._commitTxId, settleTxId: this._claimTxId } ], buildCurrentAction, state }; } /** * @inheritDoc * @internal */ async _submitExecutionTransactions(txs, abortSignal, requiredStates, idempotent) { if (txs.length === 0) throw new Error("Need to submit at least 1 transaction in the array, submitted empty array of transactions!"); if (idempotent) { // Handle idempotent calls let idempotencyTriggered = false; const txIds = []; for (let tx of txs) { let parsedTx; if (typeof (tx) === "string") { try { parsedTx = await this.wrapper._chain.deserializeSignedTx(tx); } catch (e) { } try { parsedTx = (0, BitcoinUtils_1.parsePsbtTransaction)(tx); } catch (e) { } } else { parsedTx = tx; } if (parsedTx == null) { this.logger.debug("_submitExecutionTransactions(): Failed to parse provided execution transaction: ", tx); continue; } if (parsedTx instanceof btc_signer_1.Transaction) { // Bitcoin tx const btcTx = await this.wrapper._btcRpc.parseTransaction(buffer_1.Buffer.from(parsedTx.toBytes(true)).toString("hex")); if (btcTx.txid === this.txId) idempotencyTriggered = true; txIds.push(btcTx.txid); } else { // SC tx if (this.wrapper._chain.getTxId != null) { const txId = await this.wrapper._chain.getTxId(parsedTx); if (this._commitTxId === txId || this._claimTxId === txId) idempotencyTriggered = true; txIds.push(txId); } } } if (idempotencyTriggered) return txIds; } if (requiredStates != null && !requiredStates.includes(this._state)) throw new Error("Swap state has changed before transactions were submitted!"); if (this._state === FromBTCSwapState.CLAIM_COMMITED) { let psbt; if (txs.length !== 1) throw new Error("Need to submit exactly 1 signed PSBT!"); if (typeof (txs[0]) !== "string" && !(txs[0] instanceof btc_signer_1.Transaction)) throw new Error("Must submit a valid PSBT as hex/base64 string or `@scure/btc-signer` Transaction object!"); psbt = txs[0]; return [await this.submitPsbt(psbt)]; } if (this._state === FromBTCSwapState.PR_CREATED || this._state === FromBTCSwapState.QUOTE_SOFT_EXPIRED) { if (!await this._verifyQuoteValid()) throw new Error("Quote is already expired!"); if (this.getTimeoutTime() < Date.now()) throw new Error("Swap address already expired or close to expiry!"); const parsedTxs = []; for (let tx of txs) { parsedTxs.push(typeof (tx) === "string" ? await this.wrapper._chain.deserializeSignedTx(tx) : tx); } const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false); await this.waitTillCommited(abortSignal); return txIds; } if (this._state === FromBTCSwapState.BTC_TX_CONFIRMED) { const parsedTxs = []; for (let tx of txs) { parsedTxs.push(typeof (tx) === "string" ? await this.wrapper._chain.deserializeSignedTx(tx) : tx); } const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false); await this.waitTillClaimed(undefined, abortSignal); return txIds; } throw new Error("Invalid swap state for transaction submission!"); } /** * @internal */ async _buildSendToAddressOrSignPsbtAction(actionOptions) { if (this.address == null) throw new Error("Bitcoin swap address not known!"); if (this.amount == null) throw new Error("Bitcoin swap amount not known!"); if (actionOptions?.bitcoinWallet == null) { return { type: "SendToAddress", name: "Deposit on Bitcoin", description: "Send funds to the bitcoin swap address", chain: "BITCOIN", txs: [{ type: "BITCOIN_ADDRESS", address: this.address, hyperlink: this._getHyperlink(), amount: (0, TokenAmount_1.toTokenAmount)(this.amount, Token_1.BitcoinTokens.BTC, this.wrapper._prices) }], waitForTransactions: async (maxWaitTimeSeconds, pollIntervalSeconds, abortSignal) => { let btcTxId; const abortController = (0, Utils_1.extendAbortController)(abortSignal, maxWaitTimeSeconds, "Timed out waiting for bitcoin transaction"); try { return await this.waitForBitcoinTransaction((txId) => { btcTxId = txId; abortController.abort(); }, pollIntervalSeconds, abortController.signal); } catch (e) { if (btcTxId != null) return btcTxId; throw e; } } }; } return { type: "SignPSBT", name: "Deposit on Bitcoin", description: "Send funds to the bitcoin swap address", chain: "BITCOIN", txs: [{ ...await this.getFundedPsbt(actionOptions.bitcoinWallet, actionOptions?.bitcoinFeeRate), type: "FUNDED_PSBT" }], submitPsbt: async (signedPsbt, idempotent) => { return this._submitExecutionTransactions(Array.isArray(signedPsbt) ? signedPsbt : [signedPsbt], undefined, [FromBTCSwapState.CLAIM_COMMITED], idempotent); } }; } /** * @internal */ async _buildWaitBitcoinConfirmationsAction(confirmationDelay, description) { return { type: "Wait", name: "Bitcoin confirmations", description: description ?? "Wait for bitcoin transaction to confirm", pollTimeSeconds: 10, expectedTimeSeconds: confirmationDelay === -1 ? -1 : Math.floor(confirmationDelay / 1000), wait: async (maxWaitTimeSeconds, pollIntervalSeconds, abortSignal, btcConfirmationsCallback) => { const abortController = (0, Utils_1.extendAbortController)(abortSignal, maxWaitTimeSeconds, "Timed out waiting for bitcoin transaction to confirm"); await this.waitForBitcoinTransaction(btcConfirmationsCallback, pollIntervalSeconds, abortController.signal); } }; } /** * @internal */ async _buildWaitSettlementAction(maxWaitTillAutomaticSettlementSeconds) { return { type: "Wait", name: "Automatic settlement", description: "Wait for automatic settlement by the watchtower", pollTimeSeconds: 5, expectedTimeSeconds: 10, wait: async (maxWaitTimeSeconds, pollIntervalSeconds, abortSignal) => { await this.waitTillClaimed(maxWaitTimeSeconds ?? maxWaitTillAutomaticSettlementSeconds ?? 60, abortSignal, pollIntervalSeconds); } }; } /** * @internal */ async _buildInitSmartChainTxAction(actionOptions) { return { type: "SignSmartChainTransaction", name: "Initiate swap", description: `Opens up the bitcoin swap address on the ${this.chainIdentifier} side`, chain: this.chainIdentifier, txs: await this.prepareTransactions(this.txsCommit(actionOptions?.skipChecks)), submitTransactions: async (txs, abortSignal, idempotent) => { return this._submitExecutionTransactions(txs, abortSignal, [FromBTCSwapState.PR_CREATED, FromBTCSwapState.QUOTE_SOFT_EXPIRED], idempotent); }, requiredSigner: this._getInitiator() }; } /** * @inheritDoc * @internal */ async _buildClaimSmartChainTxAction(actionOptions) { const signerAddress = await this.wrapper._getSignerAddress(actionOptions?.manualSettlementSmartChainSigner); return { type: "SignSmartChainTransaction", name: "Settle manually", description: "Manually settle (claim) the swap on the destination smart chain", chain: this.chainIdentifier, txs: await this.prepareTransactions(this.txsClaim(actionOptions?.manualSettlementSmartChainSigner)), submitTransactions: async (txs, abortSignal, idempotent) => { return this._submitExecutionTransactions(txs, abortSignal, [FromBTCSwapState.BTC_TX_CONFIRMED], idempotent); }, requiredSigner: signerAddress ?? this._getInitiator() }; } /** * @inheritDoc * * @param options.bitcoinFeeRate Optional fee rate to use for the created Bitcoin transaction * @param options.bitcoinWallet Bitcoin wallet to use, when provided the function returns a funded * psbt (`"FUNDED_PSBT"`), if not passed just a bitcoin receive address is returned (`"ADDRESS"`) * @param options.skipChecks Skip checks like making sure init signature is still valid and swap * wasn't commited yet (this is handled on swap creation, if you commit right after quoting, you * can use `skipChecks=true`) * @param options.manualSettlementSmartChainSigner Optional smart chain signer to create a manual claim (settlement) transaction * @param options.maxWaitTillAutomaticSettlementSeconds Maximum time to wait for an automatic settlement after * the bitcoin transaction is confirmed (defaults to 60 seconds) */ async getExecutionAction(options) { const executionStatus = await this._getExecutionStatus(options); return executionStatus.buildCurrentAction(options); } /** * @inheritDoc */ async getExecutionStatus(options) { const executionStatus = await this._getExecutionStatus(options); return { steps: executionStatus.steps, currentAction: options?.skipBuildingAction ? undefined : await executionStatus.buildCurrentAction(options), stateInfo: this._getStateInfo(executionStatus.state) }; } /** * @inherit