@atomiqlabs/sdk
Version:
atomiq labs SDK for cross-chain swaps between smart chains and bitcoin
776 lines (690 loc) • 27.6 kB
text/typescript
import {SwapType} from "../enums/SwapType";
import {EventEmitter} from "events";
import {ISwapWrapper, SwapTypeDefinition} from "./ISwapWrapper";
import {ChainType} from "@atomiqlabs/base";
import {randomBytes, toBigInt} from "../utils/Utils";
import {SwapDirection} from "../enums/SwapDirection";
import {Fee} from "../types/fees/Fee";
import {FeeBreakdown} from "../types/fees/FeeBreakdown";
import {PercentagePPM, ppmToPercentage} from "../types/fees/PercentagePPM";
import {TokenAmount} from "../types/TokenAmount";
import {isSCToken, Token} from "../types/Token";
import {SwapExecutionAction} from "../types/SwapExecutionAction";
import {LoggerType} from "../utils/Logger";
import {isPriceInfoType, PriceInfoType} from "../types/PriceInfoType";
import {SwapStateInfo} from "../types/SwapStateInfo";
import {SwapExecutionStep} from "../types/SwapExecutionStep";
/**
* Initialization data for creating a swap
*
* @category Swaps/Base
*/
export type ISwapInit = {
pricingInfo: PriceInfoType,
url?: string,
expiry: number,
swapFee: bigint,
swapFeeBtc: bigint,
exactIn: boolean,
contractVersion: string
};
/**
* Type guard to check if an object is an ISwapInit
*
* @category Swaps/Base
*/
export function isISwapInit(obj: any): obj is ISwapInit {
return typeof obj === 'object' &&
obj != null &&
isPriceInfoType(obj.pricingInfo) &&
(obj.url==null || typeof obj.url === 'string') &&
typeof obj.expiry === 'number' &&
typeof(obj.swapFee) === "bigint" &&
typeof(obj.swapFeeBtc) === "bigint" &&
(typeof obj.exactIn === 'boolean');
}
/**
* Base abstract class for all swap types
*
* @category Swaps/Base
*/
export abstract class ISwap<
T extends ChainType = ChainType,
D extends SwapTypeDefinition<T, ISwapWrapper<T, D>, ISwap<T, D, S>> = SwapTypeDefinition<T, ISwapWrapper<T, any>, ISwap<T, any, any>>,
S extends number = number
> {
/**
* Swap type
*/
protected readonly abstract TYPE: SwapType;
/**
* Description for the states
* @internal
*/
protected readonly abstract swapStateDescription: Record<S, string>;
/**
* Name of the states
* @internal
*/
protected readonly abstract swapStateName: (state: number) => string;
/**
* Swap logger
* @internal
*/
protected readonly abstract logger: LoggerType;
/**
* Current newest defined version of the swap
* @internal
*/
protected readonly currentVersion: number = 1;
/**
* Wrapper instance holding this swap
* @internal
*/
protected readonly wrapper: D["Wrapper"];
/**
* The current version of the swap
* @internal
*/
protected version: number;
/**
* Whether a swap was initialized, a swap is considered initialize on first interaction with it, i.e.
* calling commit() on a Smart chain -> Bitcoin swaps, calling waitForPayment() or similar on the other
* direction. Not initiated swaps are not saved to the persistent storage by default (see
* {@link SwapperOptions.saveUninitializedSwaps})
* @internal
*/
protected initiated: boolean = false;
/**
* Expiration of the swap quote
* @internal
*/
protected expiry: number;
/**
* Pricing information of the swap
* @internal
*/
protected pricingInfo?: PriceInfoType;
/**
* Swap fee in the non-bitcoin token
* @internal
*/
protected swapFee: bigint;
/**
* Swap fee in bitcoin satoshis
* @internal
*/
protected swapFeeBtc: bigint;
/**
* Swap state
* @internal
*/
_state: S = 0 as S;
/**
* Random nonce to differentiate the swap from others with the same identifier hash (i.e. when quoting the same swap
* from multiple LPs)
* @internal
*/
_randomNonce: string;
/**
* Whether the swap is saved in the persistent storage or not.
*
* @remarks This field itself is not persisted but is instead derived during runtime
*
* @internal
*/
_persisted: boolean = false;
/**
* @internal
*/
_contractVersion?: string;
/**
* Storage specific metadata that can be used for e.g. optimistic concurrency
*
* @internal
*/
_meta?: any;
/**
* Event emitter emitting `"swapState"` event when swap's state changes
*/
readonly events: EventEmitter<{swapState: [D["Swap"]]}> = new EventEmitter();
/**
* URL of the intermediary (LP) used for this swap, already has the swap service specific path appended
*/
readonly url?: string;
/**
* Smart chain identifier string corresponding to this swap
*/
readonly chainIdentifier: T["ChainId"];
/**
* Whether a swap is an exact input swap
*/
readonly exactIn: boolean;
/**
* A UNIX milliseconds timestamps of when this swap was created
*/
createdAt: number;
protected constructor(wrapper: D["Wrapper"], obj: any);
protected constructor(wrapper: D["Wrapper"], swapInit: ISwapInit);
protected constructor(
wrapper: D["Wrapper"],
swapInitOrObj: ISwapInit | any,
) {
this.chainIdentifier = wrapper.chainIdentifier;
this.wrapper = wrapper;
if(isISwapInit(swapInitOrObj)) {
this.pricingInfo = swapInitOrObj.pricingInfo;
this.url = swapInitOrObj.url;
this.expiry = swapInitOrObj.expiry;
this.swapFee = swapInitOrObj.swapFee;
this.swapFeeBtc = swapInitOrObj.swapFeeBtc;
this.exactIn = swapInitOrObj.exactIn;
this.version = this.currentVersion;
this.createdAt = Date.now();
this._randomNonce = randomBytes(16).toString("hex");
this._contractVersion = swapInitOrObj.contractVersion;
} else {
this.expiry = swapInitOrObj.expiry;
this.url = swapInitOrObj.url;
this._state = swapInitOrObj.state;
if(
swapInitOrObj._isValid!=null && swapInitOrObj._differencePPM!=null && swapInitOrObj._satsBaseFee!=null &&
swapInitOrObj._feePPM!=null && swapInitOrObj._swapPriceUSatPerToken!=null
) {
this.pricingInfo = {
isValid: swapInitOrObj._isValid,
differencePPM: BigInt(swapInitOrObj._differencePPM),
satsBaseFee: BigInt(swapInitOrObj._satsBaseFee),
feePPM: BigInt(swapInitOrObj._feePPM),
realPriceUSatPerToken: toBigInt(swapInitOrObj._realPriceUSatPerToken),
realPriceUsdPerBitcoin: swapInitOrObj._realPriceUsdPerBitcoin,
swapPriceUSatPerToken: BigInt(swapInitOrObj._swapPriceUSatPerToken),
};
}
this.swapFee = toBigInt(swapInitOrObj.swapFee);
this.swapFeeBtc = toBigInt(swapInitOrObj.swapFeeBtc);
this.version = swapInitOrObj.version;
this.initiated = swapInitOrObj.initiated;
this.exactIn = swapInitOrObj.exactIn;
this.createdAt = swapInitOrObj.createdAt ?? swapInitOrObj.expiry;
this._randomNonce = swapInitOrObj.randomNonce;
this._contractVersion = swapInitOrObj.contractVersion;
this._meta = swapInitOrObj._meta;
}
if(this.version!==this.currentVersion) {
this.upgradeVersion();
}
if(this.initiated==null) this.initiated = true;
}
/**
* Called when swap is deserialized to potentially update the version of the data for the swap
*
* @internal
*/
protected abstract upgradeVersion(): void;
/**
* Waits till the swap reaches a specific state
*
* @param targetState The state to wait for
* @param type Whether to wait for the state exactly or also to a state with a higher number
* @param abortSignal Abort signal
* @internal
*/
protected waitTillState(targetState: S, type: "eq" | "gte" | "neq" = "eq", abortSignal?: AbortSignal): Promise<void> {
//TODO: This doesn't hold strong reference to the swap, hence if no other strong reference to the
// swap exists, it will just never resolve!
return new Promise((resolve, reject) => {
let listener: () => void;
listener = () => {
if(type==="eq" ? this._state===targetState : type==="gte" ? this._state>=targetState : this._state!=targetState) {
resolve();
this.events.removeListener("swapState", listener);
}
};
this.events.on("swapState", listener);
if(abortSignal!=null) abortSignal.addEventListener("abort", () => {
this.events.removeListener("swapState", listener);
reject(abortSignal.reason);
});
});
}
/**
* Executes the swap with the provided wallet, the exact arguments for this functions differ for various swap
* types. Check the `execute()` function signature in the respective swap class to see the required arguments.
*
* @param args Execution arguments, usually contains a source wallet object, callbacks and options, for exact
* syntax check the respective swap class.
*
* @returns Whether a swap was successfully executed or not, if it wasn't the user can refund or claim manually
*/
public abstract execute(...args: any[]): Promise<boolean>;
//////////////////////////////
//// Pricing
/**
* This attempts to populate missing fields in the pricing info based on the swap amounts
*
* @internal
*/
protected tryRecomputeSwapPrice(): void {
if(this.pricingInfo==null) return;
if(this.pricingInfo.swapPriceUSatPerToken==null) {
const priceUsdPerBtc = this.pricingInfo.realPriceUsdPerBitcoin;
const input = this.getInput();
const output = this.getOutput();
if(input.isUnknown || output.isUnknown) return;
if(isSCToken(input.token) && this.getDirection()===SwapDirection.TO_BTC) {
this.pricingInfo = this.wrapper._prices.recomputePriceInfoSend(
this.chainIdentifier,
output.rawAmount!,
this.pricingInfo.satsBaseFee,
this.pricingInfo.feePPM,
input.rawAmount!,
input.token.address
);
this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc;
} else if(isSCToken(output.token) && this.getDirection()===SwapDirection.FROM_BTC) {
this.pricingInfo = this.wrapper._prices.recomputePriceInfoReceive(
this.chainIdentifier,
input.rawAmount!,
this.pricingInfo.satsBaseFee,
this.pricingInfo.feePPM,
output.rawAmount!,
output.token.address
);
this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc;
}
}
}
/**
* Returns the specific state along with the human-readable description of that state
*
* @internal
*/
protected _getStateInfo(state: S): SwapStateInfo<S> {
return {
state: state,
name: this.swapStateName(state),
description: this.swapStateDescription[state]
};
}
/**
* Re-fetches & revalidates the price data based on the current market prices
*/
public async refreshPriceData(): Promise<void> {
if(this.pricingInfo==null) return;
const priceUsdPerBtc = this.pricingInfo.realPriceUsdPerBitcoin;
const output = this.getOutput();
if(output.isUnknown) return;
if(isSCToken(this.getInputToken()) && this.getDirection()===SwapDirection.TO_BTC) {
const input = this.getInputWithoutFee();
if(input.isUnknown) return;
this.pricingInfo = await this.wrapper._prices.isValidAmountSend(
this.chainIdentifier,
output.rawAmount!,
this.pricingInfo.satsBaseFee,
this.pricingInfo.feePPM,
input.rawAmount! + this.swapFee,
input.token.address,
undefined,
undefined,
this.swapFeeBtc
);
this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc;
} else if(isSCToken(output.token) && this.getDirection()===SwapDirection.FROM_BTC) {
const input = this.getInput();
if(input.isUnknown) return;
this.pricingInfo = await this.wrapper._prices.isValidAmountReceive(
this.chainIdentifier,
input.rawAmount!,
this.pricingInfo.satsBaseFee,
this.pricingInfo.feePPM,
output.rawAmount!,
output.token.address,
undefined,
undefined,
this.swapFeeBtc
);
this.pricingInfo.realPriceUsdPerBitcoin = priceUsdPerBtc;
}
}
/**
* Checks if the pricing for the swap is valid, according to max allowed price difference set in the ISwapPrice
*/
public hasValidPrice(): boolean {
if(this.pricingInfo==null) throw new Error("Pricing info not found, cannot check price validity!");
return this.pricingInfo.isValid;
}
/**
* Returns pricing info about the swap
*/
public getPriceInfo(): {
marketPrice?: number,
swapPrice: number,
difference: PercentagePPM
} {
if(this.pricingInfo==null) throw new Error("Pricing info not provided and not known!");
const swapPrice = this.getDirection()===SwapDirection.TO_BTC ?
100_000_000_000_000/Number(this.pricingInfo.swapPriceUSatPerToken) :
Number(this.pricingInfo.swapPriceUSatPerToken)/100_000_000_000_000;
let marketPrice: number | undefined;
if(this.pricingInfo.realPriceUSatPerToken!=null)
marketPrice = this.getDirection()===SwapDirection.TO_BTC ?
100_000_000_000_000/Number(this.pricingInfo.realPriceUSatPerToken) :
Number(this.pricingInfo.realPriceUSatPerToken)/100_000_000_000_000;
return {
marketPrice,
swapPrice,
difference: ppmToPercentage(this.pricingInfo.differencePPM)
}
}
//////////////////////////////
//// Getters & utils
/**
* Asserts a given signer is the initiator of this swap
*
* @param signer Signer to check with this swap's initiator
* @throws {Error} When signer's address doesn't match with the swap's initiator one
* @internal
*/
protected checkSigner(signer: T["Signer"] | string): void {
if((typeof(signer)==="string" ? signer : signer.getAddress())!==this._getInitiator()) throw new Error("Invalid signer provided!");
}
/**
* Await and prepares a list of passed transactions
*
* @param txsPromise
* @internal
*/
protected async prepareTransactions(txsPromise: Promise<T["TX"][]>): Promise<T["TX"][]> {
const txs = await txsPromise;
if(this.wrapper._chain.prepareTxs==null) return txs;
return await this.wrapper._chain.prepareTxs(txs);
}
/**
* Returns an escrow hash of the swap
*
* @internal
*/
abstract _getEscrowHash(): string | null;
/**
* Checks if the swap's quote is expired for good (i.e. the swap strictly cannot be initiated anymore)
* @internal
*/
abstract _verifyQuoteDefinitelyExpired(): Promise<boolean>;
/**
* Checks if the swap's quote is still valid
* @internal
*/
abstract _verifyQuoteValid(): Promise<boolean>;
/**
* Returns the intiator address of the swap - address that created this swap
* @internal
*/
abstract _getInitiator(): string;
/**
* Sets this swap as initiated
* @internal
*/
_setInitiated(): void {
this.initiated = true;
}
/**
* Returns source address of the swap
*/
public abstract getInputAddress(): string | null;
/**
* Returns destination address of the swap
*/
public abstract getOutputAddress(): string | null;
/**
* Returns swap input transaction ID on the source chain
*/
public abstract getInputTxId(): string | null;
/**
* Returns swap output transaction ID on the destination chain
*/
public abstract getOutputTxId(): string | null;
/**
* Returns the ID of the swap, as used in the storage
*/
public abstract getId(): string;
/**
* Checks whether there is some action required from the user for this swap - can mean either refundable or claimable
*/
public abstract requiresAction(): boolean;
/**
* Returns whether the swap is finished and in its terminal state (this can mean successful, refunded or failed)
*/
public abstract isFinished(): boolean;
/**
* Checks whether the swap's quote has definitely expired and cannot be committed anymore, we can remove such swap
*/
public abstract isQuoteExpired(): boolean;
/**
* Checks whether the swap's quote is soft expired (this means there is not enough time buffer for it to commit,
* but it still can happen)
*/
public abstract isQuoteSoftExpired(): boolean;
/**
* Returns whether the swap finished successful
*/
public abstract isSuccessful(): boolean;
/**
* Returns whether the swap failed (e.g. was refunded)
*/
public abstract isFailed(): boolean;
/**
* Returns whether the swap is currently being processed
*/
public abstract isInProgress(): boolean;
/**
* Whether a swap was initialized, a swap is considered initialized on first interaction with it, i.e.
* calling commit() on a Smart chain -> Bitcoin swaps, calling waitForPayment() or similar on the other
* direction. Not initiated swaps are not saved to the persistent storage by default (see
* {@link SwapperOptions.saveUninitializedSwaps})
*/
public isInitiated(): boolean {
return this.initiated;
}
/**
* Returns quote expiry in UNIX millis
*/
public getQuoteExpiry(): number {
return this.expiry;
}
/**
* Returns the type of the swap
*/
public getType(): SwapType {
return this.TYPE;
}
/**
* Returns the direction of the swap
*/
public getDirection(): SwapDirection {
return this.TYPE===SwapType.TO_BTC || this.TYPE===SwapType.TO_BTCLN ? SwapDirection.TO_BTC : SwapDirection.FROM_BTC;
}
/**
* Returns the current state of the swap
*/
public getState(): S {
return this._state;
}
/**
* Returns the current state of the swap along with the human-readable description of the state
*/
public getStateInfo(): SwapStateInfo<S> {
return this._getStateInfo(this._state);
}
/**
* Returns a current state-dependent action for the user to execute, or `undefined` if there is no more action
* required for this swap - this means that the swap is probably finished (either expired, failed or settled).
*
* @param options Optional options argument for the additional action context (i.e. passing bitcoin wallet info to
* get funded PSBTs or passing the externally-generated swap secret), see the actual type in the respective swap
* classes
*/
public abstract getExecutionAction(options?: any): Promise<SwapExecutionAction | undefined>;
/**
* Returns a list of execution steps the user has to go through for a given swap, to see the possible execution
* steps check out {@link SwapExecutionStep}.
*
* @param options Optional options argument for the additional steps context (i.e. automatic settlement timeout),
* see the actual type in the respective swap classes
*/
public abstract getExecutionSteps(options?: any): Promise<SwapExecutionStep[]>;
/**
* Returns the current action and the full execution steps for a given swap. Prefer this to calling
* {@link getExecutionSteps} and {@link getExecutionAction} separately - if called sequentially they might
* return the respective steps/actions in different states if you hit the state transition boundary.
*
* @param options Optional options argument for the additional execution status context, see the actual type in
* the respective swap classes
*/
public abstract getExecutionStatus(options?: { skipBuildingAction?: boolean } & any): Promise<{
steps: SwapExecutionStep[],
currentAction: SwapExecutionAction | undefined,
stateInfo: SwapStateInfo<S>
}>;
/**
* Submits signed transactions obtained from the execution action back to the swap.
*
* @remarks This endpoint will also wait till the submitted transactions are confirmed (on a smart-chain side)
* and till the swap state change is observed from the authoritative chain/intermediary state.
*
* If invalid transactions are submitted, i.e. sending a simple noop or transfer transaction instead of the
* expected tx, this call may wait indefinitely unless aborted via the AbortSignal.
*
* @param txs Signed transactions
* @param abortSignal Abort signal
* @param requiredStates Optional list of states that the swap has to be in for the transactions to be
* submitted, else throws
* @param idempotent Whether the tx submission should be handled idempotently, meaning if any of the supplied
* transactions are already processed as e.g. init, claim, refund or execution transactions the function just
* returns these transaction IDs without actually submitting them
*
* @internal
*/
abstract _submitExecutionTransactions(txs: (T["SignedTXType"] | string | any)[], abortSignal?: AbortSignal, requiredStates?: S[], idempotent?: boolean): Promise<string[]>;
//////////////////////////////
//// Amounts & fees
/**
* Returns output amount of the swap, user receives this much
*/
public abstract getOutput(): TokenAmount;
/**
* Returns the output token of the swap
*/
public abstract getOutputToken(): Token<T["ChainId"]>;
/**
* Returns input amount of the swap, user needs to pay this much
*/
public abstract getInput(): TokenAmount;
/**
* Returns the input token of the swap
*/
public abstract getInputToken(): Token<T["ChainId"]>;
/**
* Returns input amount of the swap without the fees (swap fee, network fee)
*/
public abstract getInputWithoutFee(): TokenAmount;
/**
* Returns total fee for the swap, the fee is represented in source currency & destination currency, but is
* paid only once
*/
public abstract getFee(): Fee;
/**
* Returns the breakdown of all the fees paid
*/
public abstract getFeeBreakdown(): FeeBreakdown<T["ChainId"]>;
//////////////////////////////
//// Storage
/**
* Saves the swap data to the underlying storage, or removes it if it is in a quote expired state
*
* @internal
*/
_save(): Promise<void> {
if(this.isQuoteExpired()) {
return this.wrapper._removeSwapData(this);
} else {
return this.wrapper._saveSwapData(this);
}
}
/**
* Saves the swap data and also emits a swap state change
*
* @param state Optional state to set before the swap is saved an event emitted
*
* @internal
*/
async _saveAndEmit(state?: S): Promise<void> {
if(state!=null) this._state = state;
await this._save();
this._emitEvent();
}
/**
* Serializes the swap to a JSON stringifiable representation (i.e. no bigints, buffers etc.)
*/
public serialize(): any {
if(this.pricingInfo==null) return {};
return {
id: this.getId(),
type: this.getType(),
escrowHash: this._getEscrowHash(),
initiator: this._getInitiator(),
_isValid: this.pricingInfo.isValid,
_differencePPM: this.pricingInfo.differencePPM==null ? null :this.pricingInfo.differencePPM.toString(10),
_satsBaseFee: this.pricingInfo.satsBaseFee==null ? null :this.pricingInfo.satsBaseFee.toString(10),
_feePPM: this.pricingInfo.feePPM==null ? null :this.pricingInfo.feePPM.toString(10),
_realPriceUSatPerToken: this.pricingInfo.realPriceUSatPerToken==null ? null :this.pricingInfo.realPriceUSatPerToken.toString(10),
_realPriceUsdPerBitcoin: this.pricingInfo.realPriceUsdPerBitcoin,
_swapPriceUSatPerToken: this.pricingInfo.swapPriceUSatPerToken==null ? null :this.pricingInfo.swapPriceUSatPerToken.toString(10),
state: this._state,
url: this.url,
swapFee: this.swapFee==null ? null : this.swapFee.toString(10),
swapFeeBtc: this.swapFeeBtc==null ? null : this.swapFeeBtc.toString(10),
expiry: this.expiry,
version: this.version,
initiated: this.initiated,
exactIn: this.exactIn,
createdAt: this.createdAt,
randomNonce: this._randomNonce,
contractVersion: this._contractVersion,
_meta: this._meta
}
}
//////////////////////////////
//// Events
/**
* Emits a `swapState` event with the current swap
*
* @internal
*/
_emitEvent() {
this.wrapper.events.emit("swapState", this);
this.events.emit("swapState", this);
}
//////////////////////////////
//// Swap ticks & sync
/**
* Synchronizes swap state from chain and/or LP node, usually ran on startup
*
* @param save whether to save the new swap state or not
*
* @returns {boolean} true if the swap changed, false if the swap hasn't changed
*
* @internal
*/
abstract _sync(save?: boolean): Promise<boolean>;
/**
* Runs quick checks on the swap, such as checking the expiry, usually ran periodically every few seconds
*
* @param save whether to save the new swap state or not
*
* @returns {boolean} true if the swap changed, false if the swap hasn't changed
*
* @internal
*/
abstract _tick(save?: boolean): Promise<boolean>;
}