UNPKG

@atomiqlabs/sdk-lib

Version:

Basic SDK functionality library for atomiq

768 lines (695 loc) 37.1 kB
import {AmountData, ISwapWrapper, ISwapWrapperOptions, WrapperCtorTokens} from "../ISwapWrapper"; import { BtcRelay, ChainEvent, ChainType, RelaySynchronizer, SpvVaultClaimEvent, SpvVaultCloseEvent, SpvVaultFrontEvent, SpvVaultTokenBalance, 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 {BitcoinRpcWithAddressIndex} from "../../btc/BitcoinRpcWithAddressIndex"; 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, randomBytes, tryWithRetries } from "../../utils/Utils"; import { toCoinselectAddressType, toOutputScript } from "../../utils/BitcoinUtils"; import { IntermediaryAPI, SpvFromBTCPrepareResponseType } from "../../intermediaries/IntermediaryAPI"; import {RequestError} from "../../errors/RequestError"; import {IntermediaryError} from "../../errors/IntermediaryError"; import {CoinselectAddressTypes} from "../../btc/coinselect2"; import {OutScript, Transaction} from "@scure/btc-signer"; import {ISwap} from "../ISwap"; import {IClaimableSwapWrapper} from "../IClaimableSwapWrapper"; export type SpvFromBTCOptions = { gasAmount?: bigint, unsafeZeroWatchtowerFee?: boolean, feeSafetyFactor?: number, 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 class SpvFromBTCWrapper< T extends ChainType > extends ISwapWrapper<T, SpvFromBTCSwap<T>, SpvFromBTCWrapperOptions> implements IClaimableSwapWrapper<SpvFromBTCSwap<T>> { public readonly claimableSwapStates = [SpvFromBTCSwapState.BTC_TX_CONFIRMED]; public readonly TYPE = SwapType.SPV_VAULT_FROM_BTC; public readonly swapDeserializer = SpvFromBTCSwap; readonly synchronizer: RelaySynchronizer<any, T["TX"], any>; readonly contract: T["SpvVaultContract"]; readonly btcRelay: T["BtcRelay"]; readonly btcRpc: BitcoinRpcWithAddressIndex<any>; readonly spvWithdrawalDataDeserializer: new (data: any) => T["SpvVaultWithdrawalData"]; /** * @param chainIdentifier * @param unifiedStorage Storage interface for the current environment * @param unifiedChainEvents On-chain event listener * @param chain * @param contract Underlying contract handling the swaps * @param prices Pricing to use * @param tokens * @param spvWithdrawalDataDeserializer Deserializer for SpvVaultWithdrawalData * @param btcRelay * @param synchronizer Btc relay synchronizer * @param btcRpc Bitcoin RPC which also supports getting transactions by txoHash * @param options * @param events Instance to use for emitting events */ constructor( chainIdentifier: string, unifiedStorage: UnifiedSwapStorage<T>, unifiedChainEvents: UnifiedSwapEventListener<T>, chain: T["ChainInterface"], contract: T["SpvVaultContract"], prices: ISwapPrice, tokens: WrapperCtorTokens, spvWithdrawalDataDeserializer: new (data: any) => T["SpvVaultWithdrawalData"], btcRelay: BtcRelay<any, T["TX"], any>, synchronizer: RelaySynchronizer<any, T["TX"], any>, btcRpc: BitcoinRpcWithAddressIndex<any>, options?: SpvFromBTCWrapperOptions, events?: EventEmitter<{swapState: [ISwap]}> ) { if(options==null) options = {}; options.bitcoinNetwork ??= TEST_NETWORK; options.maxConfirmations ??= 6; options.bitcoinBlocktime ??= 10*60; options.maxTransactionsDelta ??= 3; options.maxRawAmountAdjustmentDifferencePPM ??= 100; options.maxBtcFeeOffset ??= 5; options.maxBtcFeeMultiplier ??= 1.5; super(chainIdentifier, unifiedStorage, unifiedChainEvents, chain, prices, tokens, options, events); this.spvWithdrawalDataDeserializer = spvWithdrawalDataDeserializer; this.contract = contract; this.btcRelay = btcRelay; this.synchronizer = synchronizer; this.btcRpc = btcRpc; } readonly pendingSwapStates: Array<SpvFromBTCSwap<T>["state"]> = [ SpvFromBTCSwapState.CREATED, SpvFromBTCSwapState.SIGNED, SpvFromBTCSwapState.POSTED, SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED, SpvFromBTCSwapState.BROADCASTED, SpvFromBTCSwapState.DECLINED, SpvFromBTCSwapState.BTC_TX_CONFIRMED ]; readonly tickSwapState: Array<SpvFromBTCSwap<T>["state"]> = [ SpvFromBTCSwapState.CREATED, SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED, SpvFromBTCSwapState.SIGNED, SpvFromBTCSwapState.POSTED, SpvFromBTCSwapState.BROADCASTED ]; protected processEventFront(event: SpvVaultFrontEvent, swap: SpvFromBTCSwap<T>): 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; return true; } return false; } protected processEventClaim(event: SpvVaultClaimEvent, swap: SpvFromBTCSwap<T>): 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.CLAIMED; return true; } return false; } protected processEventClose(event: SpvVaultCloseEvent, swap: SpvFromBTCSwap<T>): 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 true; } return false; } protected async processEvent(event: ChainEvent<T["Data"]>, swap: SpvFromBTCSwap<T>): Promise<boolean> { if(swap==null) return; let swapChanged: boolean = false; if(event instanceof SpvVaultFrontEvent) { swapChanged = 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 = 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 = this.processEventClose(event, swap); } this.logger.info("processEvents(): "+event.constructor.name+" processed for "+swap.getId()+" swap: ", swap); if(swapChanged) { await swap._saveAndEmit(); } return true; } /** * Pre-fetches latest finalized block height of the smart chain * * @param abortController * @private */ private async preFetchFinalizedBlockHeight(abortController: AbortController): Promise<number> { try { const block = await tryWithRetries(() => this.chain.getFinalizedBlock(), null, null, abortController.signal); return block.height; } catch (e) { abortController.abort(e); return null; } } /** * 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 pricePrefetch * @param nativeTokenPricePrefetch * @param abortController * @private */ private async preFetchCallerFeeShare( amountData: AmountData, options: SpvFromBTCOptions, pricePrefetch: Promise<bigint>, nativeTokenPricePrefetch: Promise<bigint>, abortController: AbortController ): Promise<bigint> { if(options.unsafeZeroWatchtowerFee) return 0n; if(amountData.amount===0n) return 0n; try { const [ feePerBlock, btcRelayData, currentBtcBlock, claimFeeRate, nativeTokenPrice ] = await Promise.all([ tryWithRetries(() => this.btcRelay.getFeePerBlock(), null, null, abortController.signal), tryWithRetries(() => this.btcRelay.getTipData(), null, null, abortController.signal), this.btcRpc.getTipHeight(), tryWithRetries<bigint>(() => this.contract.getClaimFee(this.chain.randomAddress(), null, null), null, null, abortController.signal), nativeTokenPricePrefetch ?? (amountData.token===this.chain.getNativeCurrencyAddress() ? pricePrefetch : this.prices.preFetchPrice(this.chainIdentifier, this.chain.getNativeCurrencyAddress(), abortController.signal)) ]); 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; let payoutAmount: bigint; if(amountData.exactIn) { //Convert input amount in BTC to const amountInNativeToken = await this.prices.getFromBtcSwapAmount(this.chainIdentifier, amountData.amount, this.chain.getNativeCurrencyAddress(), abortController.signal, nativeTokenPrice); payoutAmount = amountInNativeToken - totalFeeInNativeToken; } else { if(amountData.token===this.chain.getNativeCurrencyAddress()) { //Both amounts in same currency payoutAmount = amountData.amount; } else { //Need to convert both to native currency const btcAmount = await this.prices.getToBtcSwapAmount(this.chainIdentifier, amountData.amount, amountData.token, abortController.signal, await pricePrefetch); payoutAmount = await this.prices.getFromBtcSwapAmount(this.chainIdentifier, btcAmount, this.chain.getNativeCurrencyAddress(), abortController.signal, nativeTokenPrice); } } this.logger.debug("preFetchCallerFeeShare(): 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; } catch (e) { abortController.abort(e); return null; } } /** * 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 bitcoinFeeRatePromise Maximum accepted fee rate from the LPs * @param abortSignal * @private * @throws {IntermediaryError} in case the response is invalid */ private async verifyReturnedData( resp: SpvFromBTCPrepareResponseType, amountData: AmountData, lp: Intermediary, options: SpvFromBTCOptions, callerFeeShare: bigint, bitcoinFeeRatePromise: Promise<number>, abortSignal: AbortSignal ): Promise<{ vault: T["SpvVaultData"], vaultUtxoValue: number }> { const btcFeeRate = await bitcoinFeeRatePromise; abortSignal.throwIfAborted(); if(btcFeeRate!=null && resp.btcFeeRate > btcFeeRate) throw new IntermediaryError("Bitcoin fee rate returned too high!"); //Vault related let vaultScript: Uint8Array; let vaultAddressType: CoinselectAddressTypes; let btcAddressScript: Uint8Array; //Ensure valid btc addresses returned try { vaultScript = toOutputScript(this.options.bitcoinNetwork, resp.vaultBtcAddress); vaultAddressType = toCoinselectAddressType(vaultScript); btcAddressScript = toOutputScript(this.options.bitcoinNetwork, resp.btcAddress); } 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 vaultAddressType==="p2pkh" || vaultAddressType==="p2sh-p2wpkh" || //Constrain the vault script type to witness types 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"]; try { vault = await this.contract.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.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.btcAmount !== amountData.amount) throw new IntermediaryError("Invalid amount returned"); } 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==null || 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); 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) btcTx = await this.btcRpc.getTransaction(txId); const withdrawalData = await this.contract.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.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 }; } /** * Returns a newly created swap, receiving 'amount' on chain * * @param signer Smartchain signer's address intiating the swap * @param amountData Amount of token & amount to swap * @param lps LPs (liquidity providers) to get the quotes from * @param options Quote options * @param additionalParams Additional parameters sent to the LP when creating the swap * @param abortSignal Abort signal for aborting the process */ create( signer: string, amountData: AmountData, lps: Intermediary[], options?: SpvFromBTCOptions, additionalParams?: Record<string, any>, abortSignal?: AbortSignal ): { quote: Promise<SpvFromBTCSwap<T>>, intermediary: Intermediary }[] { options ??= {}; options.gasAmount ??= 0n; options.feeSafetyFactor ??= 1.25; const _abortController = extendAbortController(abortSignal); const pricePrefetchPromise: Promise<bigint> = this.preFetchPrice(amountData, _abortController.signal); const finalizedBlockHeightPrefetchPromise: Promise<number> = this.preFetchFinalizedBlockHeight(_abortController); const nativeTokenAddress = this.chain.getNativeCurrencyAddress(); const gasTokenPricePrefetchPromise: Promise<bigint> = options.gasAmount===0n ? null : this.preFetchPrice({token: nativeTokenAddress}, _abortController.signal); const callerFeePrefetchPromise = this.preFetchCallerFeeShare(amountData, options, pricePrefetchPromise, gasTokenPricePrefetchPromise, _abortController); const bitcoinFeeRatePromise: Promise<number> = options.maxAllowedNetworkFeeRate!=null ? Promise.resolve(options.maxAllowedNetworkFeeRate) : this.btcRpc.getFeeRate().then(x => this.options.maxBtcFeeOffset + (x*this.options.maxBtcFeeMultiplier)).catch(e => { _abortController.abort(e); return null; }); return lps.map(lp => { return { intermediary: lp, quote: tryWithRetries(async () => { const abortController = extendAbortController(_abortController.signal); try { const resp = await tryWithRetries(async(retryCount: number) => { return await IntermediaryAPI.prepareSpvFromBTC( this.chainIdentifier, lp.url, { address: signer, amount: amountData.amount, token: amountData.token.toString(), exactOut: !amountData.exactIn, gasToken: nativeTokenAddress, gasAmount: options.gasAmount, callerFeeRate: callerFeePrefetchPromise, frontingFeeRate: 0n, additionalParams }, this.options.postRequestTimeout, abortController.signal, retryCount>0 ? false : null ); }, null, e => e instanceof RequestError, abortController.signal); this.logger.debug("create("+lp.url+"): LP response: ", resp) const callerFeeShare = await callerFeePrefetchPromise; 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, {}, pricePrefetchPromise, abortController.signal ), options.gasAmount===0n ? Promise.resolve() : 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, {}, gasTokenPricePrefetchPromise, abortController.signal ), this.verifyReturnedData(resp, amountData, lp, options, callerFeeShare, bitcoinFeeRatePromise, abortController.signal) ]); 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: signer, 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: resp.btcFeeRate, outputTotalSwap: resp.total, outputSwapToken: amountData.token, outputTotalGas: resp.totalGas, outputGasToken: nativeTokenAddress, gasSwapFeeBtc: resp.gasSwapFeeBtc, gasSwapFee: resp.gasSwapFee, callerFeeShare: resp.callerFeeShare, frontingFeeShare: resp.frontingFeeShare, executionFeeShare: resp.executionFeeShare, genesisSmartChainBlockHeight: await finalizedBlockHeightPrefetchPromise }; const quote = new SpvFromBTCSwap<T>(this, swapInit); await quote._save(); return quote; } catch (e) { abortController.abort(e); throw e; } }, null, err => !(err instanceof IntermediaryError && err.recoverable), _abortController.signal) } }); } /** * Returns a random dummy PSBT that can be used for fee estimation, the last output (the LP output) is omitted * to allow for coinselection algorithm to determine maximum sendable amount there * * @param includeGasToken Whether to return the PSBT also with the gas token amount (increases the vSize by 8) */ public getDummySwapPsbt(includeGasToken = false): Transaction { //Construct dummy swap psbt const psbt = new Transaction({ allowUnknownInputs: true, allowLegacyWitnessUtxo: true, allowUnknownOutputs: true }); const randomVaultOutScript = OutScript.encode({type: "tr", pubkey: Buffer.from("0101010101010101010101010101010101010101010101010101010101010101", "hex")}); psbt.addInput({ txid: randomBytes(32), index: 0, witnessUtxo: { script: randomVaultOutScript, amount: 600n } }); psbt.addOutput({ script: randomVaultOutScript, amount: 600n }); const opReturnData = this.contract.toOpReturnData( this.chain.randomAddress(), includeGasToken ? [0xFFFFFFFFFFFFFFFFn, 0xFFFFFFFFFFFFFFFFn] : [0xFFFFFFFFFFFFFFFFn] ); psbt.addOutput({ script: Buffer.concat([ opReturnData.length <= 75 ? Buffer.from([0x6a, opReturnData.length]) : Buffer.from([0x6a, 0x4c, opReturnData.length]), opReturnData ]), amount: 0n }); return psbt; } protected async _checkPastSwaps(pastSwaps: SpvFromBTCSwap<T>[]): Promise<{ changedSwaps: SpvFromBTCSwap<T>[]; removeSwaps: SpvFromBTCSwap<T>[] }> { const changedSwaps: Set<SpvFromBTCSwap<T>> = new Set(); const removeSwaps: SpvFromBTCSwap<T>[] = []; const broadcastedOrConfirmedSwaps: SpvFromBTCSwap<T>[] = []; for(let pastSwap of pastSwaps) { let changed: boolean = false; if( pastSwap.state===SpvFromBTCSwapState.SIGNED || pastSwap.state===SpvFromBTCSwapState.POSTED || pastSwap.state===SpvFromBTCSwapState.BROADCASTED || pastSwap.state===SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED || pastSwap.state===SpvFromBTCSwapState.DECLINED || pastSwap.state===SpvFromBTCSwapState.BTC_TX_CONFIRMED ) { //Check BTC transaction if(await pastSwap._syncStateFromBitcoin(false)) changed ||= true; } if( pastSwap.state===SpvFromBTCSwapState.CREATED || pastSwap.state===SpvFromBTCSwapState.SIGNED || pastSwap.state===SpvFromBTCSwapState.POSTED ) { if(pastSwap.expiry<Date.now()) { if(pastSwap.state===SpvFromBTCSwapState.CREATED) { pastSwap.state = SpvFromBTCSwapState.QUOTE_EXPIRED; } else { pastSwap.state = SpvFromBTCSwapState.QUOTE_SOFT_EXPIRED; } changed ||= true; } } if(pastSwap.isQuoteExpired()) { removeSwaps.push(pastSwap); continue; } if(changed) changedSwaps.add(pastSwap); if(pastSwap.state===SpvFromBTCSwapState.BROADCASTED || pastSwap.state===SpvFromBTCSwapState.BTC_TX_CONFIRMED) { broadcastedOrConfirmedSwaps.push(pastSwap); } } const checkWithdrawalStateSwaps: SpvFromBTCSwap<T>[] = []; const _fronts = await this.contract.getFronterAddresses(broadcastedOrConfirmedSwaps.map(val => ({ owner: val.vaultOwner, vaultId: val.vaultId, withdrawal: val.data }))); const _vaultUtxos = await this.contract.getVaultLatestUtxos(broadcastedOrConfirmedSwaps.map(val => ({ owner: val.vaultOwner, vaultId: val.vaultId }))); for(const pastSwap of broadcastedOrConfirmedSwaps) { const fronterAddress = _fronts[pastSwap.data.getTxId()]; const latestVaultUtxo = _vaultUtxos[pastSwap.vaultOwner]?.[pastSwap.vaultId.toString(10)]; if(fronterAddress===undefined) this.logger.warn(`_checkPastSwaps(): No fronter address returned for ${pastSwap.data.getTxId()}`); if(latestVaultUtxo===undefined) this.logger.warn(`_checkPastSwaps(): No last vault utxo returned for ${pastSwap.data.getTxId()}`); if(await pastSwap._shouldCheckWithdrawalState(fronterAddress, latestVaultUtxo)) checkWithdrawalStateSwaps.push(pastSwap); } const withdrawalStates = await this.contract.getWithdrawalStates( checkWithdrawalStateSwaps.map(val => ({ withdrawal: val.data, scStartBlockheight: val.genesisSmartChainBlockHeight })) ); for(const pastSwap of checkWithdrawalStateSwaps) { const status = withdrawalStates[pastSwap.data.getTxId()]; if(status==null) { this.logger.warn(`_checkPastSwaps(): No withdrawal state returned for ${pastSwap.data.getTxId()}`); continue; } this.logger.debug("syncStateFromChain(): status of "+pastSwap.data.btcTx.txid, status?.type); let changed = false; switch(status.type) { case SpvWithdrawalStateType.FRONTED: pastSwap.frontTxId = status.txId; pastSwap.state = SpvFromBTCSwapState.FRONTED; changed ||= true; break; case SpvWithdrawalStateType.CLAIMED: pastSwap.claimTxId = status.txId; pastSwap.state = SpvFromBTCSwapState.CLAIMED; changed ||= true; break; case SpvWithdrawalStateType.CLOSED: pastSwap.state = SpvFromBTCSwapState.CLOSED; changed ||= true; break; } if(changed) changedSwaps.add(pastSwap); } return { changedSwaps: Array.from(changedSwaps), removeSwaps }; } }