@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
975 lines (831 loc) • 43.2 kB
text/typescript
import {decode as bolt11Decode} from "@atomiqlabs/bolt11";
import {SwapType} from "../../../enums/SwapType";
import {
ChainSwapType,
ChainType, isAbstractSigner,
SwapClaimWitnessMessage,
SwapCommitState,
SwapCommitStateType,
SwapData,
} from "@atomiqlabs/base";
import {Buffer} from "buffer";
import {isLNURLWithdraw, LNURL, LNURLWithdraw, LNURLWithdrawParamsWithUrl} from "../../../../utils/LNURL";
import {UserError} from "../../../../errors/UserError";
import {
IntermediaryAPI,
InvoiceStatusResponse,
InvoiceStatusResponseCodes
} from "../../../../intermediaries/IntermediaryAPI";
import {IntermediaryError} from "../../../../errors/IntermediaryError";
import {extendAbortController, getLogger, timeoutPromise, tryWithRetries} from "../../../../utils/Utils";
import {BitcoinTokens, BtcToken, SCToken, TokenAmount, toTokenAmount} from "../../../../Tokens";
import {ppmToPercentage} from "../../../ISwap";
import {Fee, FeeType} from "../../../fee/Fee";
import {IAddressSwap} from "../../../IAddressSwap";
import {FromBTCLNAutoWrapper} from "./FromBTCLNAutoWrapper";
import {ISwapWithGasDrop} from "../../../ISwapWithGasDrop";
import {MinimalLightningNetworkWalletInterface} from "../../../../btc/wallet/MinimalLightningNetworkWalletInterface";
import {IClaimableSwap} from "../../../IClaimableSwap";
import {IEscrowSwap, IEscrowSwapInit, isIEscrowSwapInit} from "../../IEscrowSwap";
export enum FromBTCLNAutoSwapState {
FAILED = -4,
QUOTE_EXPIRED = -3,
QUOTE_SOFT_EXPIRED = -2,
EXPIRED = -1,
PR_CREATED = 0,
PR_PAID = 1,
CLAIM_COMMITED = 2,
CLAIM_CLAIMED = 3
}
export type FromBTCLNAutoSwapInit<T extends SwapData> = IEscrowSwapInit<T> & {
pr: string,
secret: string,
initialSwapData: T,
btcAmountSwap: bigint,
btcAmountGas: bigint,
gasSwapFeeBtc: bigint,
gasSwapFee: bigint,
lnurl?: string,
lnurlK1?: string,
lnurlCallback?: string
};
export function isFromBTCLNAutoSwapInit<T extends SwapData>(obj: any): obj is FromBTCLNAutoSwapInit<T> {
return typeof obj.pr==="string" &&
typeof obj.secret==="string" &&
typeof obj.btcAmountSwap==="bigint" &&
typeof obj.btcAmountGas==="bigint" &&
typeof obj.gasSwapFeeBtc==="bigint" &&
typeof obj.gasSwapFee==="bigint" &&
(obj.lnurl==null || typeof(obj.lnurl)==="string") &&
(obj.lnurlK1==null || typeof(obj.lnurlK1)==="string") &&
(obj.lnurlCallback==null || typeof(obj.lnurlCallback)==="string") &&
isIEscrowSwapInit(obj);
}
export class FromBTCLNAutoSwap<T extends ChainType = ChainType>
extends IEscrowSwap<T, FromBTCLNAutoSwapState>
implements IAddressSwap, ISwapWithGasDrop<T>, IClaimableSwap<T, FromBTCLNAutoSwapState> {
protected readonly inputToken: BtcToken<true> = BitcoinTokens.BTCLN;
protected readonly TYPE = SwapType.FROM_BTCLN_AUTO;
protected readonly lnurlFailSignal: AbortController = new AbortController();
protected readonly pr: string;
protected readonly secret: string;
protected initialSwapData: T["Data"];
protected readonly btcAmountSwap: bigint;
protected readonly btcAmountGas: bigint;
protected readonly gasSwapFeeBtc: bigint;
protected readonly gasSwapFee: bigint;
lnurl?: string;
lnurlK1?: string;
lnurlCallback?: string;
prPosted?: boolean = false;
wrapper: FromBTCLNAutoWrapper<T>;
protected getSwapData(): T["Data"] {
return this.data ?? this.initialSwapData;
}
constructor(wrapper: FromBTCLNAutoWrapper<T>, init: FromBTCLNAutoSwapInit<T["Data"]>);
constructor(wrapper: FromBTCLNAutoWrapper<T>, obj: any);
constructor(
wrapper: FromBTCLNAutoWrapper<T>,
initOrObject: FromBTCLNAutoSwapInit<T["Data"]> | any
) {
if(isFromBTCLNAutoSwapInit(initOrObject)) initOrObject.url += "/frombtcln_auto";
super(wrapper, initOrObject);
if(isFromBTCLNAutoSwapInit(initOrObject)) {
this.state = FromBTCLNAutoSwapState.PR_CREATED;
} else {
this.pr = initOrObject.pr;
this.secret = initOrObject.secret;
this.initialSwapData = initOrObject.initialSwapData==null ? null : SwapData.deserialize<T["Data"]>(initOrObject.initialSwapData);
this.btcAmountSwap = initOrObject.btcAmountSwap==null ? null : BigInt(initOrObject.btcAmountSwap);
this.btcAmountGas = initOrObject.btcAmountGas==null ? null : BigInt(initOrObject.btcAmountGas);
this.gasSwapFeeBtc = initOrObject.gasSwapFeeBtc==null ? null : BigInt(initOrObject.gasSwapFeeBtc);
this.gasSwapFee = initOrObject.gasSwapFee==null ? null : BigInt(initOrObject.gasSwapFee);
this.data = initOrObject.data==null ? null : SwapData.deserialize<T["Data"]>(initOrObject.data);
this.commitTxId = initOrObject.commitTxId;
this.claimTxId = initOrObject.claimTxId;
this.lnurl = initOrObject.lnurl;
this.lnurlK1 = initOrObject.lnurlK1;
this.lnurlCallback = initOrObject.lnurlCallback;
this.prPosted = initOrObject.prPosted;
}
this.tryRecomputeSwapPrice();
this.logger = getLogger("FromBTCLNAuto("+this.getIdentifierHashString()+"): ");
}
protected upgradeVersion() { /*NOOP*/ }
/**
* In case swapFee in BTC is not supplied it recalculates it based on swap price
* @protected
*/
protected tryRecomputeSwapPrice() {
if(this.pricingInfo.swapPriceUSatPerToken==null) {
this.pricingInfo = this.wrapper.prices.recomputePriceInfoReceive(
this.chainIdentifier,
this.btcAmountSwap,
this.pricingInfo.satsBaseFee,
this.pricingInfo.feePPM,
this.getOutputAmountWithoutFee(),
this.getSwapData().getToken()
);
}
}
//////////////////////////////
//// Pricing
async refreshPriceData(): Promise<void> {
if(this.pricingInfo==null) return null;
this.pricingInfo = await this.wrapper.prices.isValidAmountReceive(
this.chainIdentifier,
this.btcAmountSwap,
this.pricingInfo.satsBaseFee,
this.pricingInfo.feePPM,
this.getOutputAmountWithoutFee(),
this.getSwapData().getToken()
);
}
//////////////////////////////
//// Getters & utils
_getEscrowHash(): string | null {
//Use claim hash in case the data is not yet known
return this.data == null ? this.initialSwapData?.getClaimHash() : this.data?.getEscrowHash();
}
_getInitiator(): string {
return this.getSwapData().getClaimer();
}
getId(): string {
return this.getIdentifierHashString();
}
getOutputAddress(): string | null {
return this._getInitiator();
}
getOutputTxId(): string | null {
return this.claimTxId;
}
requiresAction(): boolean {
return this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED;
}
protected getIdentifierHashString(): string {
const paymentHashBuffer = this.getPaymentHash();
if(this.randomNonce==null) return paymentHashBuffer?.toString("hex");
return paymentHashBuffer.toString("hex") + this.randomNonce;
}
protected getPaymentHash(): Buffer {
if(this.pr==null) return null;
const decodedPR = bolt11Decode(this.pr);
return Buffer.from(decodedPR.tagsObject.payment_hash, "hex");
}
getInputTxId(): string | null {
return this.getPaymentHash().toString("hex");
}
/**
* Returns the lightning network BOLT11 invoice that needs to be paid as an input to the swap
*/
getAddress(): string {
return this.pr;
}
getHyperlink(): string {
return "lightning:"+this.pr.toUpperCase();
}
/**
* Returns the timeout time (in UNIX milliseconds) when the swap will definitelly be considered as expired
* if the LP doesn't make it expired sooner
*/
getDefinitiveExpiryTime(): number {
if(this.pr==null) return null;
const decoded = bolt11Decode(this.pr);
const finalCltvExpiryDelta = decoded.tagsObject.min_final_cltv_expiry ?? 144;
const finalCltvExpiryDelay = finalCltvExpiryDelta * this.wrapper.options.bitcoinBlocktime * this.wrapper.options.safetyFactor;
return (decoded.timeExpireDate + finalCltvExpiryDelay)*1000;
}
/**
* Returns timeout time (in UNIX milliseconds) when the swap htlc will expire
*/
getHtlcTimeoutTime(): number | null {
return this.data==null ? null : Number(this.wrapper.getHtlcTimeout(this.data))*1000;
}
isFinished(): boolean {
return this.state===FromBTCLNAutoSwapState.CLAIM_CLAIMED || this.state===FromBTCLNAutoSwapState.QUOTE_EXPIRED || this.state===FromBTCLNAutoSwapState.FAILED;
}
isClaimable(): boolean {
return this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED;
}
isSuccessful(): boolean {
return this.state===FromBTCLNAutoSwapState.CLAIM_CLAIMED;
}
isFailed(): boolean {
return this.state===FromBTCLNAutoSwapState.FAILED || this.state===FromBTCLNAutoSwapState.EXPIRED;
}
isQuoteExpired(): boolean {
return this.state===FromBTCLNAutoSwapState.QUOTE_EXPIRED;
}
isQuoteSoftExpired(): boolean {
return this.state===FromBTCLNAutoSwapState.QUOTE_EXPIRED;
}
_verifyQuoteDefinitelyExpired(): Promise<boolean> {
return Promise.resolve(this.getDefinitiveExpiryTime()<Date.now());
}
verifyQuoteValid(): Promise<boolean> {
return Promise.resolve(this.getQuoteExpiry()>Date.now());
}
//////////////////////////////
//// Amounts & fees
protected getLightningInvoiceSats(): bigint {
const parsed = bolt11Decode(this.pr);
return (BigInt(parsed.millisatoshis) + 999n) / 1000n;
}
protected getWatchtowerFeeAmountBtc() {
return (this.btcAmountGas - this.gasSwapFeeBtc) * this.getSwapData().getClaimerBounty() / this.getSwapData().getTotalDeposit();
}
protected getInputSwapAmountWithoutFee(): bigint {
return this.btcAmountSwap - this.swapFeeBtc;
}
protected getInputGasAmountWithoutFee(): bigint {
return this.btcAmountGas - this.gasSwapFeeBtc;
}
protected getInputAmountWithoutFee(): bigint {
return this.getInputSwapAmountWithoutFee() + this.getInputGasAmountWithoutFee() - this.getWatchtowerFeeAmountBtc();
}
protected getOutputAmountWithoutFee(): bigint {
return this.getSwapData().getAmount() + this.swapFee;
}
getInput(): TokenAmount<T["ChainId"], BtcToken<true>> {
return toTokenAmount(this.getLightningInvoiceSats(), this.inputToken, this.wrapper.prices);
}
getInputWithoutFee(): TokenAmount {
return toTokenAmount(this.getInputAmountWithoutFee(), this.inputToken, this.wrapper.prices);
}
getOutput(): TokenAmount<T["ChainId"], SCToken<T["ChainId"]>> {
return toTokenAmount(this.getSwapData().getAmount(), this.wrapper.tokens[this.getSwapData().getToken()], this.wrapper.prices);
}
getGasDropOutput(): TokenAmount<T["ChainId"], SCToken<T["ChainId"]>> {
return toTokenAmount(
this.getSwapData().getSecurityDeposit() - this.getSwapData().getClaimerBounty(),
this.wrapper.tokens[this.getSwapData().getDepositToken()], this.wrapper.prices
);
}
protected getSwapFee(): Fee<T["ChainId"], BtcToken<true>, SCToken<T["ChainId"]>> {
const outputToken = this.wrapper.tokens[this.getSwapData().getToken()];
const gasSwapFeeInOutputToken = this.gasSwapFeeBtc
* (10n ** BigInt(outputToken.decimals))
* 1_000_000n
/ this.pricingInfo.swapPriceUSatPerToken;
const feeWithoutBaseFee = this.gasSwapFeeBtc + this.swapFeeBtc - this.pricingInfo.satsBaseFee;
const swapFeePPM = feeWithoutBaseFee * 1000000n / (this.getLightningInvoiceSats() - this.swapFeeBtc - this.gasSwapFeeBtc);
return {
amountInSrcToken: toTokenAmount(this.swapFeeBtc + this.gasSwapFeeBtc, BitcoinTokens.BTCLN, this.wrapper.prices),
amountInDstToken: toTokenAmount(this.swapFee + gasSwapFeeInOutputToken, outputToken, this.wrapper.prices),
usdValue: (abortSignal?: AbortSignal, preFetchedUsdPrice?: number) =>
this.wrapper.prices.getBtcUsdValue(this.swapFeeBtc + this.gasSwapFeeBtc, abortSignal, preFetchedUsdPrice),
composition: {
base: toTokenAmount(this.pricingInfo.satsBaseFee, BitcoinTokens.BTCLN, this.wrapper.prices),
percentage: ppmToPercentage(swapFeePPM)
}
};
}
protected getWatchtowerFee(): Fee<T["ChainId"], BtcToken<true>, SCToken<T["ChainId"]>> {
const btcWatchtowerFee = this.getWatchtowerFeeAmountBtc();
const outputToken = this.wrapper.tokens[this.getSwapData().getToken()];
const watchtowerFeeInOutputToken = btcWatchtowerFee
* (10n ** BigInt(outputToken.decimals))
* 1_000_000n
/ this.pricingInfo.swapPriceUSatPerToken;
return {
amountInSrcToken: toTokenAmount(btcWatchtowerFee, BitcoinTokens.BTCLN, this.wrapper.prices),
amountInDstToken: toTokenAmount(watchtowerFeeInOutputToken, outputToken, this.wrapper.prices),
usdValue: (abortSignal?: AbortSignal, preFetchedUsdPrice?: number) =>
this.wrapper.prices.getBtcUsdValue(btcWatchtowerFee, abortSignal, preFetchedUsdPrice)
};
}
getFee(): Fee<T["ChainId"], BtcToken<true>, SCToken<T["ChainId"]>> {
const swapFee = this.getSwapFee();
const watchtowerFee = this.getWatchtowerFee();
return {
amountInSrcToken: toTokenAmount(swapFee.amountInSrcToken.rawAmount + watchtowerFee.amountInSrcToken.rawAmount, BitcoinTokens.BTCLN, this.wrapper.prices),
amountInDstToken: toTokenAmount(swapFee.amountInDstToken.rawAmount + watchtowerFee.amountInDstToken.rawAmount, this.wrapper.tokens[this.getSwapData().getToken()], this.wrapper.prices),
usdValue: (abortSignal?: AbortSignal, preFetchedUsdPrice?: number) =>
this.wrapper.prices.getBtcUsdValue(swapFee.amountInSrcToken.rawAmount + watchtowerFee.amountInSrcToken.rawAmount, abortSignal, preFetchedUsdPrice)
};
}
getFeeBreakdown(): [
{type: FeeType.SWAP, fee: Fee<T["ChainId"], BtcToken<true>, SCToken<T["ChainId"]>>},
{type: FeeType.NETWORK_OUTPUT, fee: Fee<T["ChainId"], BtcToken<true>, SCToken<T["ChainId"]>>}
] {
return [
{
type: FeeType.SWAP,
fee: this.getSwapFee()
},
{
type: FeeType.NETWORK_OUTPUT,
fee: this.getWatchtowerFee()
}
];
}
//////////////////////////////
//// Execution
/**
* Executes the swap with the provided bitcoin lightning network wallet or LNURL
*
* @param walletOrLnurlWithdraw Bitcoin lightning wallet to use to pay the lightning network invoice, or an LNURL-withdraw
* link, wallet is not required and the LN invoice can be paid externally as well (just pass null or undefined here)
* @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 a swap was settled automatically by swap watchtowers or requires manual claim by the
* user, in case `false` is returned the user should call `swap.claim()` to settle the swap on the destination manually
*/
async execute(
walletOrLnurlWithdraw?: MinimalLightningNetworkWalletInterface | LNURLWithdraw | string | null | undefined,
callbacks?: {
onSourceTransactionReceived?: (sourceTxId: string) => void,
onSwapSettled?: (destinationTxId: string) => void
},
options?: {
abortSignal?: AbortSignal,
lightningTxCheckIntervalSeconds?: number,
maxWaitTillAutomaticSettlementSeconds?: number
}
): Promise<boolean> {
if(this.state===FromBTCLNAutoSwapState.FAILED) throw new Error("Swap failed!");
if(this.state===FromBTCLNAutoSwapState.EXPIRED) throw new Error("Swap HTLC expired!");
if(this.state===FromBTCLNAutoSwapState.QUOTE_EXPIRED || this.state===FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Swap quote expired!");
if(this.state===FromBTCLNAutoSwapState.CLAIM_CLAIMED) throw new Error("Swap already settled!");
let abortSignal = options?.abortSignal;
if(this.state===FromBTCLNAutoSwapState.PR_CREATED) {
if(walletOrLnurlWithdraw!=null && this.lnurl==null) {
if(typeof(walletOrLnurlWithdraw)==="string" || isLNURLWithdraw(walletOrLnurlWithdraw)) {
await this.settleWithLNURLWithdraw(walletOrLnurlWithdraw);
} else {
const paymentPromise = walletOrLnurlWithdraw.payInvoice(this.pr);
const abortController = new AbortController();
paymentPromise.catch(e => abortController.abort(e));
if(options?.abortSignal!=null) options.abortSignal.addEventListener("abort", () => abortController.abort(options.abortSignal.reason));
abortSignal = abortController.signal;
}
}
}
if(this.state===FromBTCLNAutoSwapState.PR_CREATED || this.state===FromBTCLNAutoSwapState.PR_PAID) {
const paymentSuccess = await this.waitForPayment(callbacks?.onSourceTransactionReceived, options?.lightningTxCheckIntervalSeconds, abortSignal);
if (!paymentSuccess) throw new Error("Failed to receive lightning network payment");
}
if((this.state as FromBTCLNAutoSwapState)===FromBTCLNAutoSwapState.CLAIM_CLAIMED) return true;
if(this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED) {
const success = await this.waitTillClaimed(options?.maxWaitTillAutomaticSettlementSeconds ?? 60, options?.abortSignal);
if (success && callbacks?.onSwapSettled != null) callbacks.onSwapSettled(this.getOutputTxId());
return success;
}
}
//////////////////////////////
//// Payment
/**
* Checks whether the LP received the LN payment and we can continue by committing & claiming the HTLC on-chain
*
* @param save If the new swap state should be saved
*/
async _checkIntermediaryPaymentReceived(save: boolean = true): Promise<boolean | null> {
if(
this.state===FromBTCLNAutoSwapState.PR_PAID ||
this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED ||
this.state===FromBTCLNAutoSwapState.CLAIM_CLAIMED ||
this.state===FromBTCLNAutoSwapState.FAILED
) return true;
if(this.state===FromBTCLNAutoSwapState.QUOTE_EXPIRED) return false;
const resp = await IntermediaryAPI.getInvoiceStatus(this.url, this.getPaymentHash().toString("hex"));
switch(resp.code) {
case InvoiceStatusResponseCodes.PAID:
const data = new this.wrapper.swapDataDeserializer(resp.data.data);
if(this.state===FromBTCLNAutoSwapState.PR_CREATED || this.state===FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED) try {
await this._saveRealSwapData(data, save);
return true;
} catch (e) {}
return null;
case InvoiceStatusResponseCodes.EXPIRED:
this.state = FromBTCLNAutoSwapState.QUOTE_EXPIRED;
this.initiated = true;
if(save) await this._saveAndEmit();
return false;
default:
return null;
}
}
async _saveRealSwapData(data: T["Data"], save?: boolean): Promise<boolean> {
await this.checkIntermediaryReturnedData(data);
if(this.state===FromBTCLNAutoSwapState.PR_CREATED || this.state===FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED) {
this.state = FromBTCLNAutoSwapState.PR_PAID;
delete this.initialSwapData;
this.data = data;
this.initiated = true;
if(save) await this._saveAndEmit();
return true;
}
return false;
}
/**
* Checks the data returned by the intermediary in the payment auth request
*
* @param data Parsed swap data as returned by the intermediary
* @protected
* @throws {IntermediaryError} If the returned are not valid
* @throws {Error} If the swap is already committed on-chain
*/
protected async checkIntermediaryReturnedData(data: T["Data"]): Promise<void> {
if (!data.isPayOut()) throw new IntermediaryError("Invalid not pay out");
if (data.getType() !== ChainSwapType.HTLC) throw new IntermediaryError("Invalid swap type");
if (!data.isOfferer(this.getSwapData().getOfferer())) throw new IntermediaryError("Invalid offerer used");
if (!data.isClaimer(this._getInitiator())) throw new IntermediaryError("Invalid claimer used");
if (!data.isToken(this.getSwapData().getToken())) throw new IntermediaryError("Invalid token used");
if (data.getSecurityDeposit() !== this.getSwapData().getSecurityDeposit()) throw new IntermediaryError("Invalid security deposit!");
if (data.getClaimerBounty() !== this.getSwapData().getClaimerBounty()) throw new IntermediaryError("Invalid security deposit!");
if (data.getAmount() < this.getSwapData().getAmount()) throw new IntermediaryError("Invalid amount received!");
if (data.getClaimHash() !== this.getSwapData().getClaimHash()) throw new IntermediaryError("Invalid payment hash used!");
if (!data.isDepositToken(this.getSwapData().getDepositToken())) throw new IntermediaryError("Invalid deposit token used!");
if (data.hasSuccessAction()) throw new IntermediaryError("Invalid has success action");
if (await this.wrapper.contract.isExpired(this._getInitiator(), data)) throw new IntermediaryError("Not enough time to claim!");
if (this.wrapper.getHtlcTimeout(data) <= (Date.now()/1000)) throw new IntermediaryError("HTLC expires too soon!");
}
/**
* Waits till an LN payment is received by the intermediary and client can continue commiting & claiming the HTLC
*
* @param onPaymentReceived Callback as for when the LP reports having received the ln payment
* @param checkIntervalSeconds How often to poll the intermediary for answer (default 5 seconds)
* @param abortSignal Abort signal to stop waiting for payment
*/
async waitForPayment(onPaymentReceived?: (txId: string) => void, checkIntervalSeconds?: number, abortSignal?: AbortSignal): Promise<boolean> {
checkIntervalSeconds ??= 5;
if(this.state===FromBTCLNAutoSwapState.PR_PAID) {
await this.waitTillCommited(checkIntervalSeconds, abortSignal);
}
if(this.state>=FromBTCLNAutoSwapState.CLAIM_COMMITED) return true;
if(
this.state!==FromBTCLNAutoSwapState.PR_CREATED
) throw new Error("Must be in PR_CREATED state!");
const abortController = new AbortController();
if(abortSignal!=null) abortSignal.addEventListener("abort", () => abortController.abort(abortSignal.reason));
let save = false;
if(this.lnurl!=null && !this.prPosted) {
LNURL.postInvoiceToLNURLWithdraw({k1: this.lnurlK1, callback: this.lnurlCallback}, this.pr).catch(e => {
this.lnurlFailSignal.abort(e);
});
this.prPosted = true;
save ||= true;
}
if(!this.initiated) {
this.initiated = true;
save ||= true;
}
if(save) await this._saveAndEmit();
let lnurlFailListener = () => abortController.abort(this.lnurlFailSignal.signal.reason);
this.lnurlFailSignal.signal.addEventListener("abort", lnurlFailListener);
this.lnurlFailSignal.signal.throwIfAborted();
if(this.wrapper.messenger.warmup!=null) await this.wrapper.messenger.warmup().catch(e => {
this.logger.warn("waitForPayment(): Failed to warmup messenger: ", e);
});
if(this.state===FromBTCLNAutoSwapState.PR_CREATED) {
const paymentResult = await Promise.any([
this.waitTillState(FromBTCLNAutoSwapState.PR_PAID, "gte", abortController.signal).then(() => true),
(async () => {
let resp: InvoiceStatusResponse = {code: InvoiceStatusResponseCodes.PENDING, msg: ""};
while(!abortController.signal.aborted && resp.code===InvoiceStatusResponseCodes.PENDING) {
resp = await IntermediaryAPI.getInvoiceStatus(this.url, this.getPaymentHash().toString("hex"));
if(resp.code===InvoiceStatusResponseCodes.PENDING)
await timeoutPromise(checkIntervalSeconds*1000, abortController.signal);
}
this.lnurlFailSignal.signal.removeEventListener("abort", lnurlFailListener);
abortController.signal.throwIfAborted();
if(resp.code===InvoiceStatusResponseCodes.PAID) {
const swapData = new this.wrapper.swapDataDeserializer(resp.data.data);
return await this._saveRealSwapData(swapData, true);
}
if(this.state===FromBTCLNAutoSwapState.PR_CREATED || this.state===FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED) {
if(resp.code===InvoiceStatusResponseCodes.EXPIRED) {
await this._saveAndEmit(FromBTCLNAutoSwapState.QUOTE_EXPIRED);
}
return false;
}
})()
]);
abortController.abort();
if(!paymentResult) return false;
if(onPaymentReceived!=null) onPaymentReceived(this.getInputTxId());
}
if((this.state as FromBTCLNAutoSwapState)===FromBTCLNAutoSwapState.PR_PAID) {
await this.waitTillCommited(checkIntervalSeconds, abortSignal);
}
return this.state>=FromBTCLNAutoSwapState.CLAIM_COMMITED;
}
//////////////////////////////
//// Commit
protected async waitTillCommited(checkIntervalSeconds?: number, abortSignal?: AbortSignal): Promise<void> {
if(this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED || this.state===FromBTCLNAutoSwapState.CLAIM_CLAIMED) return Promise.resolve();
if(this.state!==FromBTCLNAutoSwapState.PR_PAID) throw new Error("Invalid state");
const abortController = extendAbortController(abortSignal);
let result: number | boolean;
try {
result = await Promise.race([
this.watchdogWaitTillCommited(checkIntervalSeconds, abortController.signal),
this.waitTillState(FromBTCLNAutoSwapState.CLAIM_COMMITED, "gte", abortController.signal).then(() => 0)
]);
abortController.abort();
} catch (e) {
abortController.abort();
throw e;
}
if(result===false) {
this.logger.debug("waitTillCommited(): Resolved from watchdog - HTLC expired");
if(
this.state===FromBTCLNAutoSwapState.PR_PAID
) {
await this._saveAndEmit(FromBTCLNAutoSwapState.EXPIRED);
}
return;
}
if(
this.state===FromBTCLNAutoSwapState.PR_PAID
) {
await this._saveAndEmit(FromBTCLNAutoSwapState.CLAIM_COMMITED);
}
if(result===0) this.logger.debug("waitTillCommited(): Resolved from state changed");
if(result===true) {
this.logger.debug("waitTillCommited(): Resolved from watchdog - commited");
await this._broadcastSecret().catch(e => {
this.logger.error("waitTillCommited(): Error broadcasting swap secret: ", e);
});
}
}
//////////////////////////////
//// Claim
/**
* Returns transactions required for claiming the HTLC and finishing the swap by revealing the HTLC secret
* (hash preimage)
*
* @param _signer Optional signer address to use for claiming the swap, can also be different from the initializer
* @throws {Error} If in invalid state (must be CLAIM_COMMITED)
*/
async txsClaim(_signer?: T["Signer"] | T["NativeSigner"]): Promise<T["TX"][]> {
if(this.state!==FromBTCLNAutoSwapState.CLAIM_COMMITED) throw new Error("Must be in CLAIM_COMMITED state!");
return await this.wrapper.contract.txsClaimWithSecret(
_signer==null ?
this._getInitiator() :
(isAbstractSigner(_signer) ? _signer : await this.wrapper.chain.wrapSigner(_signer)),
this.data, this.secret, true, true
);
}
/**
* Claims and finishes the swap
*
* @param _signer Signer to sign the transactions with, can also be different to the initializer
* @param abortSignal Abort signal to stop waiting for transaction confirmation
*/
async claim(_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.txsClaim(), true, abortSignal
);
this.claimTxId = result[0];
if(this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED || this.state===FromBTCLNAutoSwapState.EXPIRED || this.state===FromBTCLNAutoSwapState.FAILED) {
await this._saveAndEmit(FromBTCLNAutoSwapState.CLAIM_CLAIMED);
}
return result[0];
}
/**
* Waits till the swap is successfully claimed
*
* @param maxWaitTimeSeconds Maximum time in seconds to wait for the swap to be settled
* @param abortSignal AbortSignal
* @throws {Error} If swap is in invalid state (must be BTC_TX_CONFIRMED)
* @throws {Error} If the LP refunded sooner than we were able to claim
* @returns {boolean} whether the swap was claimed in time or not
*/
async waitTillClaimed(maxWaitTimeSeconds?: number, abortSignal?: AbortSignal): Promise<boolean> {
if(this.state===FromBTCLNAutoSwapState.CLAIM_CLAIMED) return Promise.resolve(true);
if(this.state!==FromBTCLNAutoSwapState.CLAIM_COMMITED) throw new Error("Invalid state (not CLAIM_COMMITED)");
const abortController = new AbortController();
if(abortSignal!=null) abortSignal.addEventListener("abort", () => abortController.abort(abortSignal.reason));
let timedOut: boolean = false;
if(maxWaitTimeSeconds!=null) {
const timeout = setTimeout(() => {
timedOut = true;
abortController.abort();
}, maxWaitTimeSeconds * 1000);
abortController.signal.addEventListener("abort", () => clearTimeout(timeout));
}
let res: 0 | 1 | SwapCommitState;
try {
res = await Promise.race([
this.watchdogWaitTillResult(undefined, abortController.signal),
this.waitTillState(FromBTCLNAutoSwapState.CLAIM_CLAIMED, "eq", abortController.signal).then(() => 0 as const),
this.waitTillState(FromBTCLNAutoSwapState.EXPIRED, "eq", abortController.signal).then(() => 1 as const),
]);
abortController.abort();
} catch (e) {
abortController.abort();
if(timedOut) return false;
throw e;
}
if(res===0) {
this.logger.debug("waitTillClaimed(): Resolved from state change (CLAIM_CLAIMED)");
return true;
}
if(res===1) {
this.logger.debug("waitTillClaimed(): Resolved from state change (EXPIRED)");
throw new Error("Swap expired during claiming");
}
this.logger.debug("waitTillClaimed(): Resolved from watchdog");
if(res?.type===SwapCommitStateType.PAID) {
if((this.state as FromBTCLNAutoSwapState)!==FromBTCLNAutoSwapState.CLAIM_CLAIMED) {
this.claimTxId = await res.getClaimTxId();
await this._saveAndEmit(FromBTCLNAutoSwapState.CLAIM_CLAIMED);
}
}
if(res?.type===SwapCommitStateType.NOT_COMMITED || res?.type===SwapCommitStateType.EXPIRED) {
if(
(this.state as FromBTCLNAutoSwapState)!==FromBTCLNAutoSwapState.CLAIM_CLAIMED &&
(this.state as FromBTCLNAutoSwapState)!==FromBTCLNAutoSwapState.FAILED
) {
await this._saveAndEmit(FromBTCLNAutoSwapState.FAILED);
}
throw new Error("Swap expired during claiming");
}
return true;
}
//////////////////////////////
//// LNURL
/**
* Is this an LNURL-withdraw swap?
*/
isLNURL(): boolean {
return this.lnurl!=null;
}
/**
* Gets the used LNURL or null if this is not an LNURL-withdraw swap
*/
getLNURL(): string | null {
return this.lnurl;
}
/**
* Pay the generated lightning network invoice with LNURL-withdraw
*/
async settleWithLNURLWithdraw(lnurl: string | LNURLWithdraw): Promise<void> {
if(this.lnurl!=null) throw new Error("Cannot settle LNURL-withdraw swap with different LNURL");
let lnurlParams: LNURLWithdrawParamsWithUrl;
if(typeof(lnurl)==="string") {
const parsedLNURL = await LNURL.getLNURL(lnurl);
if(parsedLNURL==null || parsedLNURL.tag!=="withdrawRequest")
throw new UserError("Invalid LNURL-withdraw to settle the swap");
lnurlParams = parsedLNURL;
} else {
lnurlParams = lnurl.params;
}
LNURL.useLNURLWithdraw(lnurlParams, this.pr).catch(e => this.lnurlFailSignal.abort(e));
this.lnurl = lnurlParams.url;
this.lnurlCallback = lnurlParams.callback;
this.lnurlK1 = lnurlParams.k1;
this.prPosted = true;
await this._saveAndEmit();
}
//////////////////////////////
//// Storage
serialize(): any {
return {
...super.serialize(),
data: this.data==null ? null : this.data.serialize(),
commitTxId: this.commitTxId,
claimTxId: this.claimTxId,
btcAmountSwap: this.btcAmountSwap==null ? null : this.btcAmountSwap.toString(10),
btcAmountGas: this.btcAmountGas==null ? null : this.btcAmountGas.toString(10),
gasSwapFeeBtc: this.gasSwapFeeBtc==null ? null : this.gasSwapFeeBtc.toString(10),
gasSwapFee: this.gasSwapFee==null ? null : this.gasSwapFee.toString(10),
pr: this.pr,
secret: this.secret,
lnurl: this.lnurl,
lnurlK1: this.lnurlK1,
lnurlCallback: this.lnurlCallback,
prPosted: this.prPosted,
initialSwapData: this.initialSwapData==null ? null : this.initialSwapData.serialize()
};
}
//////////////////////////////
//// 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> {
//Check for expiry before the getCommitStatus to prevent race conditions
let quoteExpired: boolean = false;
if(this.state===FromBTCLNAutoSwapState.PR_PAID) {
quoteExpired = quoteDefinitelyExpired ?? await this._verifyQuoteDefinitelyExpired();
}
if(this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED || this.state===FromBTCLNAutoSwapState.EXPIRED) {
//Check if it's already successfully paid
commitStatus ??= await tryWithRetries(() => this.wrapper.contract.getCommitStatus(this._getInitiator(), this.data));
if(commitStatus?.type===SwapCommitStateType.PAID) {
if(this.claimTxId==null) this.claimTxId = await commitStatus.getClaimTxId();
this.state = FromBTCLNAutoSwapState.CLAIM_CLAIMED;
return true;
}
if(commitStatus?.type===SwapCommitStateType.NOT_COMMITED || commitStatus?.type===SwapCommitStateType.EXPIRED) {
this.state = FromBTCLNAutoSwapState.FAILED;
return true;
}
}
if(this.state===FromBTCLNAutoSwapState.PR_PAID) {
//Check if it's already committed
commitStatus ??= await tryWithRetries(() => this.wrapper.contract.getCommitStatus(this._getInitiator(), this.data));
switch(commitStatus?.type) {
case SwapCommitStateType.COMMITED:
this.state = FromBTCLNAutoSwapState.CLAIM_COMMITED;
return true;
case SwapCommitStateType.EXPIRED:
this.state = FromBTCLNAutoSwapState.EXPIRED;
return true;
case SwapCommitStateType.PAID:
if(this.claimTxId==null) this.claimTxId = await commitStatus.getClaimTxId();
this.state = FromBTCLNAutoSwapState.CLAIM_CLAIMED;
return true;
}
if(quoteExpired) {
this.state = FromBTCLNAutoSwapState.QUOTE_EXPIRED;
return true;
}
}
}
_shouldFetchCommitStatus(): boolean {
return this.state===FromBTCLNAutoSwapState.PR_PAID || this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED || this.state===FromBTCLNAutoSwapState.EXPIRED;
}
_shouldFetchExpiryStatus(): boolean {
return this.state===FromBTCLNAutoSwapState.PR_PAID;
}
_shouldCheckIntermediary(): boolean {
return this.state===FromBTCLNAutoSwapState.PR_CREATED || this.state===FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED;
}
async _sync(save?: boolean, quoteDefinitelyExpired?: boolean, commitStatus?: SwapCommitState, skipLpCheck?: boolean): Promise<boolean> {
let changed = false;
if(this.state===FromBTCLNAutoSwapState.PR_CREATED || this.state===FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED) {
if(this.state!==FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED && this.getQuoteExpiry()<Date.now()) {
this.state = FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED;
changed ||= true;
}
if(!skipLpCheck) try {
const result = await this._checkIntermediaryPaymentReceived(false);
if (result !== null) changed ||= true;
} catch(e) {
this.logger.error("_sync(): Failed to synchronize swap, error: ", e);
}
if(this.state===FromBTCLNAutoSwapState.PR_CREATED || this.state===FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED) {
if(await this._verifyQuoteDefinitelyExpired()) {
this.state = FromBTCLNAutoSwapState.QUOTE_EXPIRED;
changed ||= true;
}
}
}
if(await this.syncStateFromChain(quoteDefinitelyExpired, commitStatus)) changed = true;
if(save && changed) await this._saveAndEmit();
if(this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED) await this._broadcastSecret().catch(e => {
this.logger.error("_sync(): Error when broadcasting swap secret: ", e);
});
return changed;
}
private broadcastTickCounter: number = 0;
async _broadcastSecret(noCheckExpiry?: boolean): Promise<void> {
if(this.state!==FromBTCLNAutoSwapState.CLAIM_COMMITED) throw new Error("Must be in CLAIM_COMMITED state to broadcast swap secret!");
if(!noCheckExpiry) {
if(await this.wrapper.contract.isExpired(this._getInitiator(), this.data)) throw new Error("On-chain HTLC already expired!");
}
await this.wrapper.messenger.broadcast(new SwapClaimWitnessMessage(this.data, this.secret));
}
async _tick(save?: boolean): Promise<boolean> {
switch(this.state) {
case FromBTCLNAutoSwapState.PR_CREATED:
if(this.getQuoteExpiry() < Date.now()) {
this.state = FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED;
if(save) await this._saveAndEmit();
return true;
}
break;
case FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED:
if(this.getDefinitiveExpiryTime() < Date.now()) {
this.state = FromBTCLNAutoSwapState.QUOTE_EXPIRED;
if(save) await this._saveAndEmit();
return true;
}
break;
case FromBTCLNAutoSwapState.PR_PAID:
case FromBTCLNAutoSwapState.CLAIM_COMMITED:
const expired = await this.wrapper.contract.isExpired(this._getInitiator(), this.data);
if(expired) {
this.state = FromBTCLNAutoSwapState.EXPIRED;
if(save) await this._saveAndEmit();
return true;
}
if(this.state===FromBTCLNAutoSwapState.CLAIM_COMMITED) {
//Broadcast the secret over the provided messenger channel
if(this.broadcastTickCounter===0) await this._broadcastSecret(true).catch(e => {
this.logger.warn("_tick(): Error when broadcasting swap secret: ", e);
});
this.broadcastTickCounter = (this.broadcastTickCounter + 1) % 3; //Broadcast every 3rd tick
}
break;
}
}
}