@atomiqlabs/sdk
Version:
atomiq labs SDK for cross-chain swaps between smart chains and bitcoin
1,209 lines (1,102 loc) • 55.3 kB
text/typescript
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