UNPKG

@atomiqlabs/sdk

Version:

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

989 lines (912 loc) 61.4 kB
import {ISwapWrapper, ISwapWrapperOptions, SwapTypeDefinition, WrapperCtorTokens} from "../ISwapWrapper"; import { BitcoinRpcWithAddressIndex, BtcBlock, BtcRelay, ChainEvent, ChainType, RelaySynchronizer, SpvVaultClaimEvent, SpvVaultCloseEvent, SpvVaultData, SpvVaultFrontEvent, SpvVaultTokenBalance, SpvWithdrawalClaimedState, SpvWithdrawalFrontedState, SpvWithdrawalStateType } from "@atomiqlabs/base"; import {SpvFromBTCSwap, SpvFromBTCSwapInit, SpvFromBTCSwapState} from "./SpvFromBTCSwap"; import {BTC_NETWORK, TEST_NETWORK} from "@scure/btc-signer/utils"; import {SwapType} from "../../enums/SwapType"; import {UnifiedSwapStorage} from "../../storage/UnifiedSwapStorage"; import {UnifiedSwapEventListener} from "../../events/UnifiedSwapEventListener"; import {ISwapPrice} from "../../prices/abstract/ISwapPrice"; import {EventEmitter} from "events"; import {Intermediary} from "../../intermediaries/Intermediary"; import {extendAbortController, mapArrayToObject, randomBytes, throwIfUndefined} from "../../utils/Utils"; import { fromOutputScript, getDummyOutputScript, toCoinselectAddressType, toOutputScript } from "../../utils/BitcoinUtils"; import {IntermediaryAPI, SpvFromBTCPrepareResponseType} from "../../intermediaries/apis/IntermediaryAPI"; import {OutOfBoundsError, RequestError} from "../../errors/RequestError"; import {IntermediaryError} from "../../errors/IntermediaryError"; import {CoinselectAddressTypes} from "../../bitcoin/coinselect2"; import {OutScript, Transaction} from "@scure/btc-signer"; import {ISwap} from "../ISwap"; import {IClaimableSwapWrapper} from "../IClaimableSwapWrapper"; import {AmountData} from "../../types/AmountData"; import {tryWithRetries} from "../../utils/RetryUtils"; import {AllOptional} from "../../utils/TypeUtils"; import {UserError} from "../../errors/UserError"; import {BitcoinWalletUtxo, BitcoinWalletUtxoBase, IBitcoinWallet} from "../../bitcoin/wallet/IBitcoinWallet"; import {utils} from "../../bitcoin/coinselect2/utils"; import {BitcoinWallet} from "../../bitcoin/wallet/BitcoinWallet"; export type SpvFromBTCOptions = { /** * Optional additional native token to receive as an output of the swap (e.g. STRK on Starknet or cBTC on Citrea). * * When passed as a `bigint` it is specified in base units of the token and in `string` it is the human readable * decimal format. */ gasAmount?: bigint | string, /** * The LP enforces a minimum bitcoin fee rate in sats/vB for the swap transaction. With this config you can optionally * limit how high of a minimum fee rate would you accept. * * By default the maximum allowed fee rate is calculated dynamically based on current bitcoin fee rate as: * * `maxAllowedBitcoinFeeRate` = 10 + `currentBitcoinFeeRate` * 1.5 */ maxAllowedBitcoinFeeRate?: number, /** * A flag to attach 0 watchtower fee to the swap, this would make the settlement unattractive for the watchtowers * and therefore automatic settlement for such swaps will not be possible, you will have to settle manually * with {@link FromBTCLNSwap.claim} or {@link FromBTCLNSwap.txsClaim} functions. */ unsafeZeroWatchtowerFee?: boolean, /** * A safety factor to use when estimating the watchtower fee to attach to the swap (this has to cover the gas fee * of watchtowers settling the swap). A higher multiple here would mean that a swap is more attractive for * watchtowers to settle automatically. * * Uses a `1.25` multiple by default (i.e. the current network fee is multiplied by 1.25 and then used to estimate * the settlement gas fee cost) */ feeSafetyFactor?: number, /** * Instruct the LP to create a "sticky address" for your destination wallet address. After the first successful * swap with that LP, the used bitcoin address will be permanently linked to your destination wallet address. So * all subsequent swaps to the same address will yield the same LP deposit bitcoin address. Useful for corporate * whitelist-only wallets */ stickyAddress?: boolean, /** * A bitcoin wallet UTXOs to fully use as an input for this swap, use this option along with passing `amount` as * `undefined` when you want to swap the full BTC balance of the wallet in a single swap */ sourceWalletUtxos?: BitcoinWalletUtxoBase[] | Promise<BitcoinWalletUtxoBase[]>, /** * Bitcoin fee rate to use when deriving `maxAllowedBitcoinFeeRate` and when calculating the input amount based * on the `sourceWalletUtxos` */ bitcoinFeeRate?: Promise<number> | number, /** * @deprecated Use `maxAllowedBitcoinFeeRate` instead! */ maxAllowedNetworkFeeRate?: number, }; export type SpvFromBTCWrapperOptions = ISwapWrapperOptions & { maxConfirmations: number, bitcoinNetwork: BTC_NETWORK, bitcoinBlocktime: number, maxTransactionsDelta: number, //Maximum accepted difference in state between SC state and bitcoin state, in terms of by how many transactions are they differing maxRawAmountAdjustmentDifferencePPM: number, maxBtcFeeMultiplier: number, maxBtcFeeOffset: number }; export type SpvFromBTCTypeDefinition<T extends ChainType> = SwapTypeDefinition<T, SpvFromBTCWrapper<T>, SpvFromBTCSwap<T>>; export const REQUIRED_SPV_SWAP_VAULT_ADDRESS_TYPE: CoinselectAddressTypes = "p2tr"; export const REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE: CoinselectAddressTypes = "p2wpkh"; /** * 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 SpvFromBTCWrapper< T extends ChainType > extends ISwapWrapper<T, SpvFromBTCTypeDefinition<T>, SpvFromBTCWrapperOptions> implements IClaimableSwapWrapper<SpvFromBTCSwap<T>> { public readonly TYPE: SwapType.SPV_VAULT_FROM_BTC = SwapType.SPV_VAULT_FROM_BTC; /** * @internal */ readonly _claimableSwapStates = [SpvFromBTCSwapState.BTC_TX_CONFIRMED]; /** * @internal */ readonly _swapDeserializer = SpvFromBTCSwap; /** * @internal */ protected readonly btcRelay: (version?: string) => BtcRelay<any, T["TX"], any> = (version?: string) => { const _version = version ?? "v1"; const data = this.versionedContracts[_version]; if(data==null) throw new Error(`Invalid contract version ${_version} requested`); return data.btcRelay; }; /** * @internal */ protected readonly tickSwapState: Array<SpvFromBTCSwap<T>["_state"]> = [ SpvFromBTCSwapState.CREATED, SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED, SpvFromBTCSwapState.SIGNED, SpvFromBTCSwapState.POSTED, SpvFromBTCSwapState.BROADCASTED ]; /** * @internal */ readonly _synchronizer: (version?: string) => RelaySynchronizer<any, T["TX"], any> = (version?: string) => { const _version = version ?? "v1"; const data = this.versionedSynchronizer[_version]; if(data==null) throw new Error(`Invalid contract version ${_version} requested`); return data.synchronizer; }; /** * @internal */ readonly _contract: (version?: string) => T["SpvVaultContract"] = (version?: string) => { const _version = version ?? "v1"; const data = this.versionedContracts[_version]; if(data==null) throw new Error(`Invalid contract version ${_version} requested`); return data.spvVaultContract; }; /** * @internal */ readonly _btcRpc: BitcoinRpcWithAddressIndex<BtcBlock>; /** * @internal */ readonly _spvWithdrawalDataDeserializer: (version?: string) => (new (data: any) => T["SpvVaultWithdrawalData"]) = (version?: string) => { const _version = version ?? "v1"; const data = this.versionedContracts[_version]; if(data==null) throw new Error(`Invalid contract version ${_version} requested`); return data.spvVaultWithdrawalDataConstructor; }; /** * @internal */ readonly _pendingSwapStates: Array<SpvFromBTCSwap<T>["_state"]> = [ SpvFromBTCSwapState.CREATED, SpvFromBTCSwapState.SIGNED, SpvFromBTCSwapState.POSTED, SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED, SpvFromBTCSwapState.BROADCASTED, SpvFromBTCSwapState.DECLINED, SpvFromBTCSwapState.BTC_TX_CONFIRMED ]; private readonly versionedContracts: { [version: string]: { btcRelay: BtcRelay<any, T["TX"], any>, spvVaultContract: T["SpvVaultContract"], spvVaultWithdrawalDataConstructor: new (data: any) => T["SpvVaultWithdrawalData"] } } = {}; private readonly versionedSynchronizer: { [version: string]: { synchronizer: RelaySynchronizer<any, T["TX"], any> } } = {}; /** * @param chainIdentifier * @param unifiedStorage Storage interface for the current environment * @param unifiedChainEvents On-chain event listener * @param chain * @param prices Pricing to use * @param tokens * @param versionedContracts * @param versionedSynchronizer * @param btcRpc Bitcoin RPC which also supports getting transactions by txoHash * @param lpApi * @param options * @param events Instance to use for emitting events */ constructor( chainIdentifier: string, unifiedStorage: UnifiedSwapStorage<T>, unifiedChainEvents: UnifiedSwapEventListener<T>, chain: T["ChainInterface"], prices: ISwapPrice, tokens: WrapperCtorTokens, versionedContracts: { [version: string]: { btcRelay: BtcRelay<any, T["TX"], any>, spvVaultContract: T["SpvVaultContract"], spvVaultWithdrawalDataConstructor: new (data: any) => T["SpvVaultWithdrawalData"] } }, versionedSynchronizer: { [version: string]: { synchronizer: RelaySynchronizer<any, T["TX"], any> } }, btcRpc: BitcoinRpcWithAddressIndex<any>, lpApi: IntermediaryAPI, options?: AllOptional<SpvFromBTCWrapperOptions>, events?: EventEmitter<{swapState: [ISwap]}> ) { super( chainIdentifier, unifiedStorage, unifiedChainEvents, chain, prices, tokens, lpApi, { ...options, bitcoinNetwork: options?.bitcoinNetwork ?? TEST_NETWORK, maxConfirmations: options?.maxConfirmations ?? 6, bitcoinBlocktime: options?.bitcoinBlocktime ?? 10*60, maxTransactionsDelta: options?.maxTransactionsDelta ?? 3, maxRawAmountAdjustmentDifferencePPM: options?.maxRawAmountAdjustmentDifferencePPM ?? 100, maxBtcFeeOffset: options?.maxBtcFeeOffset ?? 10, maxBtcFeeMultiplier: options?.maxBtcFeeMultiplier ?? 1.5 }, events ); this.versionedContracts = versionedContracts; this.versionedSynchronizer = versionedSynchronizer; this._btcRpc = btcRpc; } private async processEventFront(event: SpvVaultFrontEvent, swap: SpvFromBTCSwap<T>): Promise<boolean> { if( swap._state===SpvFromBTCSwapState.SIGNED || swap._state===SpvFromBTCSwapState.POSTED || swap._state===SpvFromBTCSwapState.BROADCASTED || swap._state===SpvFromBTCSwapState.DECLINED || swap._state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED || swap._state===SpvFromBTCSwapState.BTC_TX_CONFIRMED ) { swap._state = SpvFromBTCSwapState.FRONTED; await swap._setBitcoinTxId(event.btcTxId).catch(e => { this.logger.warn("processEventFront(): Failed to set bitcoin txId: ", e); }); return true; } return false; } private async processEventClaim(event: SpvVaultClaimEvent, swap: SpvFromBTCSwap<T>): Promise<boolean> { if( swap._state===SpvFromBTCSwapState.SIGNED || swap._state===SpvFromBTCSwapState.POSTED || swap._state===SpvFromBTCSwapState.BROADCASTED || swap._state===SpvFromBTCSwapState.DECLINED || swap._state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED || swap._state===SpvFromBTCSwapState.FRONTED || swap._state===SpvFromBTCSwapState.BTC_TX_CONFIRMED ) { swap._state = SpvFromBTCSwapState.CLAIMED; await swap._setBitcoinTxId(event.btcTxId).catch(e => { this.logger.warn("processEventClaim(): Failed to set bitcoin txId: ", e); }); return true; } return false; } private processEventClose(event: SpvVaultCloseEvent, swap: SpvFromBTCSwap<T>): Promise<boolean> { if( swap._state===SpvFromBTCSwapState.SIGNED || swap._state===SpvFromBTCSwapState.POSTED || swap._state===SpvFromBTCSwapState.BROADCASTED || swap._state===SpvFromBTCSwapState.DECLINED || swap._state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED || swap._state===SpvFromBTCSwapState.BTC_TX_CONFIRMED ) { swap._state = SpvFromBTCSwapState.CLOSED; return Promise.resolve(true); } return Promise.resolve(false); } /** * @inheritDoc * @internal */ protected async processEvent(event: ChainEvent<T["Data"]>, swap: SpvFromBTCSwap<T>): Promise<void> { if(swap==null) return; let swapChanged: boolean = false; if(event instanceof SpvVaultFrontEvent) { swapChanged = await this.processEventFront(event, swap); if(event.meta?.txId!=null && swap._frontTxId!==event.meta.txId) { swap._frontTxId = event.meta.txId; swapChanged ||= true; } } if(event instanceof SpvVaultClaimEvent) { swapChanged = await this.processEventClaim(event, swap); if(event.meta?.txId!=null && swap._claimTxId!==event.meta.txId) { swap._claimTxId = event.meta.txId; swapChanged ||= true; } } if(event instanceof SpvVaultCloseEvent) { swapChanged = await this.processEventClose(event, swap); } this.logger.info("processEvents(): "+event.constructor.name+" processed for "+swap.getId()+" swap: ", swap); if(swapChanged) { await swap._saveAndEmit(); } } /** * Pre-fetches latest finalized block height of the smart chain * * @param abortController * @private */ private async preFetchFinalizedBlockHeight(abortController: AbortController): Promise<number | undefined> { try { const block = await this._chain.getFinalizedBlock(); return block.height; } catch (e) { abortController.abort(e); } } /** * Pre-fetches caller (watchtower) bounty data for the swap. Doesn't throw, instead returns null and aborts the * provided abortController * * @param amountData * @param options Options as passed to the swap creation function * @param abortController * @param contractVersion * @private */ private async preFetchCallerFeeInNativeToken( amountData: {amount?: bigint}, options: { unsafeZeroWatchtowerFee: boolean, feeSafetyFactor: number }, abortController: AbortController, contractVersion: string ): Promise<bigint | undefined> { if(options.unsafeZeroWatchtowerFee) return 0n; if(amountData.amount===0n) return 0n; try { const [ feePerBlock, btcRelayData, currentBtcBlock, claimFeeRate ] = await Promise.all([ this.btcRelay(contractVersion).getFeePerBlock(), this.btcRelay(contractVersion).getTipData(), this._btcRpc.getTipHeight(), this._contract(contractVersion).getClaimFee(this._chain.randomAddress()) ]); if(btcRelayData==null) throw new Error("Btc relay doesn't seem to be initialized!"); const currentBtcRelayBlock = btcRelayData.blockheight; const blockDelta = Math.max(currentBtcBlock-currentBtcRelayBlock+this._options.maxConfirmations, 0); const totalFeeInNativeToken = ( (BigInt(blockDelta) * feePerBlock) + (claimFeeRate * BigInt(this._options.maxTransactionsDelta)) ) * BigInt(Math.floor(options.feeSafetyFactor*1000000)) / 1_000_000n; return totalFeeInNativeToken; } catch (e) { abortController.abort(e); } } /** * Pre-fetches caller (watchtower) bounty data for the swap. Doesn't throw, instead returns null and aborts the * provided abortController * * @param amountPrefetch * @param totalFeeInNativeTokenPrefetch * @param amountData * @param options Options as passed to the swap creation function * @param pricePrefetch * @param nativeTokenPricePrefetch * @param abortSignal * @private */ private async computeCallerFeeShare( amountPrefetch: Promise<bigint | undefined>, totalFeeInNativeTokenPrefetch: Promise<bigint | undefined>, amountData: {exactIn: boolean, token: string}, options: {unsafeZeroWatchtowerFee: boolean}, pricePrefetch: Promise<bigint | undefined>, nativeTokenPricePrefetch: Promise<bigint | undefined> | undefined, abortSignal?: AbortSignal ): Promise<bigint> { if(options.unsafeZeroWatchtowerFee) return 0n; const amount = await throwIfUndefined(amountPrefetch, "Cannot get swap amount!"); if(amount===0n) return 0n; const totalFeeInNativeToken = await throwIfUndefined(totalFeeInNativeTokenPrefetch, "Cannot get total fee in native token!"); const nativeTokenPrice = await nativeTokenPricePrefetch; let payoutAmount: bigint; if(amountData.exactIn) { //Convert input amount in BTC to const amountInNativeToken = await this._prices.getFromBtcSwapAmount(this.chainIdentifier, amount, this._chain.getNativeCurrencyAddress(), abortSignal, nativeTokenPrice); payoutAmount = amountInNativeToken - totalFeeInNativeToken; } else { if(amountData.token===this._chain.getNativeCurrencyAddress()) { //Both amounts in same currency payoutAmount = amount; } else { //Need to convert both to native currency const btcAmount = await this._prices.getToBtcSwapAmount(this.chainIdentifier, amount, amountData.token, abortSignal, await pricePrefetch); payoutAmount = await this._prices.getFromBtcSwapAmount(this.chainIdentifier, btcAmount, this._chain.getNativeCurrencyAddress(), abortSignal, nativeTokenPrice); } } this.logger.debug("computeCallerFeeShare(): Caller fee in native token: "+totalFeeInNativeToken.toString(10)+" total payout in native token: "+payoutAmount.toString(10)); const callerFeeShare = ((totalFeeInNativeToken * 100_000n) + payoutAmount - 1n) / payoutAmount; //Make sure to round up here if(callerFeeShare < 0n) return 0n; if(callerFeeShare >= 2n**20n) return 2n**20n - 1n; return callerFeeShare; } /** * Verifies response returned from intermediary * * @param resp Response as returned by the intermediary * @param amountData * @param lp Intermediary * @param options Options as passed to the swap creation function * @param callerFeeShare * @param maxBitcoinFeeRatePromise Maximum accepted fee rate from the LPs * @param bitcoinFeeRatePromise * @param abortSignal * @private * @throws {IntermediaryError} in case the response is invalid */ private async verifyReturnedData( resp: SpvFromBTCPrepareResponseType, amountData: AmountData, lp: Intermediary, options: { gasAmount: bigint, sourceWalletUtxos?: Promise<BitcoinWalletUtxoBase[]> }, callerFeeShare: bigint, maxBitcoinFeeRatePromise: Promise<number | undefined>, bitcoinFeeRatePromise: Promise<number | undefined> | undefined, abortSignal: AbortSignal ): Promise<{ vault: T["SpvVaultData"], vaultUtxoValue: number }> { const btcFeeRate = await throwIfUndefined(maxBitcoinFeeRatePromise, "Bitcoin fee rate promise failed!"); abortSignal.throwIfAborted(); if(btcFeeRate!=null && resp.btcFeeRate > btcFeeRate) throw new IntermediaryError(`Required bitcoin fee rate returned from the LP is too high! Maximum accepted: ${btcFeeRate} sats/vB, required by LP: ${resp.btcFeeRate} sats/vB`); const lpVersion = lp.getContractVersion(this.chainIdentifier); //Vault related let vaultScript: Uint8Array; let vaultAddressType: CoinselectAddressTypes; let btcAddressScript: Uint8Array; let btcAddressType: CoinselectAddressTypes; //Ensure valid btc addresses returned try { vaultScript = toOutputScript(this._options.bitcoinNetwork, resp.vaultBtcAddress); vaultAddressType = toCoinselectAddressType(vaultScript); btcAddressScript = toOutputScript(this._options.bitcoinNetwork, resp.btcAddress); btcAddressType = toCoinselectAddressType(btcAddressScript); } catch (e) { throw new IntermediaryError("Invalid btc address data returned", e); } const decodedUtxo = resp.btcUtxo.split(":"); if( resp.address!==lp.getAddress(this.chainIdentifier) || //Ensure the LP is indeed the vault owner resp.vaultId < 0n || //Ensure vaultId is not negative vaultScript==null || //Make sure vault script is parsable and of known type btcAddressScript==null || //Make sure btc address script is parsable and of known type btcAddressType!==REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE || //Constrain the btc address script type vaultAddressType!==REQUIRED_SPV_SWAP_VAULT_ADDRESS_TYPE || //Constrain the vault script type decodedUtxo.length!==2 || decodedUtxo[0].length!==64 || isNaN(parseInt(decodedUtxo[1])) || //Check valid UTXO resp.btcFeeRate < 1 || resp.btcFeeRate > 10000 //Sanity check on the returned BTC fee rate ) throw new IntermediaryError("Invalid vault data returned!"); //Amounts sanity if(resp.btcAmountSwap + resp.btcAmountGas !==resp.btcAmount) throw new Error("Btc amount mismatch"); if(resp.swapFeeBtc + resp.gasSwapFeeBtc !==resp.totalFeeBtc) throw new Error("Btc fee mismatch"); //TODO: For now ensure fees are at 0 if( resp.callerFeeShare!==callerFeeShare || resp.frontingFeeShare!==0n || resp.executionFeeShare!==0n ) throw new IntermediaryError("Invalid caller/fronting/execution fee returned"); //Check expiry const timeNowSeconds = Math.floor(Date.now()/1000); if(resp.expiry < timeNowSeconds) throw new IntermediaryError(`Quote already expired, expiry: ${resp.expiry}, systemTime: ${timeNowSeconds}, clockAdjusted: ${(Date as any)._now!=null}`); let utxo = resp.btcUtxo.toLowerCase(); const [txId, voutStr] = utxo.split(":"); const abortController = extendAbortController(abortSignal); let [vault, {vaultUtxoValue, btcTx}] = await Promise.all([ (async() => { //Fetch vault data let vault: T["SpvVaultData"] | null; try { vault = await this._contract(lpVersion).getVaultData(resp.address, resp.vaultId); } catch (e) { this.logger.error("Error getting spv vault (owner: "+resp.address+" vaultId: "+resp.vaultId.toString(10)+"): ", e); throw new IntermediaryError("Spv swap vault not found", e); } abortController.signal.throwIfAborted(); //Make sure vault is opened if(vault==null || !vault.isOpened()) throw new IntermediaryError("Returned spv swap vault is not opened!"); //Make sure the vault doesn't require insane amount of confirmations if(vault.getConfirmations()>this._options.maxConfirmations) throw new IntermediaryError("SPV swap vault needs too many confirmations: "+vault.getConfirmations()); const tokenData = vault.getTokenData(); //Amounts - make sure the amounts match if(amountData.exactIn) { if(!resp.usedUtxoInputCalculation) { //Legacy calculation if(resp.btcAmount !== amountData.amount) throw new IntermediaryError("Invalid amount returned"); } else { //Implies the raw UTXOs were passed for amount derivation //Verify the derivation was done correctly if(options.sourceWalletUtxos==null) throw new IntermediaryError("Invalid usedUtxoInputCalcuation return value"); if(bitcoinFeeRatePromise==null) throw new Error("bitcoinFeeRatePromise must be passed for UTXO-based input amount calculation checks"); const walletUtxos = await options.sourceWalletUtxos; const bitcoinFeeRate = await throwIfUndefined(bitcoinFeeRatePromise, "Failed to fetch bitcoin fee rate!"); const {balance} = BitcoinWallet.getSpendableBalance( walletUtxos, Math.max(resp.btcFeeRate, bitcoinFeeRate), this.getDummySwapPsbt(options.gasAmount!==0n), REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE ); if(resp.btcAmount !== balance) throw new IntermediaryError(`Invalid amount returned, expected: ${balance.toString(10)}, got: ${resp.btcAmount.toString(10)}`); } } else { //Check the difference between amount adjusted due to scaling to raw amount const adjustedAmount = amountData.amount / tokenData[0].multiplier * tokenData[0].multiplier; const adjustmentPPM = (amountData.amount - adjustedAmount)*1_000_000n / amountData.amount; if(adjustmentPPM > this._options.maxRawAmountAdjustmentDifferencePPM) throw new IntermediaryError("Invalid amount0 multiplier used, rawAmount diff too high"); if(resp.total !== adjustedAmount) throw new IntermediaryError("Invalid total returned"); } if(options.gasAmount===0n) { if(resp.totalGas !== 0n) throw new IntermediaryError("Invalid gas total returned"); } else { //Check the difference between amount adjusted due to scaling to raw amount const adjustedGasAmount = options.gasAmount / tokenData[0].multiplier * tokenData[0].multiplier; const adjustmentPPM = (options.gasAmount - adjustedGasAmount)*1_000_000n / options.gasAmount; if(adjustmentPPM > this._options.maxRawAmountAdjustmentDifferencePPM) throw new IntermediaryError("Invalid amount1 multiplier used, rawAmount diff too high"); if(resp.totalGas !== adjustedGasAmount) throw new IntermediaryError("Invalid gas total returned"); } return vault; })(), (async() => { //Require the vault UTXO to have at least 1 confirmation let btcTx = await this._btcRpc.getTransaction(txId); if(btcTx==null) throw new IntermediaryError("Invalid UTXO, doesn't exist (txId)"); abortController.signal.throwIfAborted(); if(btcTx.confirmations==null || btcTx.confirmations<1) throw new IntermediaryError("SPV vault UTXO not confirmed"); const vout = parseInt(voutStr); if(btcTx.outs[vout]==null) throw new IntermediaryError("Invalid UTXO, doesn't exist"); const vaultUtxoValue = btcTx.outs[vout].value; return {btcTx, vaultUtxoValue}; })(), (async() => { //Require vault UTXO is unspent if(await this._btcRpc.isSpent(utxo)) throw new IntermediaryError("Returned spv vault UTXO is already spent", null, true); abortController.signal.throwIfAborted(); })() ]).catch(e => { abortController.abort(e); throw e; }); this.logger.debug("verifyReturnedData(): Vault UTXO: "+vault.getUtxo()+" current utxo: "+utxo); //Trace returned utxo back to what's saved on-chain let pendingWithdrawals: T["SpvVaultWithdrawalData"][] = []; while(vault.getUtxo()!==utxo) { const [txId, voutStr] = utxo.split(":"); //Such that 1st tx isn't fetched twice if(btcTx.txid!==txId) { const _btcTx = await this._btcRpc.getTransaction(txId); if(_btcTx==null) throw new IntermediaryError("Invalid ancestor transaction (not found)"); btcTx = _btcTx; } const withdrawalData = await this._contract(lpVersion).getWithdrawalData(btcTx); abortSignal.throwIfAborted(); pendingWithdrawals.unshift(withdrawalData); utxo = pendingWithdrawals[0].getSpentVaultUtxo(); this.logger.debug("verifyReturnedData(): Vault UTXO: "+vault.getUtxo()+" current utxo: "+utxo); if(pendingWithdrawals.length>=this._options.maxTransactionsDelta) throw new IntermediaryError("BTC <> SC state difference too deep, maximum: "+this._options.maxTransactionsDelta); } //Verify that the vault has enough balance after processing all pending withdrawals let vaultBalances: {balances: SpvVaultTokenBalance[]}; try { vaultBalances = vault.calculateStateAfter(pendingWithdrawals); } catch (e) { this.logger.error("Error calculating spv vault balance (owner: "+resp.address+" vaultId: "+resp.vaultId.toString(10)+"): ", e); throw new IntermediaryError("Spv swap vault balance prediction failed", e); } if(vaultBalances.balances[0].scaledAmount < resp.total) throw new IntermediaryError("SPV swap vault, insufficient balance, required: "+resp.total.toString(10)+ " has: "+vaultBalances.balances[0].scaledAmount.toString(10)); if(vaultBalances.balances[1].scaledAmount < resp.totalGas) throw new IntermediaryError("SPV swap vault, insufficient balance, required: "+resp.totalGas.toString(10)+ " has: "+vaultBalances.balances[1].scaledAmount.toString(10)); //Also verify that all the withdrawal txns are valid, this is an extra sanity check try { for(let withdrawal of pendingWithdrawals) { await this._contract(lpVersion).checkWithdrawalTx(withdrawal); } } catch (e) { this.logger.error("Error calculating spv vault balance (owner: "+resp.address+" vaultId: "+resp.vaultId.toString(10)+"): ", e); throw new IntermediaryError("Spv swap vault balance prediction failed", e); } abortSignal.throwIfAborted(); return { vault, vaultUtxoValue }; } private async amountPrefetch( amountData: {token: string, exactIn: boolean, amount?: bigint}, bitcoinFeeRatePromise: Promise<number | undefined>, walletUtxosPromise: Promise<BitcoinWalletUtxoBase[]> | undefined, includeGas: boolean, abortController: AbortController ): Promise<bigint | undefined> { if(amountData.amount!=null) return amountData.amount; try { const bitcoinFeeRate = await throwIfUndefined(bitcoinFeeRatePromise, "Cannot fetch Bitcoin fee rate!"); if(walletUtxosPromise==null) throw new UserError("Cannot use empty amount without passing UTXOs!"); const walletUtxos = await walletUtxosPromise; if(walletUtxos.length===0) throw new UserError("Wallet doesn't have any BTC balance"); const spendableBalance = await BitcoinWallet.getSpendableBalance( walletUtxos, bitcoinFeeRate, this.getDummySwapPsbt(includeGas), REQUIRED_SPV_SWAP_LP_ADDRESS_TYPE ); return spendableBalance.balance; } catch (e) { abortController.abort(e); } } private bitcoinFeeRatePrefetch( options: { maxAllowedBitcoinFeeRate: number, sourceWalletUtxos?: Promise<BitcoinWalletUtxoBase[]>, bitcoinFeeRate?: Promise<number> }, abortController: AbortController ) { let bitcoinFeeRatePromise: Promise<number | undefined> | undefined; if(options?.sourceWalletUtxos!=null) { if(options.bitcoinFeeRate!=null) { bitcoinFeeRatePromise = options.bitcoinFeeRate.then(value => { if(options.maxAllowedBitcoinFeeRate!=Infinity && options.maxAllowedBitcoinFeeRate<value) throw new Error("Passed `maxAllowedBitcoinFeeRate` cannot be lower than `bitcoinFeeRate`"); return value; }); } else { bitcoinFeeRatePromise = this._btcRpc.getFeeRate().then(value => { if(options.maxAllowedBitcoinFeeRate!=Infinity && value > options.maxAllowedBitcoinFeeRate) return options.maxAllowedBitcoinFeeRate; return value; }); } bitcoinFeeRatePromise = bitcoinFeeRatePromise.catch(e => { abortController.abort(e); return undefined; }); } const maxBitcoinFeeRatePromise: Promise<number | undefined> = options.maxAllowedBitcoinFeeRate!=Infinity ? Promise.resolve(options.maxAllowedBitcoinFeeRate) : throwIfUndefined(bitcoinFeeRatePromise ?? options.bitcoinFeeRate ?? this._btcRpc.getFeeRate()) .then(x => this._options.maxBtcFeeOffset + (x*this._options.maxBtcFeeMultiplier)) .catch(e => { abortController.abort(e); return undefined; }); return { bitcoinFeeRatePromise, maxBitcoinFeeRatePromise } } /** * Returns a newly created Bitcoin -> Smart chain swap using the SPV vault (UTXO-controlled vault) swap protocol, * with the passed amount. Also allows specifying additional "gas drop" native token that the receipient receives * on the destination chain in the `options` argument. * * @param recipient Recipient address on the destination smart chain * @param amountData Amount, token and exact input/output data for to swap * @param lps An array of intermediaries (LPs) to get the quotes from * @param options Optional additional quote options * @param additionalParams Optional additional parameters sent to the LP when creating the swap * @param abortSignal Abort signal */ public create( recipient: string, amountData: { amount?: bigint, token: string, exactIn: boolean }, lps: Intermediary[], options?: SpvFromBTCOptions, additionalParams?: Record<string, any>, abortSignal?: AbortSignal ): { quote: Promise<SpvFromBTCSwap<T>>, intermediary: Intermediary }[] { const _options = { gasAmount: this.parseGasAmount(options?.gasAmount), unsafeZeroWatchtowerFee: options?.unsafeZeroWatchtowerFee ?? false, feeSafetyFactor: options?.feeSafetyFactor ?? 1.25, maxAllowedBitcoinFeeRate: options?.maxAllowedBitcoinFeeRate ?? options?.maxAllowedNetworkFeeRate ?? Infinity, sourceWalletUtxos: options?.sourceWalletUtxos==undefined ? undefined : options?.sourceWalletUtxos instanceof Promise ? options.sourceWalletUtxos : Promise.resolve(options.sourceWalletUtxos), bitcoinFeeRate: options?.bitcoinFeeRate==undefined ? undefined : options?.bitcoinFeeRate instanceof Promise ? options.bitcoinFeeRate : Promise.resolve(options.bitcoinFeeRate), }; if( _options.gasAmount!==0n && ( this._chain.shouldGetNativeTokenDrop!=null ? !this._chain.shouldGetNativeTokenDrop(amountData.token) : amountData.token===this._chain.getNativeCurrencyAddress() ) ) throw new UserError("Cannot specify `gasAmount` for swaps to a native token!"); if(amountData.amount==null && options?.sourceWalletUtxos==null) throw new UserError("Source wallet UTXOs need to be passed when amount is null!"); if(amountData.amount==null && !amountData.exactIn) throw new UserError("Amount can be null only for exactIn swaps!"); if(amountData.amount!=null && options?.sourceWalletUtxos!=null) throw new UserError("Source wallet UTXOs cannot be passed while specifying an input amount!"); const lpVersions = Intermediary.getContractVersionsForLps(this.chainIdentifier, lps); const _abortController = extendAbortController(abortSignal); const pricePrefetchPromise: Promise<bigint | undefined> = this.preFetchPrice(amountData, _abortController.signal); const usdPricePrefetchPromise: Promise<number | undefined> = this.preFetchUsdPrice(_abortController.signal); const finalizedBlockHeightPrefetchPromise: Promise<number | undefined> = this.preFetchFinalizedBlockHeight(_abortController); const nativeTokenAddress = this._chain.getNativeCurrencyAddress(); const gasTokenPricePrefetchPromise: Promise<bigint | undefined> | undefined = _options.gasAmount===0n ? undefined : this.preFetchPrice({token: nativeTokenAddress}, _abortController.signal); const callerFeePrefetchPromise = mapArrayToObject(lpVersions, (contractVersion: string) => { return this.preFetchCallerFeeInNativeToken(amountData, _options, _abortController, contractVersion); }); const {maxBitcoinFeeRatePromise, bitcoinFeeRatePromise} = this.bitcoinFeeRatePrefetch(_options, _abortController); const amountPromise = this.amountPrefetch( amountData, maxBitcoinFeeRatePromise, _options.sourceWalletUtxos, _options.gasAmount!==0n, _abortController ); return lps.map(lp => { return { intermediary: lp, quote: tryWithRetries(async () => { if(lp.services[SwapType.SPV_VAULT_FROM_BTC]==null) throw new Error("LP service for processing spv vault swaps not found!"); const version = lp.getContractVersion(this.chainIdentifier); const abortController = extendAbortController(_abortController.signal); const callerFeeRatePromise = this.computeCallerFeeShare( amountPromise, callerFeePrefetchPromise[version], amountData, _options, pricePrefetchPromise, gasTokenPricePrefetchPromise, abortController.signal ); try { const resp = await tryWithRetries(async(retryCount: number) => { return await this._lpApi.prepareSpvFromBTC( this.chainIdentifier, lp.url, { address: recipient, amount: throwIfUndefined(amountPromise, "Failed to compute swap amount"), token: amountData.token.toString(), exactOut: !amountData.exactIn, gasToken: nativeTokenAddress, gasAmount: _options.gasAmount, callerFeeRate: throwIfUndefined(callerFeeRatePromise, "Caller fee prefetch failed!"), frontingFeeRate: 0n, stickyAddress: options?.stickyAddress, amountUtxos: _options.sourceWalletUtxos!=null ? _options.sourceWalletUtxos.then(utxos => { if(utxos.length===0) return undefined; return utxos.map(utxo => ({ value: utxo.value, vSize: utils.inputBytes({type: utxo.type}), cpfp: utxo.cpfp==null ? undefined : {effectiveVSize: utxo.cpfp?.txVsize, effectiveFeeRate: utxo.cpfp?.txEffectiveFeeRate} })); }) : undefined, amountFeeRate: bitcoinFeeRatePromise, additionalParams }, this._options.postRequestTimeout, abortController.signal, retryCount>0 ? false : undefined ); }, undefined, e => e instanceof RequestError, abortController.signal); this.logger.debug("create("+lp.url+"): LP response: ", resp) const callerFeeShare = await callerFeeRatePromise; const amount = await throwIfUndefined(amountPromise); const [ pricingInfo, gasPricingInfo, {vault, vaultUtxoValue} ] = await Promise.all([ this.verifyReturnedPrice( lp.services[SwapType.SPV_VAULT_FROM_BTC], false, resp.btcAmountSwap, resp.total * (100_000n + callerFeeShare) / 100_000n, amountData.token, {swapFeeBtc: resp.swapFeeBtc}, pricePrefetchPromise, usdPricePrefetchPromise, abortController.signal ), _options.gasAmount===0n ? Promise.resolve(undefined) : this.verifyReturnedPrice( {...lp.services[SwapType.SPV_VAULT_FROM_BTC], swapBaseFee: 0}, //Base fee should be charged only on the amount, not on gas false, resp.btcAmountGas, resp.totalGas * (100_000n + callerFeeShare) / 100_000n, nativeTokenAddress, {swapFeeBtc: resp.gasSwapFeeBtc}, gasTokenPricePrefetchPromise, usdPricePrefetchPromise, abortController.signal ), this.verifyReturnedData( resp, {...amountData, amount}, lp, _options, callerFeeShare, maxBitcoinFeeRatePromise, bitcoinFeeRatePromise, abortController.signal ) ]); let minimumBtcFeeRate: number = resp.btcFeeRate; if(bitcoinFeeRatePromise!=null) minimumBtcFeeRate = Math.max(minimumBtcFeeRate, await throwIfUndefined(bitcoinFeeRatePromise)); const swapInit: SpvFromBTCSwapInit = { pricingInfo, url: lp.url, expiry: resp.expiry * 1000, swapFee: resp.swapFee, swapFeeBtc: resp.swapFeeBtc, exactIn: amountData.exactIn ?? true, quoteId: resp.quoteId, recipient, vaultOwner: resp.address, vaultId: resp.vaultId, vaultRequiredConfirmations: vault.getConfirmations(), vaultTokenMultipliers: vault.getTokenData().map(val => val.multiplier), vaultBtcAddress: resp.vaultBtcAddress, vaultUtxo: resp.btcUtxo, vaultUtxoValue: BigInt(vaultUtxoValue), btcDestinationAddress: resp.btcAddress, btcAmount: resp.btcAmount, btcAmountSwap: resp.btcAmountSwap, btcAmountGas: resp.btcAmountGas, minimumBtcFeeRate, outputTotalSwap: resp.total, outputSwapToken: amountData.token, outputTotalGas: resp.totalGas, outputGasToken: nativeTokenAddress, gasSwapFeeBtc: resp.gasSwapFeeBtc, gasSwapFee: resp.gasSwapFee, gasPricingInfo, callerFeeShare: resp.callerFeeShare, frontingFeeShare: resp.frontingFeeShare, executionFeeShare: resp.executionFeeShare, genesisSmartChainBlockHeight: await throwIfUndefined( finalizedBlockHeightPrefetchPromise, "Network finalized blockheight pre-fetch failed!" ), contractVersion: version }; const quote = new SpvFromBTCSwap<T>(this, swapInit); return quote; } catch (e) { if(e instanceof OutOfBoundsError) { const amountResult = await amountPromise.catch(() => undefined); if(_options.sourceWalletUtxos!=null && amountResult!=null && amountResult<=0n) { e = new UserError("Wallet doesn't have enough BTC balance to cover transaction fees"); } } abortController.abort(e); throw e; } }, undefined, err => !(err instanceof IntermediaryError && err.recoverable), _abortController.signal) } }); } /** * Recovers an SPV vault (UTXO-controlled vault) based swap from smart chain on-chain data * * @param state State of the spv vault withdrawal recovered from on-chain data * @param vault SPV vault processing the swap * @param lp Intermediary (LP) used as a counterparty for the swap */ public async recoverFromState(state: SpvWithdrawalClaimedState | SpvWithdrawalFrontedState, contractVersion: string, vault?: SpvVaultData | null, lp?: Intermediary): Promise<SpvFromBTCSwap<T> | null> { //Get the vault vault ??= await this._contract(contractVersion).getVaultData(state.owner, state.vaultId); if(vault==null) return null; if(state.btcTxId==null) return null; const btcTx = await this._btcRpc.getTransaction(state.btcTxId); if(btcTx==null) return null; const withdrawalData = await this._contract(contractVersion).getWithdrawalData(btcTx) .catch(e => { this.logger.warn(`Error parsing withdrawal data for tx ${btcTx.txid}: `, e); return null; }); if(withdrawalData==null) return null; const vaultTokens = vault.getTokenData(); const withdrawalDataOutputs = withdrawalData.getTotalOutput(); const txBlock = await state.getTxBlock?.(); const swapInit: SpvFromBTCSwapInit = { pricingInfo: { isValid: t