UNPKG

@atomiqlabs/sdk-lib

Version:

Basic SDK functionality library for atomiq

404 lines (369 loc) 18.3 kB
import {IFromBTCWrapper} from "../IFromBTCWrapper"; import {FromBTCSwap, FromBTCSwapInit, FromBTCSwapState} from "./FromBTCSwap"; import { ChainSwapType, ChainType, ClaimEvent, InitializeEvent, RefundEvent, RelaySynchronizer, SwapData, BtcRelay } from "@atomiqlabs/base"; import {EventEmitter} from "events"; import {Intermediary} from "../../../intermediaries/Intermediary"; import {BitcoinRpcWithTxoListener} from "../../../btc/BitcoinRpcWithTxoListener"; import {ISwapPrice} from "../../../prices/abstract/ISwapPrice"; import {AmountData, ISwapWrapperOptions, WrapperCtorTokens} from "../../ISwapWrapper"; import {Buffer} from "buffer"; import {IntermediaryError} from "../../../errors/IntermediaryError"; import {SwapType} from "../../SwapType"; import {extendAbortController, randomBytes, toOutputScript, tryWithRetries} from "../../../utils/Utils"; import {FromBTCResponseType, IntermediaryAPI} from "../../../intermediaries/IntermediaryAPI"; import {RequestError} from "../../../errors/RequestError"; import {BTC_NETWORK, TEST_NETWORK} from "@scure/btc-signer/utils"; import {UnifiedSwapEventListener} from "../../../events/UnifiedSwapEventListener"; import {UnifiedSwapStorage} from "../../UnifiedSwapStorage"; export type FromBTCOptions = { feeSafetyFactor?: bigint, blockSafetyFactor?: number, unsafeZeroWatchtowerFee?: boolean }; export type FromBTCWrapperOptions = ISwapWrapperOptions & { safetyFactor?: number, blocksTillTxConfirms?: number, maxConfirmations?: number, minSendWindow?: number, bitcoinNetwork?: BTC_NETWORK, bitcoinBlocktime?: number }; export class FromBTCWrapper< T extends ChainType > extends IFromBTCWrapper<T, FromBTCSwap<T>, FromBTCWrapperOptions> { public readonly TYPE = SwapType.FROM_BTC; public readonly swapDeserializer = FromBTCSwap; readonly synchronizer: RelaySynchronizer<any, T["TX"], any>; readonly btcRelay: BtcRelay<any, T["TX"], any>; readonly btcRpc: BitcoinRpcWithTxoListener<any>; /** * @param chainIdentifier * @param unifiedStorage Storage interface for the current environment * @param unifiedChainEvents On-chain event listener * @param contract Underlying contract handling the swaps * @param prices Pricing to use * @param tokens * @param swapDataDeserializer Deserializer for SwapData * @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>, contract: T["Contract"], prices: ISwapPrice, tokens: WrapperCtorTokens, swapDataDeserializer: new (data: any) => T["Data"], btcRelay: BtcRelay<any, T["TX"], any>, synchronizer: RelaySynchronizer<any, T["TX"], any>, btcRpc: BitcoinRpcWithTxoListener<any>, options?: FromBTCWrapperOptions, events?: EventEmitter ) { if(options==null) options = {}; options.bitcoinNetwork = options.bitcoinNetwork ?? TEST_NETWORK; options.safetyFactor = options.safetyFactor || 2; options.blocksTillTxConfirms = options.blocksTillTxConfirms || 12; options.maxConfirmations = options.maxConfirmations || 6; options.minSendWindow = options.minSendWindow || 30*60; //Minimum time window for user to send in the on-chain funds for From BTC swap options.bitcoinBlocktime = options.bitcoinBlocktime || 10*60; super(chainIdentifier, unifiedStorage, unifiedChainEvents, contract, prices, tokens, swapDataDeserializer, options, events); this.btcRelay = btcRelay; this.synchronizer = synchronizer; this.btcRpc = btcRpc; } public readonly pendingSwapStates = [ FromBTCSwapState.PR_CREATED, FromBTCSwapState.QUOTE_SOFT_EXPIRED, FromBTCSwapState.CLAIM_COMMITED, FromBTCSwapState.BTC_TX_CONFIRMED, FromBTCSwapState.EXPIRED ]; public readonly tickSwapState = [FromBTCSwapState.PR_CREATED, FromBTCSwapState.CLAIM_COMMITED, FromBTCSwapState.EXPIRED]; protected processEventInitialize(swap: FromBTCSwap<T>, event: InitializeEvent<T["Data"]>): Promise<boolean> { if(swap.state===FromBTCSwapState.PR_CREATED || swap.state===FromBTCSwapState.QUOTE_SOFT_EXPIRED) { swap.state = FromBTCSwapState.CLAIM_COMMITED; return Promise.resolve(true); } return Promise.resolve(false); } protected processEventClaim(swap: FromBTCSwap<T>, event: ClaimEvent<T["Data"]>): Promise<boolean> { if(swap.state!==FromBTCSwapState.FAILED) { swap.state = FromBTCSwapState.CLAIM_CLAIMED; return Promise.resolve(true); } return Promise.resolve(false); } protected processEventRefund(swap: FromBTCSwap<T>, event: RefundEvent<T["Data"]>): Promise<boolean> { if(swap.state!==FromBTCSwapState.CLAIM_CLAIMED) { swap.state = FromBTCSwapState.FAILED; return Promise.resolve(true); } return Promise.resolve(false); } /** * Returns the swap expiry, leaving enough time for the user to send a transaction and for it to confirm * * @param data Parsed swap data * @param requiredConfirmations Confirmations required to claim the tx */ getOnchainSendTimeout(data: SwapData, requiredConfirmations: number): bigint { const tsDelta = (this.options.blocksTillTxConfirms + requiredConfirmations) * this.options.bitcoinBlocktime * this.options.safetyFactor; return data.getExpiry() - BigInt(tsDelta); } /** * Pre-fetches claimer (watchtower) bounty data for the swap. Doesn't throw, instead returns null and aborts the * provided abortController * * @param signer Smartchain signer address initiating the swap * @param amountData * @param options Options as passed to the swap creation function * @param abortController * @private */ private async preFetchClaimerBounty( signer: string, amountData: AmountData, options: FromBTCOptions, abortController: AbortController ): Promise<{ feePerBlock: bigint, safetyFactor: number, startTimestamp: bigint, addBlock: number, addFee: bigint } | null> { const startTimestamp = BigInt(Math.floor(Date.now()/1000)); if(options.unsafeZeroWatchtowerFee) { return { feePerBlock: 0n, safetyFactor: options.blockSafetyFactor, startTimestamp: startTimestamp, addBlock: 0, addFee: 0n } } const dummyAmount = BigInt(Math.floor(Math.random()* 0x1000000)); const dummySwapData = await this.contract.createSwapData( ChainSwapType.CHAIN, signer, signer, amountData.token, dummyAmount, this.contract.getHashForOnchain(randomBytes(20), dummyAmount, 3).toString("hex"), this.getRandomSequence(), startTimestamp, false, true, BigInt(Math.floor(Math.random() * 0x10000)), BigInt(Math.floor(Math.random() * 0x10000)) ); try { const [feePerBlock, btcRelayData, currentBtcBlock, claimFeeRate] = 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(signer, dummySwapData), null, null, abortController.signal) ]); const currentBtcRelayBlock = btcRelayData.blockheight; const addBlock = Math.max(currentBtcBlock-currentBtcRelayBlock, 0); return { feePerBlock: feePerBlock * options.feeSafetyFactor, safetyFactor: options.blockSafetyFactor, startTimestamp: startTimestamp, addBlock, addFee: claimFeeRate * options.feeSafetyFactor } } catch (e) { abortController.abort(e); return null; } } /** * Returns calculated claimer bounty calculated from the claimer bounty data as fetched from preFetchClaimerBounty() * * @param data Parsed swap data returned from the intermediary * @param options Options as passed to the swap creation function * @param claimerBounty Claimer bounty data as fetched from preFetchClaimerBounty() function * @private */ private getClaimerBounty( data: T["Data"], options: FromBTCOptions, claimerBounty: { feePerBlock: bigint, safetyFactor: number, startTimestamp: bigint, addBlock: number, addFee: bigint } ) : bigint { const tsDelta = data.getExpiry() - claimerBounty.startTimestamp; const blocksDelta = tsDelta / BigInt(this.options.bitcoinBlocktime) * BigInt(options.blockSafetyFactor); const totalBlock = blocksDelta + BigInt(claimerBounty.addBlock); return claimerBounty.addFee + (totalBlock * claimerBounty.feePerBlock); } /** * 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 data Parsed swap data returned by the intermediary * @param sequence Required swap sequence * @param claimerBounty Claimer bount data as returned from the preFetchClaimerBounty() pre-fetch promise * @param depositToken * @private * @throws {IntermediaryError} in case the response is invalid */ private verifyReturnedData( resp: FromBTCResponseType, amountData: AmountData, lp: Intermediary, options: FromBTCOptions, data: T["Data"], sequence: bigint, claimerBounty: { feePerBlock: bigint, safetyFactor: number, startTimestamp: bigint, addBlock: number, addFee: bigint }, depositToken: string ): void { if(amountData.exactIn) { if(resp.amount !== amountData.amount) throw new IntermediaryError("Invalid amount returned"); } else { if(resp.total !== amountData.amount) throw new IntermediaryError("Invalid total returned"); } const requiredConfirmations = resp.confirmations ?? lp.services[SwapType.FROM_BTC].data.confirmations; if(requiredConfirmations>this.options.maxConfirmations) throw new IntermediaryError("Requires too many confirmations"); const totalClaimerBounty = this.getClaimerBounty(data, options, claimerBounty); if( data.getClaimerBounty() !== totalClaimerBounty || data.getType()!=ChainSwapType.CHAIN || data.getSequence() !== sequence || data.getAmount() !== resp.total || data.isPayIn() || !data.isToken(amountData.token) || data.getOfferer()!==lp.getAddress(this.chainIdentifier) || !data.isDepositToken(depositToken) ) { throw new IntermediaryError("Invalid data returned"); } //Check that we have enough time to send the TX and for it to confirm const expiry = this.getOnchainSendTimeout(data, requiredConfirmations); const currentTimestamp = BigInt(Math.floor(Date.now()/1000)); if((expiry - currentTimestamp) < BigInt(this.options.minSendWindow)) { throw new IntermediaryError("Send window too low"); } const lockingScript = toOutputScript(this.options.bitcoinNetwork, resp.btcAddress); const desiredExtraData = this.contract.getExtraData(lockingScript, resp.amount, requiredConfirmations); const desiredClaimHash = this.contract.getHashForOnchain(lockingScript, resp.amount, requiredConfirmations); if(!desiredClaimHash.equals(Buffer.from(data.getClaimHash(), "hex"))) { throw new IntermediaryError("Invalid claim hash returned!"); } if(!desiredExtraData.equals(Buffer.from(data.getExtraData(), "hex"))) { throw new IntermediaryError("Invalid extra data returned!"); } } /** * 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?: FromBTCOptions, additionalParams?: Record<string, any>, abortSignal?: AbortSignal ): { quote: Promise<FromBTCSwap<T>>, intermediary: Intermediary }[] { options ??= {}; options.blockSafetyFactor ??= 1; options.feeSafetyFactor ??= 2n; const sequence: bigint = this.getRandomSequence(); const _abortController = extendAbortController(abortSignal); const pricePrefetchPromise: Promise<bigint> = this.preFetchPrice(amountData, _abortController.signal); const claimerBountyPrefetchPromise = this.preFetchClaimerBounty(signer, amountData, options, _abortController); const nativeTokenAddress = this.contract.getNativeCurrencyAddress(); const feeRatePromise: Promise<any> = this.preFetchFeeRate(signer, amountData, null, _abortController); return lps.map(lp => { return { intermediary: lp, quote: (async () => { const abortController = extendAbortController(_abortController.signal); const liquidityPromise: Promise<bigint> = this.preFetchIntermediaryLiquidity(amountData, lp, abortController); try { const {signDataPromise, resp} = await tryWithRetries(async(retryCount: number) => { const {signDataPrefetch, response} = IntermediaryAPI.initFromBTC( this.chainIdentifier, lp.url, nativeTokenAddress, { claimer: signer, amount: amountData.amount, token: amountData.token.toString(), exactOut: !amountData.exactIn, sequence, claimerBounty: claimerBountyPrefetchPromise, feeRate: feeRatePromise, additionalParams }, this.options.postRequestTimeout, abortController.signal, retryCount>0 ? false : null ); return { signDataPromise: this.preFetchSignData(signDataPrefetch), resp: await response }; }, null, e => e instanceof RequestError, abortController.signal); const data: T["Data"] = new this.swapDataDeserializer(resp.data); data.setClaimer(signer); this.verifyReturnedData(resp, amountData, lp, options, data, sequence, await claimerBountyPrefetchPromise, nativeTokenAddress); const [pricingInfo, signatureExpiry] = await Promise.all([ //Get intermediary's liquidity this.verifyReturnedPrice( lp.services[SwapType.FROM_BTC], false, resp.amount, resp.total, amountData.token, resp, pricePrefetchPromise, abortController.signal ), this.verifyReturnedSignature(data, resp, feeRatePromise, signDataPromise, abortController.signal), this.verifyIntermediaryLiquidity(data.getAmount(), liquidityPromise), ]); const quote = new FromBTCSwap<T>(this, { pricingInfo, url: lp.url, expiry: signatureExpiry, swapFee: resp.swapFee, feeRate: await feeRatePromise, signatureData: resp, data, address: resp.btcAddress, amount: resp.amount, exactIn: amountData.exactIn ?? true, requiredConfirmations: resp.confirmations ?? lp.services[SwapType.FROM_BTC].data.confirmations } as FromBTCSwapInit<T["Data"]>); await quote._save(); return quote; } catch (e) { abortController.abort(e); throw e; } })() } }); } }