UNPKG

@atomiqlabs/sdk

Version:

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

1,209 lines (1,102 loc) 55.3 kB
import {IToBTCDefinition, IToBTCWrapper} from "./IToBTCWrapper"; import { ChainType, isAbstractSigner, SignatureData, SignatureVerificationError, SwapCommitState, SwapCommitStateType, SwapData } from "@atomiqlabs/base"; import { IntermediaryAPI, RefundAuthorizationResponse, RefundAuthorizationResponseCodes } from "../../../intermediaries/apis/IntermediaryAPI"; import {IntermediaryError} from "../../../errors/IntermediaryError"; import {extendAbortController, toBigInt} from "../../../utils/Utils"; import {Fee} from "../../../types/fees/Fee"; import {IEscrowSelfInitSwap, IEscrowSelfInitSwapInit, isIEscrowSelfInitSwapInit} from "../IEscrowSelfInitSwap"; import {IRefundableSwap} from "../../IRefundableSwap"; import {FeeType} from "../../../enums/FeeType"; import {ppmToPercentage} from "../../../types/fees/PercentagePPM"; import {TokenAmount, toTokenAmount} from "../../../types/TokenAmount"; import {BtcToken, SCToken} from "../../../types/Token"; import {timeoutPromise} from "../../../utils/TimeoutUtils"; import {SwapExecutionActionSignSmartChainTx, SwapExecutionActionWait} from "../../../types/SwapExecutionAction"; import { SwapExecutionStep, SwapExecutionStepPayment, SwapExecutionStepRefund, SwapExecutionStepSettlement } from "../../../types/SwapExecutionStep"; import {SwapStateInfo} from "../../../types/SwapStateInfo"; export type IToBTCSwapInit<T extends SwapData> = IEscrowSelfInitSwapInit<T> & { signatureData?: SignatureData, data: T, networkFee: bigint, networkFeeBtc: bigint }; export function isIToBTCSwapInit<T extends SwapData>(obj: any): obj is IToBTCSwapInit<T> { return typeof(obj.networkFee) === "bigint" && typeof(obj.networkFeeBtc) === "bigint" && (obj.signatureData==null || ( typeof(obj.signatureData) === 'object' && typeof(obj.signatureData.prefix)==="string" && typeof(obj.signatureData.timeout)==="string" && typeof(obj.signatureData.signature)==="string" )) && typeof(obj.data) === 'object' && isIEscrowSelfInitSwapInit<T>(obj); } /** * State enum for escrow-based Smart chain -> Bitcoin (on-chain & lightning) swaps * * @category Swaps/Smart chain → Bitcoin */ export enum ToBTCSwapState { /** * Intermediary (LP) was unable to process the swap and the funds were refunded on the * source chain */ REFUNDED = -3, /** * Swap has expired for good and there is no way how it can be executed anymore */ QUOTE_EXPIRED = -2, /** * A swap is almost expired, and it should be presented to the user as expired, though * there is still a chance that it will be processed */ QUOTE_SOFT_EXPIRED = -1, /** * Swap was created, use the {@link IToBTCSwap.commit} or {@link IToBTCSwap.txsCommit} to * initiate it by creating the swap escrow on the source chain */ CREATED = 0, /** * Swap escrow was initiated (committed) on the source chain, the intermediary (LP) will * now process the swap. You can wait till that happens with the {@link IToBTCSwap.waitForPayment} * function. */ COMMITED = 1, /** * The intermediary (LP) has processed the transaction and sent out the funds on the destination chain, * but hasn't yet settled the escrow on the source chain. */ SOFT_CLAIMED = 2, /** * Swap was successfully settled by the intermediary (LP) on the source chain */ CLAIMED = 3, /** * Intermediary (LP) was unable to process the swap and the swap escrow on the source chain * is refundable, call {@link IToBTCSwap.refund} or {@link IToBTCSwap.txsRefund} to refund */ REFUNDABLE = 4 } const ToBTCSwapStateDescription = { [ToBTCSwapState.REFUNDED]: "Intermediary (LP) was unable to process the swap and the funds were refunded on the source chain", [ToBTCSwapState.QUOTE_EXPIRED]: "Swap has expired for good and there is no way how it can be executed anymore", [ToBTCSwapState.QUOTE_SOFT_EXPIRED]: "A swap is expired, though there is still a chance that it will be processed", [ToBTCSwapState.CREATED]: "Swap was created, initiate it by creating the swap escrow on the source chain", [ToBTCSwapState.COMMITED]: "Swap escrow was initiated (committed) on the source chain, the intermediary (LP) will now process the swap.", [ToBTCSwapState.SOFT_CLAIMED]: "The intermediary (LP) has processed the transaction and sent out the funds on the destination chain, but hasn't yet settled the escrow on the source chain.", [ToBTCSwapState.CLAIMED]: "Swap was successfully settled by the intermediary (LP) on the source chain", [ToBTCSwapState.REFUNDABLE]: "Intermediary (LP) was unable to process the swap and the swap escrow on the source chain is refundable." }; /** * Base class for escrow-based Smart chain -> Bitcoin (on-chain & lightning) swaps * * @category Swaps/Smart chain → Bitcoin */ export abstract class IToBTCSwap< T extends ChainType = ChainType, D extends IToBTCDefinition<T, IToBTCWrapper<T, D>, IToBTCSwap<T, D>> = IToBTCDefinition<T, IToBTCWrapper<T, any>, IToBTCSwap<T, any>>, > extends IEscrowSelfInitSwap<T, D, ToBTCSwapState> implements IRefundableSwap<T, D, ToBTCSwapState> { /** * @internal */ protected readonly swapStateDescription = ToBTCSwapStateDescription; /** * @internal */ protected readonly swapStateName = (state: number) => ToBTCSwapState[state]; /** * @internal */ protected readonly abstract outputToken: BtcToken; /** * @internal */ protected readonly networkFee: bigint; /** * @internal */ protected networkFeeBtc: bigint; /** * @internal */ readonly _data!: T["Data"]; protected constructor(wrapper: D["Wrapper"], serializedObject: any); protected constructor(wrapper: D["Wrapper"], init: IToBTCSwapInit<T["Data"]>); protected constructor( wrapper: D["Wrapper"], initOrObject: IToBTCSwapInit<T["Data"]> | any ) { super(wrapper, initOrObject); if(isIToBTCSwapInit<T["Data"]>(initOrObject)) { this._state = ToBTCSwapState.CREATED; this.networkFee = initOrObject.networkFee; this.networkFeeBtc = initOrObject.networkFeeBtc; this._data = initOrObject.data; this.signatureData = initOrObject.signatureData; } else { this.networkFee = toBigInt(initOrObject.networkFee); this.networkFeeBtc = toBigInt(initOrObject.networkFeeBtc); } } /** * @inheritDoc * @internal */ protected getSwapData(): T["Data"] { return this._data; } /** * @inheritDoc * @internal */ protected upgradeVersion() { if(this.version == null) { switch(this._state) { case -2: this._state = ToBTCSwapState.REFUNDED break; case -1: this._state = ToBTCSwapState.QUOTE_EXPIRED break; case 0: this._state = ToBTCSwapState.CREATED break; case 1: this._state = ToBTCSwapState.COMMITED break; case 2: this._state = ToBTCSwapState.CLAIMED break; case 3: this._state = ToBTCSwapState.REFUNDABLE break; } this.version = 1; } } /** * @inheritDoc * @internal */ protected tryRecomputeSwapPrice() { const output = this.getOutput(); if(output.rawAmount!=null) { if(this.swapFeeBtc==null) { this.swapFeeBtc = this.swapFee * output.rawAmount / this.getInputWithoutFee().rawAmount; } if(this.networkFeeBtc==null) { this.networkFeeBtc = this.networkFee * output.rawAmount / this.getInputWithoutFee().rawAmount; } } super.tryRecomputeSwapPrice(); } /** * Returns the payment hash identifier to be sent to the LP for getStatus and getRefund * @internal */ protected getLpIdentifier(): string { return this.getClaimHash(); } /** * Sets the payment result for the swap, optionally also checking it (checking that tx exist or swap secret is valid) * * @param result Result returned by the LP * @param check Whether to check the passed result * @returns true if check passed, false if check failed with a soft error (e.g. tx not yet found in the mempool) * @throws {IntermediaryError} When the data returned by the intermediary isn't valid * * @internal */ abstract _setPaymentResult(result: {secret?: string, txId?: string}, check?: boolean): Promise<boolean>; ////////////////////////////// //// Getters & utils /** * @inheritDoc */ getInputAddress(): string | null { return this._getInitiator(); } /** * @inheritDoc */ getInputTxId(): string | null { return this._commitTxId ?? null; } /** * @inheritDoc */ requiresAction(): boolean { return this.isRefundable(); } /** * @inheritDoc */ isFinished(): boolean { return this._state===ToBTCSwapState.CLAIMED || this._state===ToBTCSwapState.REFUNDED || this._state===ToBTCSwapState.QUOTE_EXPIRED; } /** * @inheritDoc */ isRefundable(): boolean { return this._state===ToBTCSwapState.REFUNDABLE; } /** * @inheritDoc */ isQuoteExpired(): boolean { return this._state===ToBTCSwapState.QUOTE_EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired(): boolean { return this._state===ToBTCSwapState.QUOTE_EXPIRED || this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED; } /** * @inheritDoc */ isSuccessful(): boolean { return this._state===ToBTCSwapState.CLAIMED; } /** * @inheritDoc */ isFailed(): boolean { return this._state===ToBTCSwapState.REFUNDED; } /** * @inheritDoc */ isInProgress(): boolean { return this._state===ToBTCSwapState.COMMITED || this._state===ToBTCSwapState.SOFT_CLAIMED; } /** * Returns the time (in UNIX milliseconds) at which the swap expires and the user is able to unilaterally * refund it with the {@link refund} or {@link txsRefund} function. */ getExpiry(): number { return Number(this._data.getExpiry())*1000; } /** * @inheritDoc * @internal */ _getInitiator(): string { return this._data.getOfferer(); } ////////////////////////////// //// Amounts & fees /** * Returns the swap fee charged by the intermediary (LP) on this swap * * @internal */ protected getSwapFee(): Fee<T["ChainId"], SCToken<T["ChainId"]>, BtcToken> { if(this.pricingInfo==null) throw new Error("No pricing info known, cannot estimate fee!"); const feeWithoutBaseFee = this.swapFeeBtc - this.pricingInfo.satsBaseFee; const output = this.getOutput(); const swapFeePPM = output.rawAmount==null ? 0n : feeWithoutBaseFee * 1000000n / output.rawAmount; const amountInDstToken = toTokenAmount( this.swapFeeBtc, this.outputToken, this.wrapper._prices, this.pricingInfo ); return { amountInSrcToken: toTokenAmount(this.swapFee, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo), amountInDstToken, currentUsdValue: amountInDstToken.currentUsdValue, usdValue: amountInDstToken.usdValue, pastUsdValue: amountInDstToken.pastUsdValue, composition: { base: toTokenAmount(this.pricingInfo.satsBaseFee, this.outputToken, this.wrapper._prices, this.pricingInfo), percentage: ppmToPercentage(swapFeePPM) } }; } /** * Returns network fee for on the destination chain for the swap * * @internal */ protected getNetworkFee(): Fee<T["ChainId"], SCToken<T["ChainId"]>, BtcToken> { const amountInDstToken = toTokenAmount( this.networkFeeBtc, this.outputToken, this.wrapper._prices, this.pricingInfo ); return { amountInSrcToken: toTokenAmount( this.networkFee, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo ), amountInDstToken, currentUsdValue: amountInDstToken.currentUsdValue, usdValue: amountInDstToken.usdValue, pastUsdValue: amountInDstToken.pastUsdValue }; } /** * @inheritDoc */ getFee(): Fee<T["ChainId"], SCToken<T["ChainId"]>, BtcToken> { const amountInDstToken = toTokenAmount( this.swapFeeBtc + this.networkFeeBtc, this.outputToken, this.wrapper._prices, this.pricingInfo ); return { amountInSrcToken: toTokenAmount( this.swapFee + this.networkFee, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo ), amountInDstToken, currentUsdValue: amountInDstToken.currentUsdValue, usdValue: amountInDstToken.usdValue, pastUsdValue: amountInDstToken.pastUsdValue } } /** * @inheritDoc */ getFeeBreakdown(): [ {type: FeeType.SWAP, fee: Fee<T["ChainId"], SCToken<T["ChainId"]>, BtcToken>}, {type: FeeType.NETWORK_OUTPUT, fee: Fee<T["ChainId"], SCToken<T["ChainId"]>, BtcToken>}, ] { return [ { type: FeeType.SWAP, fee: this.getSwapFee() }, { type: FeeType.NETWORK_OUTPUT, fee: this.getNetworkFee() } ]; } /** * @inheritDoc */ getInputToken(): SCToken<T["ChainId"]> { return this.wrapper._tokens[this._data.getToken()]; } /** * @inheritDoc */ getInput(): TokenAmount<SCToken<T["ChainId"]>, true> { return toTokenAmount( this._data.getAmount(), this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo ); } /** * @inheritDoc */ getInputWithoutFee(): TokenAmount<SCToken<T["ChainId"]>, true> { return toTokenAmount( this._data.getAmount() - (this.swapFee + this.networkFee), this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo ); } /** * Checks if the initiator/sender on the source chain has enough balance to go through with the swap */ async hasEnoughBalance(): Promise<{ enoughBalance: boolean, balance: TokenAmount<SCToken<T["ChainId"]>, true>, required: TokenAmount<SCToken<T["ChainId"]>, true> }> { const [balance, commitFee] = await Promise.all([ this._contract.getBalance(this._getInitiator(), this._data.getToken(), false), this._data.getToken()===this.wrapper._chain.getNativeCurrencyAddress() ? this.getCommitFee() : Promise.resolve(null) ]); let required = this._data.getAmount(); if(commitFee!=null) required = required + commitFee; return { enoughBalance: balance >= required, balance: toTokenAmount(balance, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo), required: toTokenAmount(required, this.wrapper._tokens[this._data.getToken()], this.wrapper._prices, this.pricingInfo) }; } /** * Checks if the initiator/sender on the source chain has enough native token balance * to cover the transaction fee of initiating the swap */ async hasEnoughForTxFees(): Promise<{ enoughBalance: boolean, balance: TokenAmount<SCToken<T["ChainId"]>, true>, required: TokenAmount<SCToken<T["ChainId"]>, true> }> { const [balance, commitFee] = await Promise.all([ this._contract.getBalance(this._getInitiator(), this.wrapper._chain.getNativeCurrencyAddress(), false), this.getCommitFee() ]); return { enoughBalance: balance >= commitFee, balance: toTokenAmount(balance, this.wrapper._getNativeToken(), this.wrapper._prices), required: toTokenAmount(commitFee, this.wrapper._getNativeToken(), this.wrapper._prices) }; } ////////////////////////////// //// Execution /** * Executes the swap with the provided smart chain wallet/signer * * @param signer Smart chain wallet/signer to use to sign the transaction on the source chain * @param callbacks Callbacks to track the progress of the swap * @param options Optional options for the swap like feeRate, AbortSignal, and timeouts/intervals * * @returns {boolean} Whether the swap was successfully processed by the LP, in case `false` is returned * the user can refund their funds back on the source chain by calling {@link refund} */ async execute( signer: T["Signer"] | T["NativeSigner"], callbacks?: { onSourceTransactionSent?: (sourceTxId: string) => void, onSourceTransactionConfirmed?: (sourceTxId: string) => void, onSwapSettled?: (destinationTxId: string) => void }, options?: { abortSignal?: AbortSignal, paymentCheckIntervalSeconds?: number, maxWaitTillSwapProcessedSeconds?: number } ): Promise<boolean> { if(this._state===ToBTCSwapState.QUOTE_EXPIRED || this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Quote expired"); if(this._state===ToBTCSwapState.REFUNDED) throw new Error("Swap already refunded"); if(this._state===ToBTCSwapState.REFUNDABLE) throw new Error("Swap refundable, refund with swap.refund()"); if(this._state===ToBTCSwapState.SOFT_CLAIMED || this._state===ToBTCSwapState.CLAIMED) throw new Error("Swap already settled!"); if(this._state===ToBTCSwapState.CREATED) { const txId = await this.commit(signer, options?.abortSignal, false, callbacks?.onSourceTransactionSent); if(callbacks?.onSourceTransactionConfirmed!=null) callbacks.onSourceTransactionConfirmed(txId); } // @ts-ignore if(this._state===ToBTCSwapState.CLAIMED || this._state===ToBTCSwapState.SOFT_CLAIMED) return true; if(this._state===ToBTCSwapState.COMMITED) { const success = await this.waitForPayment(options?.maxWaitTillSwapProcessedSeconds ?? 120, options?.paymentCheckIntervalSeconds, options?.abortSignal); if(success) { if(callbacks?.onSwapSettled!=null) callbacks.onSwapSettled(this.getOutputTxId()!); return true; } else { return false; } } throw new Error("Unexpected state reached!"); } /** * @internal */ protected async _getExecutionStatus() { const state = this._state; let sourcePaymentStatus: SwapExecutionStepPayment<T["ChainId"]>["status"] = "inactive"; let destinationPayoutStatus: SwapExecutionStepSettlement<"BITCOIN" | "LIGHTNING", "soft_settled">["status"] = "inactive"; let refundStatus: SwapExecutionStepRefund<T["ChainId"]>["status"] = "inactive"; let buildCurrentAction: (actionOptions?: { skipChecks?: boolean, refundSmartChainSigner?: string | T["Signer"] | T["NativeSigner"] }) => Promise< SwapExecutionActionSignSmartChainTx<T> | SwapExecutionActionWait<"LP"> | undefined > = async () => undefined; switch(state) { case ToBTCSwapState.CREATED: const quoteValid = await this._verifyQuoteValid(); sourcePaymentStatus = quoteValid ? "awaiting" : "soft_expired"; if(this.signatureData!=null && quoteValid) { buildCurrentAction = this._buildInitSmartChainTxAction.bind(this); } break; case ToBTCSwapState.QUOTE_SOFT_EXPIRED: sourcePaymentStatus = "soft_expired"; break; case ToBTCSwapState.QUOTE_EXPIRED: sourcePaymentStatus = "expired"; break; case ToBTCSwapState.COMMITED: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "waiting_lp"; buildCurrentAction = this._buildWaitLpAction.bind(this); break; case ToBTCSwapState.SOFT_CLAIMED: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "soft_settled"; break; case ToBTCSwapState.CLAIMED: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "settled"; break; case ToBTCSwapState.REFUNDABLE: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "expired"; refundStatus = "awaiting"; buildCurrentAction = this._buildRefundSmartChainTxAction.bind(this); break; case ToBTCSwapState.REFUNDED: sourcePaymentStatus = "confirmed"; destinationPayoutStatus = "expired"; refundStatus = "refunded"; break; } return { steps: [ { type: "Payment", side: "source", chain: this.chainIdentifier, title: "Source payment", description: `Initiate the swap by funding the escrow on the ${this.chainIdentifier} side`, status: sourcePaymentStatus, initTxId: this._commitTxId, settleTxId: this._claimTxId }, { type: "Settlement", side: "destination", chain: this.outputToken.chainId, title: "Destination payout", description: `Wait for the LP to process the swap and send the payout on the ${this.outputToken.chainId} side`, status: destinationPayoutStatus, initTxId: destinationPayoutStatus==="settled" || destinationPayoutStatus==="soft_settled" ? this.getOutputTxId() : undefined, settleTxId: destinationPayoutStatus==="settled" ? this.getOutputTxId() : undefined, }, { type: "Refund", side: "source", chain: this.chainIdentifier, title: "Source refund", description: `Refund escrowed funds on the ${this.chainIdentifier} side, after LP failed to execute`, status: refundStatus, refundTxId: this._refundTxId } ] as [ SwapExecutionStepPayment<T["ChainId"]>, SwapExecutionStepSettlement<"BITCOIN" | "LIGHTNING", "soft_settled">, SwapExecutionStepRefund<T["ChainId"]>, ], buildCurrentAction, state }; } /** * @inheritDoc * @internal */ async _submitExecutionTransactions(txs: (T["SignedTXType"] | string)[], abortSignal?: AbortSignal, requiredStates?: ToBTCSwapState[], idempotent?: boolean): Promise<string[]> { const parsedTxs: T["SignedTXType"][] = []; for(let tx of txs) { parsedTxs.push(typeof(tx)==="string" ? await this.wrapper._chain.deserializeSignedTx(tx) : tx); } if(idempotent) { // Handle idempotent calls if(this.wrapper._chain.getTxId!=null) { const txIds = await Promise.all(parsedTxs.map(tx => this.wrapper._chain.getTxId!(tx))); const foundTxId = txIds.find(txId => this._commitTxId===txId || this._refundTxId===txId); if(foundTxId!=null) return txIds; } } if(requiredStates!=null && !requiredStates.includes(this._state)) throw new Error("Swap state has changed before transactions were submitted!"); if(this._state===ToBTCSwapState.CREATED || this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED) { if(!await this._verifyQuoteValid()) throw new Error("Quote is already expired!"); const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false); await this.waitTillCommited(abortSignal); return txIds; } if(this._state===ToBTCSwapState.REFUNDABLE) { const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false); await this.waitTillRefunded(abortSignal); return txIds; } throw new Error("Invalid swap state for transaction submission!"); } /** * @internal */ private async _buildInitSmartChainTxAction(actionOptions?: { skipChecks?: boolean }): Promise<SwapExecutionActionSignSmartChainTx> { return { type: "SignSmartChainTransaction", name: "Initiate swap", description: `Initiates the swap by commiting the funds to the escrow on the ${this.chainIdentifier} side`, chain: this.chainIdentifier, txs: await this.prepareTransactions(this.txsCommit(actionOptions?.skipChecks)), submitTransactions: async (txs: (T["SignedTXType"] | string)[], abortSignal?: AbortSignal, idempotent?: boolean) => { return this._submitExecutionTransactions(txs, abortSignal, [ToBTCSwapState.CREATED, ToBTCSwapState.QUOTE_SOFT_EXPIRED], idempotent); }, requiredSigner: this._getInitiator() } as SwapExecutionActionSignSmartChainTx; } /** * @internal */ private async _buildWaitLpAction(): Promise<SwapExecutionActionWait<"LP">> { return { type: "Wait", name: "Awaiting LP payout", description: "Wait for the intermediary to process the swap and either send the payout or make the swap refundable", pollTimeSeconds: 5, expectedTimeSeconds: 10, wait: async ( maxWaitTimeSeconds?: number, pollIntervalSeconds?: number, abortSignal?: AbortSignal ) => { await this.waitForPayment(maxWaitTimeSeconds, pollIntervalSeconds, abortSignal); } } as SwapExecutionActionWait<"LP">; } /** * @internal */ private async _buildRefundSmartChainTxAction(actionOptions?: { refundSmartChainSigner?: string | T["Signer"] | T["NativeSigner"] }): Promise<SwapExecutionActionSignSmartChainTx> { const signerAddress = await this.wrapper._getSignerAddress(actionOptions?.refundSmartChainSigner); return { type: "SignSmartChainTransaction", name: "Refund", description: "Refund the swap after it failed to execute", chain: this.chainIdentifier, txs: await this.prepareTransactions(this.txsRefund(actionOptions?.refundSmartChainSigner)), submitTransactions: async (txs: (T["SignedTXType"] | string)[], abortSignal?: AbortSignal, idempotent?: boolean) => { return this._submitExecutionTransactions(txs, abortSignal, [ToBTCSwapState.REFUNDABLE], idempotent); }, requiredSigner: signerAddress ?? this._getInitiator() } as SwapExecutionActionSignSmartChainTx; } /** * @inheritDoc * * @param options.skipChecks Skip checks like making sure init signature is still valid and swap wasn't commited yet * (this is handled on swap creation, if you commit right after quoting, you can use `skipChecks=true`) * @param options.refundSmartChainSigner Optional smart chain signer to use when creating refunds transactions */ async getExecutionAction(options?: { skipChecks?: boolean, refundSmartChainSigner?: string | T["Signer"] | T["NativeSigner"] }): Promise< SwapExecutionActionSignSmartChainTx<T> | SwapExecutionActionWait<"LP"> | undefined > { const executionStatus = await this._getExecutionStatus(); return executionStatus.buildCurrentAction(options); } /** * @inheritDoc */ async getExecutionStatus(options?: { skipBuildingAction?: boolean, skipChecks?: boolean, refundSmartChainSigner?: string | T["Signer"] | T["NativeSigner"] }): Promise<{ steps: [ SwapExecutionStepPayment<T["ChainId"]>, SwapExecutionStepSettlement<"BITCOIN" | "LIGHTNING", "soft_settled">, SwapExecutionStepRefund<T["ChainId"]>, ], currentAction: SwapExecutionActionSignSmartChainTx<T> | SwapExecutionActionWait<"LP"> | undefined, stateInfo: SwapStateInfo<ToBTCSwapState> }> { const executionStatus = await this._getExecutionStatus(); return { steps: executionStatus.steps, currentAction: options?.skipBuildingAction ? undefined : await executionStatus.buildCurrentAction(options), stateInfo: this._getStateInfo(executionStatus.state) }; } /** * @inheritDoc */ async getExecutionSteps(): Promise<[ SwapExecutionStepPayment<T["ChainId"]>, SwapExecutionStepSettlement<"BITCOIN" | "LIGHTNING", "soft_settled">, SwapExecutionStepRefund<T["ChainId"]>, ]> { return (await this._getExecutionStatus()).steps; } ////////////////////////////// //// Commit /** * @inheritDoc * * @throws {Error} When in invalid state (not {@link ToBTCSwapState.CREATED}) */ async txsCommit(skipChecks?: boolean): Promise<T["TX"][]> { if(this._state!==ToBTCSwapState.CREATED && (!skipChecks || this._state!==ToBTCSwapState.QUOTE_SOFT_EXPIRED)) throw new Error("Must be in CREATED state!"); if(this.signatureData==null) throw new Error("Init signature data not known, cannot commit!"); if(!this.initiated) { this.initiated = true; await this._saveAndEmit(); } return await this._contract.txsInit( this._getInitiator(), this._data, this.signatureData, skipChecks, this.feeRate ).catch(e => Promise.reject(e instanceof SignatureVerificationError ? new Error("Request timed out") : e)); } /** * @inheritDoc * * @throws {Error} If invalid signer is provided that doesn't match the swap data */ async commit(_signer: T["Signer"] | T["NativeSigner"], abortSignal?: AbortSignal, skipChecks?: boolean, onBeforeTxSent?: (txId: string) => void): Promise<string> { const signer = isAbstractSigner(_signer) ? _signer : await this.wrapper._chain.wrapSigner(_signer); this.checkSigner(signer); const txs = await this.txsCommit(skipChecks); let txCount = 0; const result = await this.wrapper._chain.sendAndConfirm( signer, txs, true, abortSignal, false, (txId, rawTx) => { txCount++; if(onBeforeTxSent!=null && txCount===txs.length) onBeforeTxSent(txId); return Promise.resolve(); } ); this._commitTxId = result[result.length-1]; if(this._state===ToBTCSwapState.CREATED || this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state===ToBTCSwapState.QUOTE_EXPIRED) { await this._saveAndEmit(ToBTCSwapState.COMMITED); } return this._commitTxId; } /** * @inheritDoc * * @throws {Error} If swap is not in the correct state (must be {@link ToBTCSwapState.CREATED}) */ async waitTillCommited(abortSignal?: AbortSignal): Promise<void> { if(this._state===ToBTCSwapState.COMMITED || this._state===ToBTCSwapState.CLAIMED) return Promise.resolve(); if(this._state!==ToBTCSwapState.CREATED && this._state!==ToBTCSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Invalid state (not CREATED)"); const abortController = extendAbortController(abortSignal); let result: SwapCommitState | number | null; try { result = await Promise.race([ this.watchdogWaitTillCommited(undefined, abortController.signal), this.waitTillState(ToBTCSwapState.COMMITED, "gte", abortController.signal).then(() => 0) ]); abortController.abort(); } catch (e) { abortController.abort(); throw e; } if(result===0) { this.logger.debug("waitTillCommited(): Resolved from state change"); } else if(result!=null) { this.logger.debug("waitTillCommited(): Resolved from watchdog - commited"); } if(result===null) { this.logger.debug("waitTillCommited(): Resolved from watchdog - signature expiry"); if(this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state===ToBTCSwapState.CREATED) { await this._saveAndEmit(ToBTCSwapState.QUOTE_EXPIRED); } throw new Error("Quote expired while waiting for transaction confirmation!"); } if(this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state===ToBTCSwapState.CREATED || this._state===ToBTCSwapState.QUOTE_EXPIRED) { if(typeof(result)==="object" && (result as any).getInitTxId!=null && this._commitTxId==null) this._commitTxId = await (result as any).getInitTxId(); await this._saveAndEmit(ToBTCSwapState.COMMITED); } } ////////////////////////////// //// Payment /** * Waits till the swap is processed by the intermediary (LP) * * @param checkIntervalSeconds How often to poll the intermediary for status (5 seconds default) * @param abortSignal Abort signal * @internal */ protected async waitTillIntermediarySwapProcessed( checkIntervalSeconds?: number, abortSignal?: AbortSignal ): Promise<RefundAuthorizationResponse> { if(this.url==null) throw new Error("LP URL not specified!"); checkIntervalSeconds ??= 5; let resp: RefundAuthorizationResponse = {code: RefundAuthorizationResponseCodes.PENDING, msg: ""}; while(!abortSignal?.aborted && ( resp.code===RefundAuthorizationResponseCodes.PENDING || resp.code===RefundAuthorizationResponseCodes.NOT_FOUND )) { resp = await this.wrapper._lpApi.getRefundAuthorization(this.url, this.getLpIdentifier(), this._data.getSequence()); if(resp.code===RefundAuthorizationResponseCodes.PAID) { const validResponse = await this._setPaymentResult(resp.data, true); if(validResponse) { if(this._state===ToBTCSwapState.COMMITED || this._state===ToBTCSwapState.REFUNDABLE) { await this._saveAndEmit(ToBTCSwapState.SOFT_CLAIMED); } } else { resp = {code: RefundAuthorizationResponseCodes.PENDING, msg: ""}; } } if( resp.code===RefundAuthorizationResponseCodes.PENDING || resp.code===RefundAuthorizationResponseCodes.NOT_FOUND ) await timeoutPromise(checkIntervalSeconds*1000, abortSignal); } return resp; } /** * Checks whether the swap was already processed by the LP and is either successful (requires proof which is * either a HTLC pre-image for LN swaps or valid txId for on-chain swap) or failed and we can cooperatively * refund. * * @param save whether to save the data * @returns `true` if swap is processed, `false` if the swap is still ongoing * * @internal */ protected async checkIntermediarySwapProcessed(save: boolean = true): Promise<boolean> { if(this._state===ToBTCSwapState.CREATED || this._state==ToBTCSwapState.QUOTE_EXPIRED || this.url==null) return false; if(this.isFinished() || this.isRefundable()) return true; //Check if that maybe already concluded according to the LP const resp = await this.wrapper._lpApi.getRefundAuthorization(this.url, this.getLpIdentifier(), this._data.getSequence()); switch(resp.code) { case RefundAuthorizationResponseCodes.PAID: const processed = await this._setPaymentResult(resp.data, true); if(processed) { this._state = ToBTCSwapState.SOFT_CLAIMED; if(save) await this._saveAndEmit(); } return processed; case RefundAuthorizationResponseCodes.REFUND_DATA: await this._contract.isValidRefundAuthorization(this._data, resp.data); this._state = ToBTCSwapState.REFUNDABLE; if(save) await this._saveAndEmit(); return true; default: return false; } } /** * A blocking promise resolving when swap was concluded by the intermediary (LP), * rejecting in case of failure * * @param maxWaitTimeSeconds Maximum time in seconds to wait for the swap to be settled, an error is thrown if the * swap is taking too long to claim * @param checkIntervalSeconds How often to poll the intermediary for answer * @param abortSignal Abort signal * @returns `true` if swap was successful, `false` if swap failed and we can refund * * @throws {IntermediaryError} If a swap is determined expired by the intermediary, but it is actually still valid * @throws {SignatureVerificationError} If the swap should be cooperatively refundable but the intermediary returned * invalid refund signature * @throws {Error} When swap expires or if the swap has invalid state (must be {@link ToBTCSwapState.COMMITED}) */ async waitForPayment(maxWaitTimeSeconds?: number, checkIntervalSeconds?: number, abortSignal?: AbortSignal): Promise<boolean> { if(this._state===ToBTCSwapState.CLAIMED) return Promise.resolve(true); if(this._state!==ToBTCSwapState.COMMITED && this._state!==ToBTCSwapState.SOFT_CLAIMED) throw new Error("Invalid state (not COMMITED)"); const abortController = extendAbortController(abortSignal); let timedOut: boolean = false; if(maxWaitTimeSeconds!=null) { const timeout = setTimeout(() => { timedOut = true; abortController.abort(); }, maxWaitTimeSeconds * 1000); abortController.signal.addEventListener("abort", () => clearTimeout(timeout)); } let result: void | RefundAuthorizationResponse; try { result = await Promise.race([ this.waitTillState(ToBTCSwapState.CLAIMED, "gte", abortController.signal), this.waitTillIntermediarySwapProcessed(checkIntervalSeconds, abortController.signal) ]); abortController.abort(); } catch (e) { abortController.abort(); if(timedOut) { throw new Error("Timed out while waiting for LP to process the swap, the LP might be unresponsive or offline!" + ` Please check later or wait till ${new Date(Number(this._data.getExpiry())*1000).toLocaleString()} to refund unilaterally!`); } throw e; } if(typeof result !== "object") { if((this._state as ToBTCSwapState)===ToBTCSwapState.REFUNDABLE) throw new Error("Swap expired"); this.logger.debug("waitTillRefunded(): Resolved from state change"); return true; } this.logger.debug("waitTillRefunded(): Resolved from intermediary response"); switch(result.code) { case RefundAuthorizationResponseCodes.PAID: return true; case RefundAuthorizationResponseCodes.REFUND_DATA: const resultData = result.data; await this._contract.isValidRefundAuthorization( this._data, resultData ); await this._saveAndEmit(ToBTCSwapState.REFUNDABLE); return false; case RefundAuthorizationResponseCodes.EXPIRED: if(await this._contract.isExpired(this._getInitiator(), this._data)) throw new Error("Swap expired"); throw new IntermediaryError("Swap expired"); case RefundAuthorizationResponseCodes.NOT_FOUND: if((this._state as ToBTCSwapState)===ToBTCSwapState.CLAIMED) return true; throw new Error("LP swap not found"); } throw new Error("Invalid response code returned by the LP"); } ////////////////////////////// //// Refund /** * Get the estimated smart chain transaction fee of the refund transaction */ async getRefundNetworkFee(): Promise<TokenAmount<SCToken<T["ChainId"]>, true>> { const swapContract: T["Contract"] = this._contract; return toTokenAmount( await swapContract.getRefundFee(this._getInitiator(), this._data), this.wrapper._getNativeToken(), this.wrapper._prices ); } /** * @inheritDoc * * @throws {IntermediaryError} If intermediary returns invalid response in case cooperative refund should be used * @throws {SignatureVerificationError} If intermediary returned invalid cooperative refund signature * @throws {Error} When state is not refundable */ async txsRefund(_signer?: string | T["Signer"] | T["NativeSigner"]): Promise<T["TX"][]> { if(!this.isRefundable()) throw new Error("Must be in REFUNDABLE state or expired!"); let signer: string; if(_signer!=null) { if (typeof (_signer) === "string") { signer = _signer; } else if (isAbstractSigner(_signer)) { signer = _signer.getAddress(); } else { signer = (await this.wrapper._chain.wrapSigner(_signer)).getAddress(); } } else { signer = this._getInitiator(); } if(await this._contract.isExpired(this._getInitiator(), this._data)) { return await this._contract.txsRefund(signer, this._data, true, true); } else { if(this.url==null) throw new Error("LP URL not known, cannot get cooperative refund message, wait till expiry to refund!"); const res = await this.wrapper._lpApi.getRefundAuthorization(this.url, this.getLpIdentifier(), this._data.getSequence()); if(res.code===RefundAuthorizationResponseCodes.REFUND_DATA) { return await this._contract.txsRefundWithAuthorization( signer, this._data, res.data, true, true ); } throw new IntermediaryError("Invalid intermediary cooperative message returned"); } } /** * @inheritDoc * * @throws {Error} If invalid signer is provided that doesn't match the swap data */ async refund(_signer: T["Signer"] | T["NativeSigner"], abortSignal?: AbortSignal): Promise<string> { const signer = isAbstractSigner(_signer) ? _signer : await this.wrapper._chain.wrapSigner(_signer); const result = await this.wrapper._chain.sendAndConfirm(signer, await this.txsRefund(signer.getAddress()), true, abortSignal) this._refundTxId = result[0]; if(this._state===ToBTCSwapState.COMMITED || this._state===ToBTCSwapState.REFUNDABLE || this._state===ToBTCSwapState.SOFT_CLAIMED) { await this._saveAndEmit(ToBTCSwapState.REFUNDED); } return result[0]; } /** * @inheritDoc * * @throws {Error} When swap is not in a valid state (must be {@link ToBTCSwapState.COMMITED} or * {@link ToBTCSwapState.REFUNDABLE}) * @throws {Error} If we tried to refund but claimer was able to claim first */ async waitTillRefunded(abortSignal?: AbortSignal): Promise<void> { if(this._state===ToBTCSwapState.REFUNDED) return Promise.resolve(); if( this._state!==ToBTCSwapState.COMMITED && this._state!==ToBTCSwapState.SOFT_CLAIMED && this._state!==ToBTCSwapState.REFUNDABLE ) throw new Error("Invalid state (not COMMITED)"); const abortController = new AbortController(); if(abortSignal!=null) abortSignal.addEventListener("abort", () => abortController.abort(abortSignal.reason)); const res = await Promise.race([ this.watchdogWaitTillResult(undefined, abortController.signal), this.waitTillState(ToBTCSwapState.REFUNDED, "eq", abortController.signal).then(() => 0 as const), this.waitTillState(ToBTCSwapState.CLAIMED, "eq", abortController.signal).then(() => 1 as const), ]); abortController.abort(); if(res===0) { this.logger.debug("waitTillRefunded(): Resolved from state change (REFUNDED)"); return; } if(res===1) { this.logger.debug("waitTillRefunded(): Resolved from state change (CLAIMED)"); throw new Error("Tried to refund swap, but claimer claimed it in the meantime!"); } this.logger.debug("waitTillRefunded(): Resolved from watchdog"); if(res?.type===SwapCommitStateType.PAID) { if(this._claimTxId==null) this._claimTxId = await res.getClaimTxId(); await this._saveAndEmit(ToBTCSwapState.CLAIMED); throw new Error("Tried to refund swap, but claimer claimed it in the meantime!"); } if(res?.type===SwapCommitStateType.NOT_COMMITED) { if(this._refundTxId==null && res.getRefundTxId!=null) this._refundTxId = await res.getRefundTxId(); await this._saveAndEmit(ToBTCSwapState.REFUNDED); } } ////////////////////////////// //// Storage /** * @inheritDoc */ serialize(): any { const obj = super.serialize(); return { ...obj, networkFee: this.networkFee==null ? null : this.networkFee.toString(10), networkFeeBtc: this.networkFeeBtc==null ? null : this.networkFeeBtc.toString(10) }; } ////////////////////////////// //// Swap ticks & sync /** * Checks the swap's state on-chain and compares it to its internal state, updates/changes it according to on-chain * data * * @private */ private async syncStateFromChain(quoteDefinitelyExpired?: boolean, commitStatus?: SwapCommitState): Promise<boolean> { if( this._state===ToBTCSwapState.CREATED || this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED || this._state===ToBTCSwapState.COMMITED || this._state===ToBTCSwapState.SOFT_CLAIMED || this._state===ToBTCSwapState.REFUNDABLE ) { let quoteExpired = false; if(this._state===ToBTCSwapState.CREATED || this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED) { //Check if quote is still valid quoteExpired = quoteDefinitelyExpired ?? await this._verifyQuoteDefinitelyExpired(); } commitStatus ??= await this._contract.getCommitStatus(this._getInitiator(), this._data); if(commitStatus!=null && await this._forciblySetOnchainState(commitStatus)) return true; if((this._state===ToBTCSwapState.CREATED || this._state===ToBTCSwapState.QUOTE_SOFT_EXPIRED)) { if(quoteExpired) { this._state = ToBTCSwapState.QUOTE_EXPIRED; return true; } } } return false; } /** * @inheritDoc * @internal */ _shouldFetchOncha