UNPKG

@atomiqlabs/sdk

Version:

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

1,140 lines (1,030 loc) 78.4 kB
import {IFromBTCSelfInitSwap} from "../IFromBTCSelfInitSwap"; import {SwapType} from "../../../../enums/SwapType"; import {FromBTCDefinition, FromBTCWrapper} from "./FromBTCWrapper"; import { BtcTxWithBlockheight, ChainType, isAbstractSigner, SwapCommitState, SwapCommitStateType, SwapData } from "@atomiqlabs/base"; import {Buffer} from "buffer"; import { extendAbortController, getTxoHash, toBigInt } from "../../../../utils/Utils"; import { fromOutputScript, getSenderAddress, getVoutIndex, parsePsbtTransaction, toOutputScript, } from "../../../../utils/BitcoinUtils"; import {IBitcoinWallet, isIBitcoinWallet} from "../../../../bitcoin/wallet/IBitcoinWallet"; import {IBTCWalletSwap} from "../../../IBTCWalletSwap"; import {Transaction} from "@scure/btc-signer"; import {SingleAddressBitcoinWallet} from "../../../../bitcoin/wallet/SingleAddressBitcoinWallet"; import { MinimalBitcoinWalletInterface, MinimalBitcoinWalletInterfaceWithSigner } from "../../../../types/wallets/MinimalBitcoinWalletInterface"; import {IClaimableSwap} from "../../../IClaimableSwap"; import {IEscrowSelfInitSwapInit, isIEscrowSelfInitSwapInit} from "../../IEscrowSelfInitSwap"; import {IAddressSwap} from "../../../IAddressSwap"; import {TokenAmount, toTokenAmount} from "../../../../types/TokenAmount"; import {BitcoinTokens, BtcToken, SCToken} from "../../../../types/Token"; import {getLogger, LoggerType} from "../../../../utils/Logger"; import {timeoutPromise} from "../../../../utils/TimeoutUtils"; import {toBitcoinWallet} from "../../../../utils/BitcoinWalletUtils"; import { SwapExecutionActionSendToAddress, SwapExecutionActionSignPSBT, SwapExecutionActionSignSmartChainTx, SwapExecutionActionWait } from "../../../../types/SwapExecutionAction"; import { SwapExecutionStepPayment, SwapExecutionStepSettlement, SwapExecutionStepSetup } from "../../../../types/SwapExecutionStep"; import {SwapStateInfo} from "../../../../types/SwapStateInfo"; /** * State enum for legacy escrow based Bitcoin -> Smart chain swaps. * * @category Swaps/Legacy/Bitcoin → Smart chain */ export enum FromBTCSwapState { /** * Bitcoin swap address has expired and the intermediary (LP) has already refunded * its funds. No BTC should be sent anymore! */ FAILED = -4, /** * 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. */ EXPIRED = -3, /** * Swap has expired for good and there is no way how it can be executed anymore */ QUOTE_EXPIRED = -2, /** * 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 */ QUOTE_SOFT_EXPIRED = -1, /** * 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 */ PR_CREATED = 0, /** * 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. */ CLAIM_COMMITED = 1, /** * 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. */ BTC_TX_CONFIRMED = 2, /** * Swap successfully settled and funds received on the destination chain */ CLAIM_CLAIMED = 3 } 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" }; export type FromBTCSwapInit<T extends SwapData> = IEscrowSelfInitSwapInit<T> & { data: T; address?: string; amount?: bigint; requiredConfirmations?: number; }; export function isFromBTCSwapInit<T extends SwapData>(obj: any): obj is FromBTCSwapInit<T> { 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") && isIEscrowSelfInitSwapInit<T>(obj); } /** * 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 */ export class FromBTCSwap<T extends ChainType = ChainType> extends IFromBTCSelfInitSwap<T, FromBTCDefinition<T>, FromBTCSwapState> implements IBTCWalletSwap, IClaimableSwap<T, FromBTCDefinition<T>, FromBTCSwapState>, IAddressSwap { protected readonly TYPE: SwapType.FROM_BTC = SwapType.FROM_BTC; /** * @internal */ protected readonly swapStateName = (state: number) => FromBTCSwapState[state]; /** * @internal */ protected readonly swapStateDescription = FromBTCSwapStateDescription; /** * @internal */ protected readonly logger: LoggerType; /** * @internal */ protected readonly inputToken: BtcToken<false> = BitcoinTokens.BTC; /** * @internal */ protected readonly feeRate!: string; /** * @internal */ readonly _data!: T["Data"]; private address?: string; private amount?: bigint; private requiredConfirmations?: number; private senderAddress?: string; private txId?: string; private vout?: number; private btcTxConfirmedAt?: number; constructor(wrapper: FromBTCWrapper<T>, init: FromBTCSwapInit<T["Data"]>); constructor(wrapper: FromBTCWrapper<T>, obj: any); constructor(wrapper: FromBTCWrapper<T>, initOrObject: FromBTCSwapInit<T["Data"]> | any) { if(isFromBTCSwapInit(initOrObject) && initOrObject.url!=null) initOrObject.url += "/frombtc"; super(wrapper, initOrObject); 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 = 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 = getLogger("FromBTC("+this.getIdentifierHashString()+"): "); } /** * @inheritDoc * @internal */ protected getSwapData(): T["Data"] { return this._data; } /** * @inheritDoc * @internal */ protected 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(): string { 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 */ private _getHyperlink(): string { return this.address==null || this.amount==null ? "" : "bitcoin:"+this.address+"?amount="+encodeURIComponent((Number(this.amount) / 100000000).toString(10)); } /** * @inheritDoc */ getHyperlink(): string { 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(): string | null { return this.senderAddress ?? null; } /** * @inheritDoc */ getInputTxId(): string | null { return this.txId ?? null; } private async _setSubmittedBitcoinTx(txId: string, psbt?: Transaction): Promise<void> { let changed = false; if(this.txId!==txId) { this.txId = txId; changed = true; } const submittedVout = this.address==null || this.amount==null || psbt==null ? undefined : 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 : 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(): number { return Number(this.wrapper._getOnchainSendTimeout(this._data, this.requiredConfirmations ?? 6)) * 1000; } /** * @inheritDoc */ requiresAction(): boolean { return this.isClaimable() || (this._state===FromBTCSwapState.CLAIM_COMMITED && this.getTimeoutTime()>Date.now() && this.txId==null); } /** * @inheritDoc */ isFinished(): boolean { return this._state===FromBTCSwapState.CLAIM_CLAIMED || this._state===FromBTCSwapState.QUOTE_EXPIRED || this._state===FromBTCSwapState.FAILED; } /** * @inheritDoc */ isClaimable(): boolean { return this._state===FromBTCSwapState.BTC_TX_CONFIRMED; } /** * @inheritDoc */ isSuccessful(): boolean { return this._state===FromBTCSwapState.CLAIM_CLAIMED; } /** * @inheritDoc */ isFailed(): boolean { return this._state===FromBTCSwapState.FAILED || this._state===FromBTCSwapState.EXPIRED; } /** * @inheritDoc */ isInProgress(): boolean { return this._state===FromBTCSwapState.CLAIM_COMMITED || this._state===FromBTCSwapState.BTC_TX_CONFIRMED; } /** * @inheritDoc */ isQuoteExpired(): boolean { return this._state===FromBTCSwapState.QUOTE_EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired(): boolean { return this._state===FromBTCSwapState.QUOTE_EXPIRED || this._state===FromBTCSwapState.QUOTE_SOFT_EXPIRED; } /** * @inheritDoc * @internal */ protected canCommit(skipQuoteExpiryChecks?: boolean): boolean { 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(): BtcToken<false> { return BitcoinTokens.BTC; } /** * @inheritDoc */ getInput(): TokenAmount<BtcToken<false>> { return 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(): TokenAmount<SCToken<T["ChainId"]>, true> { return 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 */ private inferRequiredConfirmationsCount(btcTx: Omit<BtcTxWithBlockheight, "hex" | "raw">, vout: number): number | undefined { const txOut = btcTx.outs[vout]; for(let i=1;i<=20;i++) { const computedClaimHash = this._contract.getHashForOnchain( Buffer.from(txOut.scriptPubKey.hex, "hex"), BigInt(txOut.value), i ); if(computedClaimHash.toString("hex")===this._data.getClaimHash()) { return i; } } } /** * @inheritDoc */ getRequiredConfirmationsCount(): number { return this.requiredConfirmations ?? NaN; } /** * Checks whether a bitcoin payment was already made, returns the payment or `null` when no payment has been made. * * @internal */ protected async getBitcoinPayment(): Promise<{ txId: string, vout: number, confirmations: number, targetConfirmations: number, inputAddresses?: string[] } | null> { 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.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: string) { 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.from(txoHashHint, "hex"); const vout = btcTx.outs.findIndex(out => 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 = fromOutputScript(this.wrapper._options.bitcoinNetwork, btcTx.outs[vout].scriptPubKey.hex); } catch (e: any) { 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?: (txId?: string, confirmations?: number, targetConfirmations?: number, txEtaMs?: number) => void, checkIntervalSeconds?: number, abortSignal?: AbortSignal ): Promise<string> { 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: { tx: Omit<BtcTxWithBlockheight, "hex" | "raw">, vout: number } | undefined; const abortController = extendAbortController(abortSignal); const result = await this.wrapper._btcRpc.waitForAddressTxo( this.address, Buffer.from(txoHashHint, "hex"), this.requiredConfirmations ?? 6, //In case confirmation count is not known, we use a conservative estimate (btcTx?: Omit<BtcTxWithBlockheight, "hex" | "raw">, vout?: number, txEtaMs?: number) => { 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 as FromBTCSwapState)!==FromBTCSwapState.CLAIM_CLAIMED && (this._state as FromBTCSwapState)!==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 */ private async _getFundedPsbt( _bitcoinWallet: IBitcoinWallet | MinimalBitcoinWalletInterface, feeRate?: number, additionalOutputs?: ({amount: bigint, outputScript: Uint8Array} | {amount: bigint, address: string})[] ): Promise<{psbt: Transaction, psbtHex: string, psbtBase64: string, signInputs: number[], feeRate: number}> { 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: IBitcoinWallet; if(isIBitcoinWallet(_bitcoinWallet)) { bitcoinWallet = _bitcoinWallet; } else { bitcoinWallet = new 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 Transaction({ allowUnknownOutputs: true, allowLegacyWitnessUtxo: true }); basePsbt.addOutput({ amount: this.amount, script: toOutputScript(this.wrapper._options.bitcoinNetwork, this.address) }); if(additionalOutputs!=null) additionalOutputs.forEach(output => { basePsbt.addOutput({ amount: output.amount, script: (output as {outputScript: Uint8Array}).outputScript ?? toOutputScript(this.wrapper._options.bitcoinNetwork, (output as {address: string}).address) }); }); const psbt = await bitcoinWallet.fundPsbt(basePsbt, feeRate); //Sign every input const signInputs: number[] = []; for(let i=0;i<psbt.inputsLength;i++) { signInputs.push(i); } const serializedPsbt = Buffer.from(psbt.toPSBT()); return { psbt, psbtHex: serializedPsbt.toString("hex"), psbtBase64: serializedPsbt.toString("base64"), signInputs, feeRate }; } /** * @inheritDoc */ getFundedPsbt( _bitcoinWallet: IBitcoinWallet | MinimalBitcoinWalletInterface, feeRate?: number, additionalOutputs?: ({amount: bigint, outputScript: Uint8Array} | {amount: bigint, address: string})[] ) { 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: Transaction | string): Promise<string> { const psbt = 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 = 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.from(psbt.toBytes(true, true)).toString("hex")); await this._setSubmittedBitcoinTx(txId, psbt); return txId; } /** * @inheritDoc */ async estimateBitcoinFee(_bitcoinWallet: IBitcoinWallet | MinimalBitcoinWalletInterface, feeRate?: number): Promise<TokenAmount<BtcToken<false>, true> | null> { if(this.address==null || this.amount==null) return null; const bitcoinWallet: IBitcoinWallet = 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 toTokenAmount(BigInt(txFee), BitcoinTokens.BTC, this.wrapper._prices); } /** * @inheritDoc */ async sendBitcoinTransaction(wallet: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner, feeRate?: number): Promise<string> { 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(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: T["Signer"] | T["NativeSigner"], wallet?: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner | null | undefined, callbacks?: { onDestinationCommitSent?: (destinationCommitTxId: string) => void, onSourceTransactionSent?: (sourceTxId: string) => void, onSourceTransactionConfirmationStatus?: (sourceTxId?: string, confirmations?: number, targetConfirations?: number, etaMs?: number) => void, onSourceTransactionConfirmed?: (sourceTxId: string) => void, onSwapSettled?: (destinationTxId: string) => void }, options?: { feeRate?: number, abortSignal?: AbortSignal, btcTxCheckIntervalSeconds?: number, maxWaitTillAutomaticSettlementSeconds?: number } ): Promise<boolean> { 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 */ protected async _getExecutionStatus(options?: { maxWaitTillAutomaticSettlementSeconds?: number }) { const state = this._state; const now = Date.now(); const timeoutTime = this.getTimeoutTime(); let confirmations: { current: number, target: number, etaSeconds: number } | undefined; let bitcoinTxId: string | undefined; let destinationSetupStatus: SwapExecutionStepSetup<T["ChainId"]>["status"] = "awaiting"; let bitcoinPaymentStatus: SwapExecutionStepPayment<"BITCOIN">["status"] = "inactive"; let destinationSettlementStatus: SwapExecutionStepSettlement<T["ChainId"], "awaiting_automatic" | "awaiting_manual">["status"] = "inactive"; let buildCurrentAction: (actionOptions?: { bitcoinFeeRate?: number, bitcoinWallet?: MinimalBitcoinWalletInterface, skipChecks?: boolean, manualSettlementSmartChainSigner?: string | T["Signer"] | T["NativeSigner"], }) => Promise< SwapExecutionActionSendToAddress<false> | SwapExecutionActionSignPSBT<"FUNDED_PSBT"> | SwapExecutionActionWait<"BITCOIN_CONFS" | "SETTLEMENT"> | SwapExecutionActionSignSmartChainTx<T> | undefined > = 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: number | undefined; 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 } ] as [ SwapExecutionStepSetup<T["ChainId"]>, SwapExecutionStepPayment<"BITCOIN">, SwapExecutionStepSettlement<T["ChainId"], "awaiting_automatic" | "awaiting_manual"> ], buildCurrentAction, state }; } /** * @inheritDoc * @internal */ async _submitExecutionTransactions(txs: (T["SignedTXType"] | Transaction | string)[], abortSignal?: AbortSignal, requiredStates?: FromBTCSwapState[], idempotent?: boolean): Promise<string[]> { 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: string[] = []; for(let tx of txs) { let parsedTx: T["SignedTXType"] | Transaction | undefined; if(typeof(tx)==="string") { try { parsedTx = await this.wrapper._chain.deserializeSignedTx(tx); } catch (e) {} try { parsedTx = 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 Transaction) { // Bitcoin tx const btcTx = await this.wrapper._btcRpc.parseTransaction(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: string | Transaction; if(txs.length!==1) throw new Error("Need to submit exactly 1 signed PSBT!"); if(typeof(txs[0])!=="string" && !(txs[0] instanceof 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: T["SignedTXType"][] = []; 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: T["SignedTXType"][] = []; 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 */ private async _buildSendToAddressOrSignPsbtAction(actionOptions?: { bitcoinFeeRate?: number, bitcoinWallet?: MinimalBitcoinWalletInterface, }): Promise< SwapExecutionActionSendToAddress<false> | SwapExecutionActionSignPSBT<"FUNDED_PSBT"> > { 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: toTokenAmount(this.amount, BitcoinTokens.BTC, this.wrapper._prices) }], waitForTransactions: async ( maxWaitTimeSeconds?: number, pollIntervalSeconds?: number, abortSignal?: AbortSignal ) => { let btcTxId: string | undefined; const abortController = 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; } } } as SwapExecutionActionSendToAddress<false>; } 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: string | Transaction | (string | Transaction)[], idempotent?: boolean) => { return this._submitExecutionTransactions( Array.isArray(signedPsbt) ? signedPsbt : [signedPsbt],