UNPKG

@atomiqlabs/sdk

Version:

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

613 lines (563 loc) 26.2 kB
import {IFromBTCWrapper} from "../IFromBTCWrapper"; import {FromBTCSwap, FromBTCSwapInit, FromBTCSwapState} from "./FromBTCSwap"; import { ChainSwapType, ChainType, ClaimEvent, InitializeEvent, RefundEvent, RelaySynchronizer, SwapData, BtcRelay, BitcoinRpcWithAddressIndex, SwapCommitState } from "@atomiqlabs/base"; import {EventEmitter} from "events"; import {Intermediary} from "../../../../intermediaries/Intermediary"; import {ISwapPrice} from "../../../../prices/abstract/ISwapPrice"; import {ISwapWrapperOptions, WrapperCtorTokens} from "../../../ISwapWrapper"; import {Buffer} from "buffer"; import {IntermediaryError} from "../../../../errors/IntermediaryError"; import {SwapType} from "../../../../enums/SwapType"; import { extendAbortController, mapArrayToObject, randomBytes, throwIfUndefined } from "../../../../utils/Utils"; import { toOutputScript} from "../../../../utils/BitcoinUtils"; import {FromBTCResponseType, IntermediaryAPI} from "../../../../intermediaries/apis/IntermediaryAPI"; import {RequestError} from "../../../../errors/RequestError"; import {BTC_NETWORK, TEST_NETWORK} from "@scure/btc-signer/utils"; import {UnifiedSwapEventListener} from "../../../../events/UnifiedSwapEventListener"; import {UnifiedSwapStorage} from "../../../../storage/UnifiedSwapStorage"; import {ISwap} from "../../../ISwap"; import {IClaimableSwapWrapper} from "../../../IClaimableSwapWrapper"; import {IFromBTCSelfInitDefinition} from "../IFromBTCSelfInitSwap"; import {AmountData} from "../../../../types/AmountData"; import {tryWithRetries} from "../../../../utils/RetryUtils"; import {AllOptional} from "../../../../utils/TypeUtils"; import {UserError} from "../../../../errors/UserError"; export type FromBTCOptions = { /** * 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.5` multiple by default (i.e. the current network fee is multiplied by 1.5 and then used to estimate * the settlement gas fee cost). * * Also accepts `bigint` for legacy reasons. */ feeSafetyFactor?: number | bigint, /** * @deprecated Removed as it is deemed not necessary */ blockSafetyFactor?: number }; export type FromBTCWrapperOptions = ISwapWrapperOptions & { safetyFactor: number, blocksTillTxConfirms: number, maxConfirmations: number, minSendWindow: number, bitcoinNetwork: BTC_NETWORK, bitcoinBlocktime: number }; export type FromBTCDefinition<T extends ChainType> = IFromBTCSelfInitDefinition<T, FromBTCWrapper<T>, FromBTCSwap<T>>; /** * Legacy escrow (PrTLC) based swap for Bitcoin -> Smart chains, requires manual initiation * of the swap escrow on the destination chain. * * @category Swaps/Legacy/Bitcoin → Smart chain */ export class FromBTCWrapper< T extends ChainType > extends IFromBTCWrapper<T, FromBTCDefinition<T>, FromBTCWrapperOptions> implements IClaimableSwapWrapper<FromBTCSwap<T>> { public readonly TYPE: SwapType.FROM_BTC = SwapType.FROM_BTC; /** * @internal */ protected readonly tickSwapState = [FromBTCSwapState.PR_CREATED, FromBTCSwapState.CLAIM_COMMITED, FromBTCSwapState.EXPIRED]; /** * @internal */ readonly _pendingSwapStates = [ FromBTCSwapState.PR_CREATED, FromBTCSwapState.QUOTE_SOFT_EXPIRED, FromBTCSwapState.CLAIM_COMMITED, FromBTCSwapState.BTC_TX_CONFIRMED, FromBTCSwapState.EXPIRED ]; /** * @internal */ readonly _claimableSwapStates = [FromBTCSwapState.BTC_TX_CONFIRMED]; /** * @internal */ readonly _swapDeserializer = FromBTCSwap; /** * @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 _btcRpc: BitcoinRpcWithAddressIndex<any>; private readonly btcRelay: (version?: string) => BtcRelay<any, T["TX"], any> = (version?: string) => { const _version = version ?? "v1"; const data = this.versionedBtcRelay[_version]; if(data==null) throw new Error(`Invalid contract version ${_version} requested`); return data.btcRelay; }; private readonly versionedBtcRelay: { [version: string]: { btcRelay: BtcRelay<any, T["TX"], any> } }= {}; 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]: { swapContract: T["Contract"], swapDataConstructor: new (data: any) => T["Data"], btcRelay: BtcRelay<any, T["TX"], any> } }, versionedSynchronizer: { [version: string]: { synchronizer: RelaySynchronizer<any, T["TX"], any> } }, btcRpc: BitcoinRpcWithAddressIndex<any>, lpApi: IntermediaryAPI, options?: AllOptional<FromBTCWrapperOptions>, events?: EventEmitter<{swapState: [ISwap]}> ) { super( chainIdentifier, unifiedStorage, unifiedChainEvents, chain, prices, tokens, lpApi, { ...options, bitcoinNetwork: options?.bitcoinNetwork ?? TEST_NETWORK, safetyFactor: options?.safetyFactor ?? 2, blocksTillTxConfirms: options?.blocksTillTxConfirms ?? 12, maxConfirmations: options?.maxConfirmations ?? 6, minSendWindow: options?.minSendWindow ?? 30*60, //Minimum time window for user to send in the on-chain funds for From BTC swap bitcoinBlocktime: options?.bitcoinBlocktime ?? 10*60 }, versionedContracts, events ); this._btcRpc = btcRpc; this.versionedBtcRelay = versionedContracts; this.versionedSynchronizer = versionedSynchronizer; } /** * @inheritDoc * @internal */ 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); } /** * @inheritDoc * @internal */ protected async processEventClaim(swap: FromBTCSwap<T>, event: ClaimEvent<T["Data"]>): Promise<boolean> { if(swap._state!==FromBTCSwapState.FAILED && swap._state!==FromBTCSwapState.CLAIM_CLAIMED) { await swap._setBitcoinTxId(Buffer.from(event.result, "hex").reverse().toString("hex")).catch(e => { this.logger.warn("processEventClaim(): Error setting bitcoin txId: ", e); }); swap._state = FromBTCSwapState.CLAIM_CLAIMED; return true; } return false; } /** * @inheritDoc * @internal */ protected processEventRefund(swap: FromBTCSwap<T>, event: RefundEvent<T["Data"]>): Promise<boolean> { if(swap._state!==FromBTCSwapState.CLAIM_CLAIMED && swap._state!==FromBTCSwapState.FAILED) { 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 Swap data * @param requiredConfirmations Confirmations required on the bitcoin side to settle the swap * * @internal */ _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 * @param contractVersion * * @private */ private async preFetchClaimerBounty( signer: string, amountData: AmountData, options: { feeSafetyFactorPPM: bigint, blockSafetyFactor: bigint, unsafeZeroWatchtowerFee: boolean }, abortController: AbortController, contractVersion: string ): Promise<{ feePerBlock: bigint, safetyFactor: bigint, startTimestamp: bigint, addBlock: bigint, addFee: bigint } | undefined> { const startTimestamp = BigInt(Math.floor(Date.now()/1000)); if(options.unsafeZeroWatchtowerFee) { return { feePerBlock: 0n, safetyFactor: options.blockSafetyFactor, startTimestamp: startTimestamp, addBlock: 0n, addFee: 0n } } const dummyAmount = BigInt(Math.floor(Math.random()* 0x1000000)); const dummySwapData = await this._contract(contractVersion).createSwapData( ChainSwapType.CHAIN, signer, signer, amountData.token, dummyAmount, this._contract(contractVersion).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([ this.btcRelay(contractVersion).getFeePerBlock(), this.btcRelay(contractVersion).getTipData(), this._btcRpc.getTipHeight(), this._contract(contractVersion).getClaimFee(signer, dummySwapData) ]); if(btcRelayData==null) throw new Error("Btc relay not initialized!"); const currentBtcRelayBlock = btcRelayData.blockheight; const addBlock = Math.max(currentBtcBlock-currentBtcRelayBlock, 0); return { feePerBlock: feePerBlock * options.feeSafetyFactorPPM / 1_000_000n, safetyFactor: options.blockSafetyFactor, startTimestamp: startTimestamp, addBlock: BigInt(addBlock), addFee: claimFeeRate * options.feeSafetyFactorPPM / 1_000_000n } } catch (e) { abortController.abort(e); return undefined; } } /** * 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 {@link preFetchClaimerBounty} function * * @private */ private getClaimerBounty( data: T["Data"], options: { blockSafetyFactor: bigint }, claimerBounty: { feePerBlock: bigint, safetyFactor: bigint, startTimestamp: bigint, addBlock: bigint, addFee: bigint } ) : bigint { const tsDelta = data.getExpiry() - claimerBounty.startTimestamp; const blocksDelta = tsDelta / BigInt(this._options.bitcoinBlocktime) * options.blockSafetyFactor; const totalBlock = blocksDelta + claimerBounty.addBlock; return claimerBounty.addFee + (totalBlock * claimerBounty.feePerBlock); } /** * Verifies response returned from intermediary * * @param signer * @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 * * @throws {IntermediaryError} in case the response is invalid * * @private */ private verifyReturnedData( signer: string, resp: FromBTCResponseType, amountData: AmountData, lp: Intermediary, options: { blockSafetyFactor: bigint }, data: T["Data"], sequence: bigint, claimerBounty: { feePerBlock: bigint, safetyFactor: bigint, startTimestamp: bigint, addBlock: bigint, 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; 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.isOfferer(lp.getAddress(this.chainIdentifier)) || !data.isClaimer(signer) || !data.isDepositToken(depositToken) || data.hasSuccessAction() ) { 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 version = lp.getContractVersion(this.chainIdentifier); const lockingScript = toOutputScript(this._options.bitcoinNetwork, resp.btcAddress); const desiredExtraData = this._contract(version).getExtraData(lockingScript, resp.amount, requiredConfirmations); const desiredClaimHash = this._contract(version).getHashForOnchain(lockingScript, resp.amount, requiredConfirmations); if(!desiredClaimHash.equals(Buffer.from(data.getClaimHash(), "hex"))) { throw new IntermediaryError("Invalid claim hash returned!"); } const extraData = data.getExtraData(); if(extraData==null || !desiredExtraData.equals(Buffer.from(extraData, "hex"))) { throw new IntermediaryError("Invalid extra data returned!"); } } /** * Returns a newly created legacy Bitcoin -> Smart chain swap using the PrTLC based escrow swap protocol, * with the passed amount. * * @param recipient Smart chain signer's address on the destination 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 */ create( recipient: string, amountData: AmountData, lps: Intermediary[], options?: FromBTCOptions, additionalParams?: Record<string, any>, abortSignal?: AbortSignal ): { quote: Promise<FromBTCSwap<T>>, intermediary: Intermediary }[] { let feeSafetyFactorPPM: bigint = 1_500_000n; if(typeof(options?.feeSafetyFactor)==="bigint") { feeSafetyFactorPPM = options.feeSafetyFactor * 1_000_000n; } else if(typeof(options?.feeSafetyFactor)==="number") { feeSafetyFactorPPM = BigInt(Math.floor(options.feeSafetyFactor * 1_000_000)); } const lpVersions = Intermediary.getContractVersionsForLps(this.chainIdentifier, lps); const _options = { blockSafetyFactor: options?.blockSafetyFactor!=null ? BigInt(options.blockSafetyFactor) : 1n, feeSafetyFactorPPM, unsafeZeroWatchtowerFee: options?.unsafeZeroWatchtowerFee ?? false }; const sequence: bigint = this.getRandomSequence(); const _abortController = extendAbortController(abortSignal); const pricePrefetchPromise: Promise<bigint | undefined> = this.preFetchPrice(amountData, _abortController.signal); const usdPricePrefetchPromise: Promise<number | undefined> = this.preFetchUsdPrice(_abortController.signal); const claimerBountyPrefetchPromise = mapArrayToObject(lpVersions, (contractVersion: string) => { return this.preFetchClaimerBounty(recipient, amountData, _options, _abortController, contractVersion); }); const nativeTokenAddress = this._chain.getNativeCurrencyAddress(); const feeRatePromise = this.preFetchFeeRate(recipient, amountData, undefined, _abortController, lpVersions); const _signDataPromise = mapArrayToObject(lpVersions, (contractVersion: string) => { return this._contract(contractVersion).preFetchBlockDataForSignatures == null ? this.preFetchSignData(Promise.resolve(true), contractVersion) : undefined; }); return lps.map(lp => { return { intermediary: lp, quote: (async () => { if(lp.services[SwapType.FROM_BTC]==null) throw new Error("LP service for processing from btc swaps not found!"); const version = lp.getContractVersion(this.chainIdentifier); const abortController = extendAbortController(_abortController.signal); const liquidityPromise: Promise<bigint | undefined> = this.preFetchIntermediaryLiquidity(amountData, lp, abortController, version); try { const {signDataPromise, resp} = await tryWithRetries(async(retryCount: number) => { const {signDataPrefetch, response} = this._lpApi.initFromBTC( this.chainIdentifier, lp.url, nativeTokenAddress, { claimer: recipient, amount: amountData.amount, token: amountData.token.toString(), exactOut: !amountData.exactIn, sequence, claimerBounty: throwIfUndefined(claimerBountyPrefetchPromise[version], "Watchtower fee pre-fetch failed!"), feeRate: throwIfUndefined(feeRatePromise[version], "Network fee rate pre-fetch failed!"), additionalParams }, this._options.postRequestTimeout, abortController.signal, retryCount>0 ? false : undefined ); let signDataPromise = _signDataPromise[version]; if(signDataPromise==null) { signDataPromise = this.preFetchSignData(signDataPrefetch, version); } else signDataPrefetch.catch(() => {}); return { signDataPromise, resp: await response }; }, undefined, e => e instanceof RequestError, abortController.signal); const data: T["Data"] = new (this._swapDataDeserializer(version))(resp.data); data.setClaimer(recipient); const swapFeeBtc = resp.swapFee * resp.amount / (data.getAmount() + resp.swapFee); this.verifyReturnedData(recipient, resp, amountData, lp, _options, data, sequence, (await claimerBountyPrefetchPromise[version])!, 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, {swapFeeBtc}, pricePrefetchPromise, usdPricePrefetchPromise, abortController.signal ), this.verifyReturnedSignature(recipient, data, resp, feeRatePromise[version], signDataPromise, version, abortController.signal), this.verifyIntermediaryLiquidity(data.getAmount(), throwIfUndefined(liquidityPromise, "LP liquidity pre-fetch failed!")), ]); const quote = new FromBTCSwap<T>(this, { pricingInfo, url: lp.url, expiry: signatureExpiry, swapFee: resp.swapFee, swapFeeBtc, feeRate: (await feeRatePromise[version])!, signatureData: resp, data, address: resp.btcAddress, amount: resp.amount, exactIn: amountData.exactIn ?? true, requiredConfirmations: resp.confirmations, contractVersion: version } as FromBTCSwapInit<T["Data"]>); return quote; } catch (e) { abortController.abort(e); throw e; } })() } }); } /** * @inheritDoc */ async recoverFromSwapDataAndState( init: {data: T["Data"], getInitTxId: () => Promise<string>, getTxBlock: () => Promise<{blockTime: number, blockHeight: number}>}, state: SwapCommitState, contractVersion: string, lp?: Intermediary ): Promise<FromBTCSwap<T> | null> { const data = init.data; const swapInit: FromBTCSwapInit<T["Data"]> = { pricingInfo: { isValid: true, satsBaseFee: 0n, swapPriceUSatPerToken: 100_000_000_000_000n, realPriceUSatPerToken: 100_000_000_000_000n, differencePPM: 0n, feePPM: 0n, }, url: lp?.url, expiry: 0, swapFee: 0n, swapFeeBtc: 0n, feeRate: "", signatureData: undefined, data, exactIn: false, contractVersion } const swap = new FromBTCSwap(this, swapInit); swap._commitTxId = await init.getInitTxId(); const blockData = await init.getTxBlock(); swap.createdAt = blockData.blockTime * 1000; swap._setInitiated(); swap._state = FromBTCSwapState.CLAIM_COMMITED; await swap._sync(false, false, state); await swap._save(); return swap; } }