@atomiqlabs/sdk
Version:
atomiq labs SDK for cross-chain swaps between smart chains and bitcoin
565 lines (511 loc) • 21.1 kB
text/typescript
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;
}
}