UNPKG

@atomiqlabs/sdk

Version:

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

565 lines (511 loc) 21.1 kB
import {ChainEvent, ChainType, isAbstractSigner} from "@atomiqlabs/base"; import {EventEmitter} from "events"; import {ISwap} from "./ISwap"; import {ISwapPrice} from "../prices/abstract/ISwapPrice"; import {IntermediaryError} from "../errors/IntermediaryError"; import {ChainIds, MultiChain} from "../swapper/Swapper"; import {UnifiedSwapEventListener} from "../events/UnifiedSwapEventListener"; import {SwapType} from "../enums/SwapType"; import {UnifiedSwapStorage} from "../storage/UnifiedSwapStorage"; import {SCToken} from "../types/Token"; import {getLogger} from "../utils/Logger"; import {PriceInfoType} from "../types/PriceInfoType"; import {fromHumanReadableString} from "../utils/TokenUtils"; import {UserError} from "../errors/UserError"; import {IntermediaryAPI} from "../intermediaries/apis/IntermediaryAPI"; export const DEFAULT_MAX_PARALLEL_SWAP_TICKS = 50; export const DEFAULT_MAX_PARALLEL_SWAP_SYNCS = 50; /** * Options for swap wrapper configuration * * @category Swaps/Base */ export type ISwapWrapperOptions = { getRequestTimeout?: number, postRequestTimeout?: number, /** * How many swaps to call `_tick()` for in parallel */ maxParallelSwapTicks?: number, /** * How many swaps to call `_sync()` for in parallel */ maxParallelSwapSyncs?: number, /** * Whether to save swaps that are not initialized into the persistent storage */ saveUninitializedSwaps?: boolean }; /** * Token configuration for wrapper constructors * * @category Swaps/Base */ export type WrapperCtorTokens<T extends ChainType = ChainType> = { [tokenAddress: string]: SCToken<T["ChainId"]> }; /** * Type definition linking wrapper and swap types * * @category Swaps/Base */ export type SwapTypeDefinition<T extends ChainType, W extends ISwapWrapper<T, any>, S extends ISwap<T>> = { Wrapper: W; Swap: S; }; /** * Base abstract class for swap handler implementations * * @category Swaps/Base */ export abstract class ISwapWrapper< T extends ChainType, D extends SwapTypeDefinition<T, ISwapWrapper<T, D>, ISwap<T, D>>, O extends ISwapWrapperOptions = ISwapWrapperOptions > { /** * Swap type */ abstract readonly TYPE: SwapType; /** * Function for deserializing swaps * @internal */ abstract readonly _swapDeserializer: new (wrapper: D["Wrapper"], data: any) => D["Swap"]; /** * Logger instance * @internal */ protected readonly logger = getLogger(this.constructor.name+": "); /** * Persistent storage backend for the swaps * @internal */ protected readonly unifiedStorage: UnifiedSwapStorage<T>; /** * Smart chain events listener for listening to and parsing of on-chain events * @internal */ protected readonly unifiedChainEvents: UnifiedSwapEventListener<T>; /** * States of the swaps where {@link ISwap._tick} should be called every second * @internal */ protected readonly tickSwapState?: Array<D["Swap"]["_state"]>; /** * In-memory mapping of pending (not initiated) swaps, utilizing weak references to automatically * free memory when swaps are dereferenced in not initiated state * @internal */ protected readonly pendingSwaps: Map<string, WeakRef<D["Swap"]>> = new Map(); /** * Whether this wrapper is initialized (have to call {@link init} to initialize a wrapper) * @internal */ protected isInitialized: boolean = false; /** * An interval for calling tick functions on the underlying swaps * @internal */ protected tickInterval?: NodeJS.Timeout; /** * An internal abort controller for the running tick handler * @internal */ protected tickAbortController?: AbortController; /** * States of the swaps in pending (non-final state), these are checked automatically on initial swap synchronization * @internal */ abstract readonly _pendingSwapStates: Array<D["Swap"]["_state"]>; /** * Chain interface of the underlying smart chain * @internal */ readonly _chain: T["ChainInterface"]; /** * Pricing API * @internal */ readonly _prices: ISwapPrice; /** * Wrapper options * @internal */ readonly _options: O; /** * Tokens indexed by their token address * @internal */ readonly _tokens: { [tokenAddress: string]: SCToken<T["ChainId"]> }; /** * LP API Used to communicate with the LPs * @internal */ readonly _lpApi: IntermediaryAPI; /** * Chain identifier string of this wrapper */ readonly chainIdentifier: T["ChainId"]; /** * Event emitter emitting `"swapState"` event when swap's state changes */ readonly events: EventEmitter<{swapState: [D["Swap"]]}>; constructor( chainIdentifier: T["ChainId"], unifiedStorage: UnifiedSwapStorage<T>, unifiedChainEvents: UnifiedSwapEventListener<T>, chain: T["ChainInterface"], prices: ISwapPrice, tokens: WrapperCtorTokens, lpApi: IntermediaryAPI, options: O, events?: EventEmitter<{swapState: [ISwap]}> ) { if(options?.maxParallelSwapTicks!=null && options.maxParallelSwapTicks < 1) throw new Error("maxParallelSwapTicks must be at least 1!"); if(options?.maxParallelSwapSyncs!=null && options.maxParallelSwapSyncs < 1) throw new Error("maxParallelSwapSyncs must be at least 1!"); this.unifiedStorage = unifiedStorage; this.unifiedChainEvents = unifiedChainEvents; this.chainIdentifier = chainIdentifier; this._chain = chain; this._prices = prices; this.events = events || new EventEmitter(); this._lpApi = lpApi; this._options = options; this._tokens = tokens; } /** * Parses the provided gas amount from its `string` or `bigint` representation to `bigint` base units. * * Defaults to `0n` if no gasAmount is provided * * @param gasAmount * @internal */ protected parseGasAmount(gasAmount?: string | bigint): bigint { let result: bigint | undefined | null; if(typeof(gasAmount)==="string") { result = fromHumanReadableString(gasAmount, this._getNativeToken()); if(result==null) throw new UserError("Invalid `gasAmount` option provided, not a numerical string!"); } else { result = gasAmount; } return result ?? 0n; } /** * Pre-fetches swap price for a given swap * * @param amountData Amount data * @param abortSignal Abort signal * @returns Price of the token in uSats (micro sats) * @internal */ protected preFetchPrice(amountData: { token: string }, abortSignal?: AbortSignal): Promise<bigint | undefined> { return this._prices.preFetchPrice(this.chainIdentifier, amountData.token, abortSignal).catch(e => { this.logger.error("preFetchPrice.token(): Error: ", e); return undefined; }); } /** * Pre-fetches bitcoin's USD price * * @param abortSignal Abort signal * @internal */ protected preFetchUsdPrice(abortSignal?: AbortSignal): Promise<number | undefined> { return this._prices.preFetchUsdPrice(abortSignal).catch(e => { this.logger.error("preFetchPrice.usd(): Error: ", e); return undefined; }) } /** * Verifies returned price for swaps * * @param lpServiceData Service data for the service in question (TO_BTCLN, TO_BTC, etc.) of the given intermediary * @param send Whether this is a send (Smart chain -> Bitcoin) or receive (Bitcoin -> Smart chain) swap * @param amountSats Amount in BTC * @param amountToken Amount in token * @param token Token used in the swap * @param feeData Fee data as returned by the intermediary * @param pricePrefetchPromise Optional price pre-fetch promise * @param usdPricePrefetchPromise Optiona USD price pre-fetch promise * @param abortSignal Abort signal * @returns Price info object * @throws {IntermediaryError} if the calculated fee is too high * * @internal */ protected async verifyReturnedPrice( lpServiceData: {swapBaseFee: number, swapFeePPM: number}, send: boolean, amountSats: bigint, amountToken: bigint, token: string, feeData: { networkFee?: bigint, swapFeeBtc?: bigint }, pricePrefetchPromise: Promise<bigint | undefined> = Promise.resolve(undefined), usdPricePrefetchPromise: Promise<number | undefined> = Promise.resolve(undefined), abortSignal?: AbortSignal ): Promise<PriceInfoType> { const swapBaseFee = BigInt(lpServiceData.swapBaseFee); const swapFeePPM = BigInt(lpServiceData.swapFeePPM); if(send && feeData.networkFee!=null) amountToken = amountToken - feeData.networkFee; const [isValidAmount, usdPrice] = await Promise.all([ send ? this._prices.isValidAmountSend(this.chainIdentifier, amountSats, swapBaseFee, swapFeePPM, amountToken, token, abortSignal, await pricePrefetchPromise, feeData.swapFeeBtc) : this._prices.isValidAmountReceive(this.chainIdentifier, amountSats, swapBaseFee, swapFeePPM, amountToken, token, abortSignal, await pricePrefetchPromise, feeData.swapFeeBtc), usdPricePrefetchPromise.then(value => { if(value!=null) return value; return this._prices.preFetchUsdPrice(abortSignal); }) ]); if(!isValidAmount.isValid) throw new IntermediaryError("Fee too high"); isValidAmount.realPriceUsdPerBitcoin = usdPrice; return isValidAmount; } /** * Processes a single smart chain on-chain event * * @param event Smart chain event to process * @param swap A swap related to the event * @internal */ protected abstract processEvent?(event: ChainEvent<T["Data"]>, swap: D["Swap"]): Promise<void>; /** * Starts the interval calling the {@link ISwap._tick} on all the known swaps in tick-enabled states * @internal */ protected startTickInterval(): void { if(this.tickSwapState==null || this.tickSwapState.length===0) return; if(this.tickAbortController!=null) this.tickAbortController.abort("New tick interval has been started!"); const abortController = this.tickAbortController = new AbortController(); let run: () => Promise<void>; run = async () => { if(!this.isInitialized) return; await this.tick(undefined, abortController.signal).catch(e => { if(abortController.signal.aborted) return; this.logger.warn("startTickInterval(): Tick on swaps failed, error: ", e); }); if(abortController.signal.aborted) return; if(!this.isInitialized) return; this.tickInterval = setTimeout(run, 1000); } run(); } /** * Runs checks on passed swaps, syncing their state from on-chain data * * @param pastSwaps Swaps to check * @internal */ protected async _checkPastSwaps(pastSwaps: D["Swap"][]): Promise<{changedSwaps: D["Swap"][], removeSwaps: D["Swap"][]}> { const changedSwaps: D["Swap"][] = []; const removeSwaps: D["Swap"][] = []; await Promise.all(pastSwaps.map((swap: D["Swap"]) => swap._sync(false).then(changed => { if(swap.isQuoteExpired()) { removeSwaps.push(swap); this.logger.debug("_checkPastSwaps(): Removing expired swap: "+swap.getId()); } else { if(changed) changedSwaps.push(swap); } }).catch(e => this.logger.error("_checkPastSwaps(): Error when checking swap "+swap.getId()+": ", e)) )); return {changedSwaps, removeSwaps}; } /** * Initializes the swap wrapper, needs to be called before any other action can be taken * * @param noTimers Whether to skip scheduling a tick timer for the swaps, if the tick timer is not initiated * the swap states depending on e.g. expiry can be out of sync with the actual expiration of the swap * @param noCheckPastSwaps Whether to skip checking past swaps on initialization (by default all pending swaps * are re-checked on init, and their state is synchronized from the on-chain data) */ public async init(noTimers: boolean = false, noCheckPastSwaps: boolean = false): Promise<void> { if(this.isInitialized) return; if(!noCheckPastSwaps) { //Save events received in the meantime into the event queue and process them only after we've checked and // processed all the past swaps let eventQueue: { event: ChainEvent<T["Data"]>, swap: D["Swap"] }[] = []; const initListener = (event: ChainEvent<T["Data"]>, swap: D["Swap"]) => { eventQueue.push({event, swap}); return Promise.resolve(); } if(this.processEvent!=null) this.unifiedChainEvents.registerListener(this.TYPE, initListener, this._swapDeserializer.bind(null, this)); await this.checkPastSwaps(); if(this.processEvent!=null) { //Process accumulated event queue for(let event of eventQueue) { await this.processEvent(event.event, event.swap); } //Unregister the temporary event handler this.unifiedChainEvents.unregisterListener(this.TYPE); } } if(this.processEvent!=null) this.unifiedChainEvents.registerListener(this.TYPE, this.processEvent.bind(this), this._swapDeserializer.bind(null, this)); this.isInitialized = true; if(!noTimers) this.startTickInterval(); // this.logger.info("init(): Swap wrapper initialized"); } /** * Un-subscribes from event listeners on the smart chain, terminates the tick interval and stops this wrapper */ public async stop() { this.isInitialized = false; this.unifiedChainEvents.unregisterListener(this.TYPE); this.logger.info("stop(): Swap wrapper stopped"); if(this.tickInterval!=null) { clearTimeout(this.tickInterval); delete this.tickInterval; } if(this.tickAbortController!=null) { this.tickAbortController.abort("Wrapper instance stopped!"); delete this.tickAbortController; } } /** * Runs checks on all the known pending swaps, syncing their state from on-chain data * * @remarks Doesn't work properly if you pass non-persisted swaps * * @param pastSwaps Optional array of past swaps to check, otherwise all relevant swaps will be fetched * from the persistent storage * @param noSave Whether to skip saving the swap changes in the persistent storage */ public async checkPastSwaps(pastSwaps?: D["Swap"][], noSave?: boolean): Promise<{ removeSwaps: D["Swap"][], changedSwaps: D["Swap"][] }> { if (pastSwaps == null) pastSwaps = await this.unifiedStorage.query<D["Swap"]>( [[{key: "type", value: this.TYPE}, {key: "state", value: this._pendingSwapStates}]], (val: any) => new this._swapDeserializer(this, val) ); const maxParallelSyncs = this._options.maxParallelSwapSyncs ?? DEFAULT_MAX_PARALLEL_SWAP_SYNCS; const totalRemoveSwaps: D["Swap"][] = []; const totalChangedSwaps: D["Swap"][] = []; for(let i=0; i<pastSwaps.length; i+=maxParallelSyncs) { const {removeSwaps, changedSwaps} = await this._checkPastSwaps(pastSwaps.slice(i, i+maxParallelSyncs)); if (!noSave) { await this.unifiedStorage.removeAll(removeSwaps, true); await this.unifiedStorage.saveAll(changedSwaps, true); changedSwaps.forEach(swap => swap._emitEvent()); removeSwaps.forEach(swap => swap._emitEvent()); } totalRemoveSwaps.push(...removeSwaps); totalChangedSwaps.push(...changedSwaps); } return { removeSwaps: totalRemoveSwaps, changedSwaps: totalChangedSwaps } } /** * Invokes {@link ISwap._tick} on all the known swaps * * @param swaps Optional array of swaps to invoke `_tick()` on, otherwise all relevant swaps will be fetched * from the persistent storage * @param abortSignal Abort signal */ public async tick(swaps?: D["Swap"][], abortSignal?: AbortSignal): Promise<void> { if(swaps==null) swaps = await this.unifiedStorage.query<D["Swap"]>( [[{key: "type", value: this.TYPE}, {key: "state", value: this.tickSwapState}]], (val: any) => new this._swapDeserializer(this, val) ); abortSignal?.throwIfAborted(); const parallelTicks = this._options.maxParallelSwapTicks ?? DEFAULT_MAX_PARALLEL_SWAP_TICKS; let promises: Promise<any>[] = []; for(let pendingSwap of this.pendingSwaps.values()) { const value = pendingSwap.deref(); if(value != null) promises.push(value._tick(true).catch(e => { this.logger.warn(`tick(): Error ticking swap ${value.getId()}: `, e); })); if(promises.length >= parallelTicks) { await Promise.all(promises); abortSignal?.throwIfAborted(); promises = []; } } for(let value of swaps) { promises.push(value._tick(true).catch(e => { this.logger.warn(`tick(): Error ticking swap ${value.getId()}: `, e); })); if(promises.length >= parallelTicks) { await Promise.all(promises); abortSignal?.throwIfAborted(); promises = []; } } if(promises.length>0) await Promise.all(promises); abortSignal?.throwIfAborted(); } /** * Returns the smart chain's native token used to pay for fees * @internal */ _getNativeToken(): SCToken<T["ChainId"]> { return this._tokens[this._chain.getNativeCurrencyAddress()]; } /** * Saves the swap, if it is not initiated it is only saved to pending swaps * * @param swap Swap to save * * @internal */ _saveSwapData(swap: D["Swap"]): Promise<void> { if(!this._options.saveUninitializedSwaps) { if(!swap.isInitiated()) { this.logger.debug("saveSwapData(): Swap "+swap.getId()+" not initiated, saving to pending swaps"); this.pendingSwaps.set(swap.getId(), new WeakRef<D["Swap"]>(swap)); return Promise.resolve(); } else { this.pendingSwaps.delete(swap.getId()); } } return this.unifiedStorage.save(swap); } /** * Removes the swap from the persistent storage and pending swaps * * @param swap Swap to remove * * @internal */ _removeSwapData(swap: D["Swap"]): Promise<void> { this.pendingSwaps.delete(swap.getId()); if(!swap._persisted) return Promise.resolve(); return this.unifiedStorage.remove(swap); } /** * Retrieves a swap by its ID from the pending swap mapping * * @param id * * @internal */ _getPendingSwap(id: string): D["Swap"] | null { return this.pendingSwaps.get(id)?.deref() ?? null; } /** * @internal */ async _getSignerAddress(signer?: string | T["Signer"] | T["NativeSigner"]): Promise<string | undefined> { let address: string | undefined = undefined; if(signer!=null) { if (typeof (signer) === "string") { address = signer; } else if (isAbstractSigner(signer)) { address = signer.getAddress(); } else { address = (await this._chain.wrapSigner(signer)).getAddress(); } } return address; } }