UNPKG

@atomiqlabs/sdk

Version:

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

1,216 lines (1,109 loc) 91.6 kB
import {isISwapInit, ISwap, ISwapInit} from "../ISwap"; import { BtcTx, BtcTxWithBlockheight, ChainType, isAbstractSigner, SpvWithdrawalClaimedState, SpvWithdrawalClosedState, SpvWithdrawalFrontedState, SpvWithdrawalState, SpvWithdrawalStateType } from "@atomiqlabs/base"; import {SwapType} from "../../enums/SwapType"; import {SpvFromBTCTypeDefinition, SpvFromBTCWrapper} from "./SpvFromBTCWrapper"; import {extendAbortController} from "../../utils/Utils"; import {parsePsbtTransaction, toCoinselectAddressType, toOutputScript} from "../../utils/BitcoinUtils"; import {getInputType, Transaction} from "@scure/btc-signer"; import {Buffer} from "buffer"; import {Fee} from "../../types/fees/Fee"; import {BitcoinWalletUtxo, IBitcoinWallet, isIBitcoinWallet} from "../../bitcoin/wallet/IBitcoinWallet"; import {IntermediaryAPI} from "../../intermediaries/apis/IntermediaryAPI"; import {IBTCWalletSwap} from "../IBTCWalletSwap"; import {ISwapWithGasDrop} from "../ISwapWithGasDrop"; import { MinimalBitcoinWalletInterface, MinimalBitcoinWalletInterfaceWithSigner } from "../../types/wallets/MinimalBitcoinWalletInterface"; import {IClaimableSwap} from "../IClaimableSwap"; import {FeeType} from "../../enums/FeeType"; import {ppmToPercentage} from "../../types/fees/PercentagePPM"; 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 { deserializePriceInfoType, isPriceInfoType, PriceInfoType, serializePriceInfoType } from "../../types/PriceInfoType"; import {toBitcoinWallet} from "../../utils/BitcoinWalletUtils"; import { SwapExecutionAction, SwapExecutionActionSignPSBT, SwapExecutionActionSignSmartChainTx, SwapExecutionActionWait } from "../../types/SwapExecutionAction"; import { SwapExecutionStepPayment, SwapExecutionStepSettlement } from "../../types/SwapExecutionStep"; import {SwapStateInfo} from "../../types/SwapStateInfo"; /** * State enum for SPV vault (UTXO-controlled vault) based swaps * @category Swaps/Bitcoin → Smart chain */ export enum SpvFromBTCSwapState { /** * Catastrophic failure has occurred when processing the swap on the smart chain side, * this implies a bug in the smart contract code or the user and intermediary deliberately * creating a bitcoin transaction with invalid format unparsable by the smart contract. */ CLOSED = -5, /** * Some of the bitcoin swap transaction inputs were double-spent, this means the swap * has failed and no BTC was sent */ FAILED = -4, /** * The intermediary (LP) declined to co-sign the submitted PSBT, hence the swap failed */ DECLINED = -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 was created, use the {@link SpvFromBTCSwap.getFundedPsbt} or {@link SpvFromBTCSwap.getPsbt} functions * to get the bitcoin swap PSBT that should be signed by the user's wallet and then submitted via the * {@link SpvFromBTCSwap.submitPsbt} function. */ CREATED = 0, /** * Swap bitcoin PSBT was submitted by the client to the SDK */ SIGNED = 1, /** * Swap bitcoin PSBT sent to the intermediary (LP), waiting for the intermediary co-sign * it and broadcast. You can use the {@link SpvFromBTCSwap.waitTillClaimedOrFronted} * function to wait till the intermediary broadcasts the transaction and the transaction * confirms. */ POSTED = 2, /** * Intermediary (LP) has co-signed and broadcasted the bitcoin transaction. You can use the * {@link SpvFromBTCSwap.waitTillClaimedOrFronted} function to wait till the transaction * confirms. */ BROADCASTED = 3, /** * Settlement on the destination smart chain was fronted and funds were already received * by the user, even before the final settlement. */ FRONTED = 4, /** * Bitcoin transaction confirmed with necessary amount of confirmations, wait for automatic * settlement by the watchtower with the {@link waitTillClaimedOrFronted} function, or settle manually * using the {@link FromBTCSwap.claim} or {@link FromBTCSwap.txsClaim} function. */ BTC_TX_CONFIRMED = 5, /** * Swap settled on the smart chain and funds received */ CLAIMED = 6 } const SpvFromBTCSwapStateDescription = { [SpvFromBTCSwapState.CLOSED]: "Catastrophic failure has occurred when processing the swap on the smart chain side, this implies a bug in the smart contract code or the user and intermediary deliberately creating a bitcoin transaction with invalid format unparsable by the smart contract.", [SpvFromBTCSwapState.FAILED]: "Some of the bitcoin swap transaction inputs were double-spent, this means the swap has failed and no BTC was sent", [SpvFromBTCSwapState.DECLINED]: "The intermediary (LP) declined to co-sign the submitted PSBT, hence the swap failed", [SpvFromBTCSwapState.QUOTE_EXPIRED]: "Swap has expired for good and there is no way how it can be executed anymore", [SpvFromBTCSwapState.QUOTE_SOFT_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", [SpvFromBTCSwapState.CREATED]: "Swap was created, get the bitcoin swap PSBT that should be signed by the user's wallet and then submit it back to the SDK.", [SpvFromBTCSwapState.SIGNED]: "Swap bitcoin PSBT was submitted by the client to the SDK", [SpvFromBTCSwapState.POSTED]: "Swap bitcoin PSBT sent to the intermediary (LP), waiting for the intermediary co-sign it and broadcast.", [SpvFromBTCSwapState.BROADCASTED]: "Intermediary (LP) has co-signed and broadcasted the bitcoin transaction.", [SpvFromBTCSwapState.FRONTED]: "Settlement on the destination smart chain was fronted and funds were already received by the user, even before the final settlement.", [SpvFromBTCSwapState.BTC_TX_CONFIRMED]: "Bitcoin transaction confirmed with necessary amount of confirmations, wait for automatic settlement by the watchtower or settle manually.", [SpvFromBTCSwapState.CLAIMED]: "Swap settled on the smart chain and funds received" } export type SpvFromBTCSwapInit = ISwapInit & { quoteId: string; recipient: string; vaultOwner: string; vaultId: bigint; vaultRequiredConfirmations: number; vaultTokenMultipliers: bigint[]; vaultBtcAddress: string; vaultUtxo: string; vaultUtxoValue: bigint; btcDestinationAddress: string; btcAmount: bigint; btcAmountSwap: bigint; btcAmountGas: bigint; minimumBtcFeeRate: number; outputTotalSwap: bigint; outputSwapToken: string; outputTotalGas: bigint; outputGasToken: string; gasSwapFeeBtc: bigint; gasSwapFee: bigint; callerFeeShare: bigint; frontingFeeShare: bigint; executionFeeShare: bigint; genesisSmartChainBlockHeight: number; gasPricingInfo?: PriceInfoType; }; export function isSpvFromBTCSwapInit(obj: any): obj is SpvFromBTCSwapInit { return typeof obj === "object" && typeof(obj.quoteId)==="string" && typeof(obj.recipient)==="string" && typeof(obj.vaultOwner)==="string" && typeof(obj.vaultId)==="bigint" && typeof(obj.vaultRequiredConfirmations)==="number" && Array.isArray(obj.vaultTokenMultipliers) && obj.vaultTokenMultipliers.reduce((prev: boolean, curr: any) => prev && typeof(curr)==="bigint", true) && typeof(obj.vaultBtcAddress)==="string" && typeof(obj.vaultUtxo)==="string" && typeof(obj.vaultUtxoValue)==="bigint" && typeof(obj.btcDestinationAddress)==="string" && typeof(obj.btcAmount)==="bigint" && typeof(obj.btcAmountSwap)==="bigint" && typeof(obj.btcAmountGas)==="bigint" && typeof(obj.minimumBtcFeeRate)==="number" && typeof(obj.outputTotalSwap)==="bigint" && typeof(obj.outputSwapToken)==="string" && typeof(obj.outputTotalGas)==="bigint" && typeof(obj.outputGasToken)==="string" && typeof(obj.gasSwapFeeBtc)==="bigint" && typeof(obj.gasSwapFee)==="bigint" && typeof(obj.callerFeeShare)==="bigint" && typeof(obj.frontingFeeShare)==="bigint" && typeof(obj.executionFeeShare)==="bigint" && typeof(obj.genesisSmartChainBlockHeight)==="number" && (obj.gasPricingInfo==null || isPriceInfoType(obj.gasPricingInfo)) && isISwapInit(obj); } /** * New spv vault (UTXO-controlled vault) based swaps for Bitcoin -> Smart chain swaps not requiring * any initiation on the destination chain, and with the added possibility for the user to receive * a native token on the destination chain as part of the swap (a "gas drop" feature). * * @category Swaps/Bitcoin → Smart chain */ export class SpvFromBTCSwap<T extends ChainType> extends ISwap<T, SpvFromBTCTypeDefinition<T>> implements IBTCWalletSwap, ISwapWithGasDrop<T>, IClaimableSwap<T, SpvFromBTCTypeDefinition<T>, SpvFromBTCSwapState> { protected readonly currentVersion: number = 2; readonly TYPE: SwapType.SPV_VAULT_FROM_BTC = SwapType.SPV_VAULT_FROM_BTC; /** * @internal */ protected readonly swapStateDescription = SpvFromBTCSwapStateDescription; /** * @internal */ protected readonly swapStateName = (state: number) => SpvFromBTCSwapState[state]; /** * @inheritDoc * @internal */ protected readonly logger: LoggerType; private readonly quoteId: string; private readonly recipient: string; private readonly vaultOwner: string; private readonly vaultId: bigint; private readonly vaultRequiredConfirmations: number; private readonly vaultTokenMultipliers: bigint[]; private readonly vaultBtcAddress: string; private readonly vaultUtxo: string; private readonly vaultUtxoValue: bigint; private readonly btcDestinationAddress: string; private readonly btcAmount: bigint; private readonly btcAmountSwap: bigint; private readonly btcAmountGas: bigint; private readonly outputTotalSwap: bigint; private readonly outputSwapToken: string; private readonly outputTotalGas: bigint; private readonly outputGasToken: string; private readonly gasSwapFeeBtc: bigint; private readonly gasSwapFee: bigint; private readonly callerFeeShare: bigint; private readonly frontingFeeShare: bigint; private readonly executionFeeShare: bigint; private readonly gasPricingInfo?: PriceInfoType; private posted?: boolean; /** * @internal */ readonly _genesisSmartChainBlockHeight: number; /** * @internal */ _senderAddress?: string; /** * @internal */ _claimTxId?: string; /** * @internal */ _frontTxId?: string; /** * @internal */ _data?: T["SpvVaultWithdrawalData"]; /** * Minimum fee rate in sats/vB that the input bitcoin transaction needs to pay */ readonly minimumBtcFeeRate: number; /** * Time at which the SDK realized the bitcoin transaction was confirmed * @private */ private btcTxConfirmedAt?: number; private _contract: T["SpvVaultContract"]; constructor(wrapper: SpvFromBTCWrapper<T>, init: SpvFromBTCSwapInit); constructor(wrapper: SpvFromBTCWrapper<T>, obj: any); constructor(wrapper: SpvFromBTCWrapper<T>, initOrObject: SpvFromBTCSwapInit | any) { if(isSpvFromBTCSwapInit(initOrObject) && initOrObject.url!=null) initOrObject.url += "/frombtc_spv"; super(wrapper, initOrObject); if(isSpvFromBTCSwapInit(initOrObject)) { this._state = SpvFromBTCSwapState.CREATED; this.quoteId = initOrObject.quoteId; this.recipient = initOrObject.recipient; this.vaultOwner = initOrObject.vaultOwner; this.vaultId = initOrObject.vaultId; this.vaultRequiredConfirmations = initOrObject.vaultRequiredConfirmations; this.vaultTokenMultipliers = initOrObject.vaultTokenMultipliers; this.vaultBtcAddress = initOrObject.vaultBtcAddress; this.vaultUtxo = initOrObject.vaultUtxo; this.vaultUtxoValue = initOrObject.vaultUtxoValue; this.btcDestinationAddress = initOrObject.btcDestinationAddress; this.btcAmount = initOrObject.btcAmount; this.btcAmountSwap = initOrObject.btcAmountSwap; this.btcAmountGas = initOrObject.btcAmountGas; this.minimumBtcFeeRate = initOrObject.minimumBtcFeeRate; this.outputTotalSwap = initOrObject.outputTotalSwap; this.outputSwapToken = initOrObject.outputSwapToken; this.outputTotalGas = initOrObject.outputTotalGas; this.outputGasToken = initOrObject.outputGasToken; this.gasSwapFeeBtc = initOrObject.gasSwapFeeBtc; this.gasSwapFee = initOrObject.gasSwapFee; this.callerFeeShare = initOrObject.callerFeeShare; this.frontingFeeShare = initOrObject.frontingFeeShare; this.executionFeeShare = initOrObject.executionFeeShare; this._genesisSmartChainBlockHeight = initOrObject.genesisSmartChainBlockHeight; this.gasPricingInfo = initOrObject.gasPricingInfo; const vaultAddressType = toCoinselectAddressType(toOutputScript(this.wrapper._options.bitcoinNetwork, this.vaultBtcAddress)); if(vaultAddressType!=="p2tr" && vaultAddressType!=="p2wpkh" && vaultAddressType!=="p2wsh") throw new Error("Vault address type must be of witness type: p2tr, p2wpkh, p2wsh"); } else { this.quoteId = initOrObject.quoteId; this.recipient = initOrObject.recipient; this.vaultOwner = initOrObject.vaultOwner; this.vaultId = BigInt(initOrObject.vaultId); this.vaultRequiredConfirmations = initOrObject.vaultRequiredConfirmations; this.vaultTokenMultipliers = initOrObject.vaultTokenMultipliers.map((val: string) => BigInt(val)); this.vaultBtcAddress = initOrObject.vaultBtcAddress; this.vaultUtxo = initOrObject.vaultUtxo; this.vaultUtxoValue = BigInt(initOrObject.vaultUtxoValue); this.btcDestinationAddress = initOrObject.btcDestinationAddress; this.btcAmount = BigInt(initOrObject.btcAmount); this.btcAmountSwap = BigInt(initOrObject.btcAmountSwap); this.btcAmountGas = BigInt(initOrObject.btcAmountGas); this.minimumBtcFeeRate = initOrObject.minimumBtcFeeRate; this.outputTotalSwap = BigInt(initOrObject.outputTotalSwap); this.outputSwapToken = initOrObject.outputSwapToken; this.outputTotalGas = BigInt(initOrObject.outputTotalGas); this.outputGasToken = initOrObject.outputGasToken; this.gasSwapFeeBtc = BigInt(initOrObject.gasSwapFeeBtc); this.gasSwapFee = BigInt(initOrObject.gasSwapFee); this.callerFeeShare = BigInt(initOrObject.callerFeeShare); this.frontingFeeShare = BigInt(initOrObject.frontingFeeShare); this.executionFeeShare = BigInt(initOrObject.executionFeeShare); this._genesisSmartChainBlockHeight = initOrObject.genesisSmartChainBlockHeight; this._senderAddress = initOrObject.senderAddress; this._claimTxId = initOrObject.claimTxId; this._frontTxId = initOrObject.frontTxId; this.gasPricingInfo = deserializePriceInfoType(initOrObject.gasPricingInfo); this.btcTxConfirmedAt = initOrObject.btcTxConfirmedAt; this.posted = initOrObject.posted; if(initOrObject.data!=null) this._data = new (this.wrapper._spvWithdrawalDataDeserializer(this._contractVersion))(initOrObject.data); } this.tryCalculateSwapFee(); this.logger = getLogger("SPVFromBTC("+this.getId()+"): "); this._contract = wrapper._contract(this._contractVersion); } /** * @inheritDoc * @internal */ protected upgradeVersion() { if(this.version===1) { this.posted = this.initiated && this._data!=null; this.version = 2; } } /** * @inheritDoc * @internal */ protected tryCalculateSwapFee() { if(this.swapFeeBtc==null && this.swapFee!=null) { this.swapFeeBtc = this.swapFee * this.btcAmountSwap / this.getOutputWithoutFee().rawAmount; } if(this.pricingInfo!=null && this.pricingInfo.swapPriceUSatPerToken==null) { const priceUsdPerBtc = this.pricingInfo.realPriceUsdPerBitcoin; this.pricingInfo = this.wrapper._prices.recomputePriceInfoReceive( this.chainIdentifier, this.btcAmountSwap, this.pricingInfo.satsBaseFee, this.pricingInfo.feePPM, this.getOutputWithoutFee().rawAmount, this.outputSwapToken ); this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc; } } ////////////////////////////// //// Pricing /** * @inheritDoc */ async refreshPriceData(): Promise<void> { if(this.pricingInfo==null) return; const usdPricePerBtc = this.pricingInfo.realPriceUsdPerBitcoin; this.pricingInfo = await this.wrapper._prices.isValidAmountReceive( this.chainIdentifier, this.btcAmountSwap, this.pricingInfo.satsBaseFee, this.pricingInfo.feePPM, this.getOutputWithoutFee().rawAmount, this.outputSwapToken, undefined, undefined, this.swapFeeBtc ); this.pricingInfo.realPriceUsdPerBitcoin = usdPricePerBtc; } ////////////////////////////// //// Getters & utils /** * @inheritDoc * @internal */ _getInitiator(): string { return this.recipient; } /** * @inheritDoc * @internal */ _getEscrowHash(): string | null { return this._data?.btcTx?.txid ?? null; } /** * @inheritDoc */ getId(): string { return this.quoteId+this._randomNonce; } /** * @inheritDoc */ getQuoteExpiry(): number { return this.expiry - 20*1000; } /** * @inheritDoc * @internal */ _verifyQuoteDefinitelyExpired(): Promise<boolean> { return Promise.resolve(this.expiry<Date.now()); } /** * @inheritDoc * @internal */ _verifyQuoteValid(): Promise<boolean> { return Promise.resolve(this.expiry>Date.now() && (this._state===SpvFromBTCSwapState.CREATED || this._state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED)); } /** * @inheritDoc */ getOutputAddress(): string | null { return this.recipient; } /** * @inheritDoc */ getOutputTxId(): string | null { return this._frontTxId ?? this._claimTxId ?? null; } /** * @inheritDoc */ getInputAddress(): string | null { return this._senderAddress ?? null; } /** * @inheritDoc */ getInputTxId(): string | null { return this._data?.btcTx?.txid ?? null; } /** * @inheritDoc */ requiresAction(): boolean { return this._state===SpvFromBTCSwapState.BTC_TX_CONFIRMED; } /** * @inheritDoc */ isFinished(): boolean { return this.isSuccessful() || this.isFailed() || this.isQuoteExpired(); } /** * @inheritDoc */ isClaimable(): boolean { return this._state===SpvFromBTCSwapState.BTC_TX_CONFIRMED; } /** * @inheritDoc */ isSuccessful(): boolean { return this._state===SpvFromBTCSwapState.FRONTED || this._state===SpvFromBTCSwapState.CLAIMED; } /** * @inheritDoc */ isFailed(): boolean { return this._state===SpvFromBTCSwapState.FAILED || this._state===SpvFromBTCSwapState.DECLINED || this._state===SpvFromBTCSwapState.CLOSED; } /** * @inheritDoc */ isInProgress(): boolean { return this._state===SpvFromBTCSwapState.POSTED || this._state===SpvFromBTCSwapState.BROADCASTED || this._state===SpvFromBTCSwapState.BTC_TX_CONFIRMED; } /** * @inheritDoc */ isQuoteExpired(): boolean { return this._state===SpvFromBTCSwapState.QUOTE_EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired(): boolean { return this._state===SpvFromBTCSwapState.QUOTE_EXPIRED || this._state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED; } /** * Returns the data about used spv vault (UTXO-controlled vault) to perform the swap */ getSpvVaultData(): { owner: string, vaultId: bigint, utxo: string } { return { owner: this.vaultOwner, vaultId: this.vaultId, utxo: this.vaultUtxo } } ////////////////////////////// //// Amounts & fees /** * Returns the input BTC amount in sats without any fees * * @internal */ protected getInputSwapAmountWithoutFee(): bigint { return (this.btcAmountSwap - this.swapFeeBtc) * 100_000n / (100_000n + this.callerFeeShare + this.frontingFeeShare + this.executionFeeShare); } /** * Returns the input gas BTC amount in sats without any fees * * @internal */ protected getInputGasAmountWithoutFee(): bigint { return (this.btcAmountGas - this.gasSwapFeeBtc) * 100_000n / (100_000n + this.callerFeeShare + this.frontingFeeShare); } /** * Returns to total input BTC amount in sats without any fees (this is BTC amount for the swap + BTC amount * for the gas drop). * * @internal */ protected getInputAmountWithoutFee(): bigint { return this.getInputSwapAmountWithoutFee() + this.getInputGasAmountWithoutFee(); } /** * Returns the swap output amount without any fees, this value is therefore always higher than * the actual received output. * * @internal */ protected getOutputWithoutFee(): TokenAmount<SCToken<T["ChainId"]>, true> { return toTokenAmount( (this.outputTotalSwap * (100_000n + this.callerFeeShare + this.frontingFeeShare + this.executionFeeShare) / 100_000n) + (this.swapFee ?? 0n), this.wrapper._tokens[this.outputSwapToken], this.wrapper._prices, this.pricingInfo ); } /** * Returns the swap fee charged by the intermediary (LP) on this swap * * @internal */ protected getSwapFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> { if(this.pricingInfo==null) throw new Error("No pricing info known, cannot estimate fee!"); const outputToken = this.wrapper._tokens[this.outputSwapToken]; const gasSwapFeeInOutputToken = this.gasSwapFeeBtc * (10n ** BigInt(outputToken.decimals)) * 1_000_000n / this.pricingInfo.swapPriceUSatPerToken; const feeWithoutBaseFee = this.swapFeeBtc - this.pricingInfo.satsBaseFee; const swapFeePPM = feeWithoutBaseFee * 1000000n / (this.btcAmount - this.swapFeeBtc - this.gasSwapFeeBtc); const amountInSrcToken = toTokenAmount( this.swapFeeBtc + this.gasSwapFeeBtc, BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo ); return { amountInSrcToken, amountInDstToken: toTokenAmount(this.swapFee + gasSwapFeeInOutputToken, outputToken, this.wrapper._prices, this.pricingInfo), currentUsdValue: amountInSrcToken.currentUsdValue, usdValue: amountInSrcToken.usdValue, pastUsdValue: amountInSrcToken.pastUsdValue, composition: { base: toTokenAmount(this.pricingInfo.satsBaseFee, BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo), percentage: ppmToPercentage(swapFeePPM) } }; } /** * Returns the fee to be paid to watchtowers on the destination chain to automatically * process and settle this swap without requiring any user interaction * * @internal */ protected getWatchtowerFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> { if(this.pricingInfo==null) throw new Error("No pricing info known, cannot estimate fee!"); const totalFeeShare = this.callerFeeShare + this.frontingFeeShare; const outputToken = this.wrapper._tokens[this.outputSwapToken]; const watchtowerFeeInOutputToken = this.getInputGasAmountWithoutFee() * totalFeeShare * (10n ** BigInt(outputToken.decimals)) * 1_000_000n / this.pricingInfo.swapPriceUSatPerToken / 100_000n; const feeBtc = this.getInputAmountWithoutFee() * (totalFeeShare + this.executionFeeShare) / 100_000n; const amountInSrcToken = toTokenAmount(feeBtc, BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); return { amountInSrcToken, amountInDstToken: toTokenAmount( (this.outputTotalSwap * (totalFeeShare + this.executionFeeShare) / 100_000n) + watchtowerFeeInOutputToken, outputToken, this.wrapper._prices, this.pricingInfo ), currentUsdValue: amountInSrcToken.currentUsdValue, usdValue: amountInSrcToken.usdValue, pastUsdValue: amountInSrcToken.pastUsdValue }; } /** * @inheritDoc */ getFee(): Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>> { const swapFee = this.getSwapFee(); const watchtowerFee = this.getWatchtowerFee(); const amountInSrcToken = toTokenAmount( swapFee.amountInSrcToken.rawAmount + watchtowerFee.amountInSrcToken.rawAmount, BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo ); return { amountInSrcToken, amountInDstToken: toTokenAmount( swapFee.amountInDstToken.rawAmount + watchtowerFee.amountInDstToken.rawAmount, this.wrapper._tokens[this.outputSwapToken], this.wrapper._prices, this.pricingInfo ), currentUsdValue: amountInSrcToken.currentUsdValue, usdValue: amountInSrcToken.usdValue, pastUsdValue: amountInSrcToken.pastUsdValue }; } /** * @inheritDoc */ getFeeBreakdown(): [ {type: FeeType.SWAP, fee: Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>>}, {type: FeeType.NETWORK_OUTPUT, fee: Fee<T["ChainId"], BtcToken<false>, SCToken<T["ChainId"]>>} ] { return [ { type: FeeType.SWAP, fee: this.getSwapFee() }, { type: FeeType.NETWORK_OUTPUT, fee: this.getWatchtowerFee() } ]; } /** * @inheritDoc */ getOutputToken(): SCToken<T["ChainId"]> { return this.wrapper._tokens[this.outputSwapToken]; } /** * @inheritDoc */ getOutput(): TokenAmount<SCToken<T["ChainId"]>, true> { return toTokenAmount(this.outputTotalSwap, this.wrapper._tokens[this.outputSwapToken], this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ getGasDropOutput(): TokenAmount<SCToken<T["ChainId"]>, true> { return toTokenAmount(this.outputTotalGas, this.wrapper._tokens[this.outputGasToken], this.wrapper._prices, this.gasPricingInfo); } /** * @inheritDoc */ getInputWithoutFee(): TokenAmount<BtcToken<false>, true> { return toTokenAmount(this.getInputAmountWithoutFee(), BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ getInputToken(): BtcToken<false> { return BitcoinTokens.BTC; } /** * @inheritDoc */ getInput(): TokenAmount<BtcToken<false>, true> { return toTokenAmount(this.btcAmount, BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); } ////////////////////////////// //// Bitcoin tx /** * @inheritDoc */ getRequiredConfirmationsCount(): number { return this.vaultRequiredConfirmations; } /** * Returns raw transaction details that can be used to manually create a swap PSBT. It is better to use * the {@link getPsbt} or {@link getFundedPsbt} function retrieve an already prepared PSBT. */ async getTransactionDetails(): Promise<{ in0txid: string, in0vout: number, in0sequence: number, vaultAmount: bigint, vaultScript: Uint8Array, in1sequence: number, out1script: Uint8Array, out2amount: bigint, out2script: Uint8Array, locktime: number }> { const [txId, voutStr] = this.vaultUtxo.split(":"); const vaultScript = toOutputScript(this.wrapper._options.bitcoinNetwork, this.vaultBtcAddress); const out2script = toOutputScript(this.wrapper._options.bitcoinNetwork, this.btcDestinationAddress); const opReturnData = this._contract.toOpReturnData( this.recipient, [ this.outputTotalSwap / this.vaultTokenMultipliers[0], this.outputTotalGas / this.vaultTokenMultipliers[1] ] ); const out1script = Buffer.concat([ opReturnData.length > 75 ? Buffer.from([0x6a, 0x4c, opReturnData.length]) : Buffer.from([0x6a, opReturnData.length]), opReturnData ]); if(this.callerFeeShare<0n || this.callerFeeShare>0xFFFFFn) throw new Error("Caller fee out of bounds!"); if(this.frontingFeeShare<0n || this.frontingFeeShare>0xFFFFFn) throw new Error("Fronting fee out of bounds!"); if(this.executionFeeShare<0n || this.executionFeeShare>0xFFFFFn) throw new Error("Execution fee out of bounds!"); const nSequence0 = 0x80000000n | (this.callerFeeShare & 0xFFFFFn) | (this.frontingFeeShare & 0b1111_1111_1100_0000_0000n) << 10n; const nSequence1 = 0x80000000n | (this.executionFeeShare & 0xFFFFFn) | (this.frontingFeeShare & 0b0000_0000_0011_1111_1111n) << 20n; return { in0txid: txId, in0vout: parseInt(voutStr), in0sequence: Number(nSequence0), vaultAmount: this.vaultUtxoValue, vaultScript, in1sequence: Number(nSequence1), out1script, out2amount: this.btcAmount, out2script, locktime: 500_000_000 + Math.floor(Math.random() * 1_000_000_000) //Use this as a random salt to make the btc txId unique! }; } /** * Returns the raw PSBT (not funded), the wallet should fund the PSBT (add its inputs) and importantly **set the nSequence field of the * 2nd input** (input 1 - indexing from 0) to the value returned in `in1sequence`, sign the PSBT and then pass * it back to the swap with {@link submitPsbt} function. The transaction should use at least the returned `feeRate` * sats/vB as the transaction fee. */ async getPsbt(): Promise<{ psbt: Transaction, psbtHex: string, psbtBase64: string, in1sequence: number, feeRate: number }> { const res = await this.getTransactionDetails(); const psbt = new Transaction({ allowUnknownOutputs: true, allowLegacyWitnessUtxo: true, lockTime: res.locktime }); psbt.addInput({ txid: res.in0txid, index: res.in0vout, witnessUtxo: { amount: res.vaultAmount, script: res.vaultScript }, sequence: res.in0sequence }); psbt.addOutput({ amount: res.vaultAmount, script: res.vaultScript }); psbt.addOutput({ amount: 0n, script: res.out1script }); psbt.addOutput({ amount: res.out2amount, script: res.out2script }); const serializedPsbt = Buffer.from(psbt.toPSBT()); return { psbt, psbtHex: serializedPsbt.toString("hex"), psbtBase64: serializedPsbt.toString("base64"), in1sequence: res.in1sequence, feeRate: this.minimumBtcFeeRate }; } /** * Returns the PSBT that is already funded with wallet's UTXOs (runs a coin-selection algorithm to choose UTXOs to use), * also returns inputs indices that need to be signed by the wallet before submitting the PSBT back to the SDK with * {@link submitPsbt} * * @remarks * Note that when passing the `feeRate` argument, the fee must be at least {@link minimumBtcFeeRate} sats/vB. * * @param _bitcoinWallet Sender's bitcoin wallet * @param feeRate Optional fee rate in sats/vB for the transaction * @param additionalOutputs additional outputs to add to the PSBT - can be used to collect fees from users * @param utxos Pre-fetched list of UTXOs to spend from * @param spendFully Instructs the wallet to spend all the passed UTXOs in the transaction without creating any * change output, if the `feeRate` is passed, it will also enforce that the feeRate in sats/vB for the resulting * transaction is not more than 50% and 10 sats/vB larger (considering also the CPFP adjustments) */ async getFundedPsbt( _bitcoinWallet: IBitcoinWallet | MinimalBitcoinWalletInterface, feeRate?: number, additionalOutputs?: ({amount: bigint, outputScript: Uint8Array} | {amount: bigint, address: string})[], utxos?: BitcoinWalletUtxo[], spendFully?: boolean ): Promise<{ psbt: Transaction, psbtHex: string, psbtBase64: string, signInputs: number[], feeRate: number }> { const bitcoinWallet: IBitcoinWallet = toBitcoinWallet(_bitcoinWallet, this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork); if(feeRate!=null) { if(feeRate<this.minimumBtcFeeRate) throw new Error("Bitcoin tx fee needs to be at least "+this.minimumBtcFeeRate+" sats/vB"); } else { feeRate = Math.max(this.minimumBtcFeeRate, await bitcoinWallet.getFeeRate()); } let {psbt, in1sequence} = await this.getPsbt(); if(additionalOutputs!=null) additionalOutputs.forEach(output => { psbt.addOutput({ amount: output.amount, script: (output as {outputScript: Uint8Array}).outputScript ?? toOutputScript(this.wrapper._options.bitcoinNetwork, (output as {address: string}).address) }); }); psbt = await bitcoinWallet.fundPsbt(psbt, feeRate, utxos, spendFully); psbt.updateInput(1, {sequence: in1sequence}); //Sign every input except the first one const signInputs: number[] = []; for(let i=1;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 */ async submitPsbt(_psbt: Transaction | string): Promise<string> { const psbt = parsePsbtTransaction(_psbt); //Ensure not expired if(this.expiry<Date.now()) { throw new Error("Quote expired!"); } //Ensure valid state if(this._state!==SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED && this._state!==SpvFromBTCSwapState.CREATED) { throw new Error("Invalid swap state!"); } if(this.url==null) throw new Error("LP URL not known, cannot submit PSBT!"); //Ensure all inputs except the 1st are finalized for(let i=1;i<psbt.inputsLength;i++) { if(getInputType(psbt.getInput(i)).txType==="legacy") throw new Error("Legacy (non-segwit) inputs are not allowed in the transaction!"); psbt.finalizeIdx(i); } const btcTx = await this.wrapper._btcRpc.parseTransaction(Buffer.from(psbt.toBytes(true)).toString("hex")); const data = await this._contract.getWithdrawalData(btcTx); this.logger.debug("submitPsbt(): parsed withdrawal data: ", data); //Verify correct withdrawal data if( !data.isRecipient(this.recipient) || data.rawAmounts[0]*this.vaultTokenMultipliers[0] !== this.outputTotalSwap || (data.rawAmounts[1] ?? 0n)*this.vaultTokenMultipliers[1] !== this.outputTotalGas || data.callerFeeRate!==this.callerFeeShare || data.frontingFeeRate!==this.frontingFeeShare || data.executionFeeRate!==this.executionFeeShare || data.getSpentVaultUtxo()!==this.vaultUtxo || BigInt(data.getNewVaultBtcAmount())!==this.vaultUtxoValue || !data.getNewVaultScript().equals(toOutputScript(this.wrapper._options.bitcoinNetwork, this.vaultBtcAddress)) || data.getExecutionData()!=null ) { throw new Error("Invalid withdrawal tx data submitted!"); } //Verify correct LP output const lpOutput = psbt.getOutput(2); if( lpOutput.script==null || lpOutput.amount!==this.btcAmount || !toOutputScript(this.wrapper._options.bitcoinNetwork, this.btcDestinationAddress).equals(Buffer.from(lpOutput.script)) ) { throw new Error("Invalid LP bitcoin output in transaction!"); } //Verify vault utxo not spent yet if(await this.wrapper._btcRpc.isSpent(this.vaultUtxo)) { throw new Error("Vault UTXO already spent, please create new swap!"); } //Verify tx is parsable by the contract try { await this._contract.checkWithdrawalTx(data); } catch (e: any) { throw new Error("Transaction not parsable by the contract: "+(e.message ?? e.toString())); } //Ensure still not expired if(this.expiry<Date.now()) { throw new Error("Quote expired!"); } this._data = data; this.initiated = true; this.posted = true; await this._saveAndEmit(SpvFromBTCSwapState.SIGNED); try { await this.wrapper._lpApi.initSpvFromBTC( this.chainIdentifier, this.url, { quoteId: this.quoteId, psbtHex: Buffer.from(psbt.toPSBT(0)).toString("hex") } ); await this._saveAndEmit(SpvFromBTCSwapState.POSTED); } catch (e) { await this._saveAndEmit(SpvFromBTCSwapState.DECLINED); throw e; } return this._data.getTxId(); } /** * @inheritDoc */ async estimateBitcoinFee(_bitcoinWallet: IBitcoinWallet | MinimalBitcoinWalletInterface, feeRate?: number): Promise<TokenAmount<BtcToken<false>, true> | null> { const bitcoinWallet: IBitcoinWallet = toBitcoinWallet(_bitcoinWallet, this.wrapper._btcRpc, this.wrapper._options.bitcoinNetwork); const txFee = await bitcoinWallet.getFundedPsbtFee((await this.getPsbt()).psbt, feeRate); if(txFee==null) return null; return toTokenAmount(BigInt(txFee), BitcoinTokens.BTC, this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ async sendBitcoinTransaction( wallet: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner, feeRate?: number, utxos?: BitcoinWalletUtxo[], spendFully?: boolean ): Promise<string> { const {psbt, psbtBase64, psbtHex, signInputs} = await this.getFundedPsbt(wallet, feeRate, undefined, utxos, spendFully); let signedPsbt: Transaction | string; if(isIBitcoinWallet(wallet)) { signedPsbt = await wallet.signPsbt(psbt, signInputs); } else { signedPsbt = await wallet.signPsbt({ psbt, psbtHex, psbtBase64 }, signInputs); } return await this.submitPsbt(signedPsbt); } /** * Executes the swap with the provided bitcoin wallet * * @param wallet Bitcoin wallet to use to sign the bitcoin transaction * @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 the {@link claim} function to settle the swap on the * destination manually */ async execute( wallet: IBitcoinWallet | MinimalBitcoinWalletInterfaceWithSigner, callbacks?: { 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, utxos?: BitcoinWalletUtxo[], spendFully?: boolean } ): Promise<boolean> { if(this._state===SpvFromBTCSwapState.CLOSED) throw new Error("Swap encountered a catastrophic failure!"); if(this._state===SpvFromBTCSwapState.FAILED) throw new Error("Swap failed!"); if(this._state===SpvFromBTCSwapState.DECLINED) throw new Error("Swap execution already declined by the LP!"); if(this._state===SpvFromBTCSwapState.QUOTE_EXPIRED) throw new Error("Swap quote expired!"); if(this._state===SpvFromBTCSwapState.CLAIMED || this._state===SpvFromBTCSwapState.FRONTED) throw new Error("Swap already settled or fronted!"); if(this._state===SpvFromBTCSwapState.CREATED) { const txId = await this.sendBitcoinTransaction(wallet, options?.feeRate, options?.utxos, options?.spendFully); if(callbacks?.onSourceTransactionSent!=null) callbacks.onSourceTransactionSent(txId); } if(this._state===SpvFromBTCSwapState.POSTED || this._state===SpvFromBTCSwapState.BROADCASTED) { const txId = await this.waitForBitcoinTransaction(callbacks?.onSourceTransactionConfirmationStatus, options?.btcTxCheckIntervalSeconds, options?.abortSignal); if (callbacks?.onSourceTransactionConfirmed != null) callbacks.onSourceTransactionConfirmed(txId); } // @ts-ignore if(this._state===SpvFromBTCSwapState.CLAIMED || this._state===SpvFromBTCSwapState.FRONTED) return true; if(this._state===SpvFromBTCSwapState.BTC_TX_CONFIRMED) { const success = await this.waitTillClaimedOrFronted(options?.maxWaitTillAutomaticSettlementSeconds ?? 60, options?.abortSignal); if(success && callbacks?.onSwapSettled!=null) callbacks.onSwapSettled(this.getOutputTxId()!); return success; } throw new Error("Unexpected state reached!"); } /** * @internal */ protected async _getExecutionStatus(options?: { bitcoinFeeRate?: number, bitcoinWallet?: MinimalBitcoinWalletInterface, manualSettlementSmartChainSigner?: string | T["Signer"] | T["NativeSigner"], maxWaitTillAutomaticSettlementSeconds?: number }) { const state = this._state; const now = Date.now(); let confirmations: { current: number, target: number, etaSeconds: number } | undefined; let bitcoinPaymentStatus: SwapExecutionStepPayment<"BITCOIN">["status"] = "inactive"; let destinationSettlementStatus: SwapExecutionStepSettlement<T["ChainId"], "awaiting_automatic" | "awaiting_manual">["status"] = "inactive"; let buildCurrentAction: (actionOptions?: { bitcoinFeeRate?: number, bitcoinWallet?: MinimalBitcoinWalletInterface, manualSettlementSmartChainSigner?: string | T["Signer"] | T["NativeSigner"], maxWaitTillAutomaticSettlementSeconds?: number }) => Promise< SwapExecutionActionSignPSBT | SwapExecutionActionWait<"BITCOIN_CONFS" | "SETTLEMENT"> | SwapExecutionActionSignSmartChainTx<T> | undefined > = async () => undefined; switch(state) { case SpvFromBTCSwapState.QUOTE_EXPIRED: case SpvFromBTCSwapState.DECLINED: case SpvFromBTCSwapState.FAILED: bitcoinPaymentStatus = "expired"; break; case SpvFromBTCSwapState.CREATED: { const quoteValid = await this._verifyQuoteValid(); bitcoinPaymentStatus = quoteValid ? "awaiting" : "soft_expired"; if(quoteValid) { buildCurrentAction = this._buildDepositPsbtAction.bind(this); } break; } case SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED: case SpvFromBTCSwapState.SIGNED: case SpvFromBTCSwapState.POSTED: case SpvFromBTCSwapState.BROADCASTED: case SpvFromBTCSwapState.FRONTED: { const bitcoinPayment = await this.getBitcoinPayment(); let bitcoinConfirmationDelay = -1; let knownBitcoinPaymentStatus: "received" | "confirmed" | undefined; if(bitcoinPayment!=null) { if(bitcoinPayment.confirmations >= bitcoinPayment.targetConfirmations) { knownBitcoinPaymentStatus = "confirmed"; } else { const result = await this.wrapper._btcRpc.getConfirmationDelay( bitcoinPayment.btcTx, bitcoinPayment.targetConfirmations ); confirmations = { current: bitcoinPayment.confirmations, target: bitcoinPayment.targetConfirmations, etaSeconds: result ?? -1 }; knownBitcoinPaymentStatus = "received"; bitcoinConfirmationDelay = result ?? -1; } } if(state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED) bitcoinPaymentStatus = knownBitcoinPaymentStatus ?? "soft_expired"; if(state===SpvFromBTCSwapState.POSTED || state===SpvFromBTCSwapState.SIGNED) bitcoinPaymentStatus = knownBitcoinPaymentStatus ?? "awaiting"; if(state===SpvFromBTCSwapState.BROADCASTED) bitcoinPaymentStatus = knownBitcoinPaymentStatus ?? "received"; destinationSettlementStatus = "inactive"; if(state===SpvFromBTCSwapState.FRONTED) { bitcoinPaymentStatus = knownBitcoinPaymentStatus ?? "received"; destinationSettlementStatus = "settled"; } if( state===SpvFromBTCSwapState.SIGNED || state===SpvFromBTCSwapState.POSTED || state===SpvFromBTCSwapState.BROADCASTED ) { buildCurrentAction = this._buildWaitBitcoinConfirmationsAction.bind(this, bitcoinConfirmationDelay); } break; } case SpvFromBTCSwapState.BTC_TX_CONFIRMED: bitcoinPaymentStatus = "confirmed"; if( this.btcTxConfirmedAt==null || options?.maxWaitTillAutomaticSett