UNPKG

@atomiqlabs/sdk

Version:

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

1,153 lines (1,046 loc) 73.3 kB
import {decode as bolt11Decode} from "@atomiqlabs/bolt11"; import {FromBTCLNDefinition, FromBTCLNWrapper} from "./FromBTCLNWrapper"; import {IFromBTCSelfInitSwap} from "../IFromBTCSelfInitSwap"; import {SwapType} from "../../../../enums/SwapType"; import { ChainSwapType, ChainType, isAbstractSigner, SignatureData, SwapCommitState, SwapCommitStateType, SwapData, SignatureVerificationError } from "@atomiqlabs/base"; import {Buffer} from "buffer"; import {LNURL} from "../../../../lnurl/LNURL"; import {UserError} from "../../../../errors/UserError"; import { IntermediaryAPI, PaymentAuthorizationResponse, PaymentAuthorizationResponseCodes } from "../../../../intermediaries/apis/IntermediaryAPI"; import {IntermediaryError} from "../../../../errors/IntermediaryError"; import {extendAbortController} from "../../../../utils/Utils"; import {MinimalLightningNetworkWalletInterface} from "../../../../types/wallets/MinimalLightningNetworkWalletInterface"; import {IClaimableSwap} from "../../../IClaimableSwap"; import {IAddressSwap} from "../../../IAddressSwap"; import {IEscrowSelfInitSwapInit, isIEscrowSelfInitSwapInit} from "../../IEscrowSelfInitSwap"; import {TokenAmount, toTokenAmount} from "../../../../types/TokenAmount"; import {BitcoinTokens, BtcToken, SCToken} from "../../../../types/Token"; import {getLogger, LoggerType} from "../../../../utils/Logger"; import {timeoutPromise} from "../../../../utils/TimeoutUtils"; import {isLNURLWithdraw, LNURLWithdraw, LNURLWithdrawParamsWithUrl} from "../../../../types/lnurl/LNURLWithdraw"; import {sha256} from "@noble/hashes/sha2"; import { SwapExecutionActionSendToAddress, SwapExecutionActionSignSmartChainTx } from "../../../../types/SwapExecutionAction"; import { SwapExecutionStepPayment, SwapExecutionStepSettlement } from "../../../../types/SwapExecutionStep"; import {SwapStateInfo} from "../../../../types/SwapStateInfo"; /** * State enum for legacy Lightning -> Smart chain swaps * @category Swaps/Legacy/Lightning → Smart chain */ export enum FromBTCLNSwapState { /** * Swap has failed as the user didn't settle the HTLC on the destination before expiration */ FAILED = -4, /** * Swap has expired for good and there is no way how it can be executed anymore */ QUOTE_EXPIRED = -3, /** * 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 = -2, /** * Swap HTLC on the destination chain has expired, it is not safe anymore to settle (claim) the * swap on the destination smart chain. */ EXPIRED = -1, /** * Swap quote was created, use {@link FromBTCLNSwap.getAddress} or {@link FromBTCLNSwap.getHyperlink} * to get the bolt11 lightning network invoice to pay to initiate the swap, then use the * {@link FromBTCLNSwap.waitForPayment} to wait till the lightning network payment is received * by the intermediary (LP) */ PR_CREATED = 0, /** * Lightning network payment has been received by the intermediary (LP), the user can now settle * the swap on the destination smart chain side with {@link FromBTCLNSwap.commitAndClaim} (if * the underlying chain supports it - check with {@link FromBTCLNSwap.canCommitAndClaimInOneShot}), * or by calling {@link FromBTCLNSwap.commit} and {@link FromBTCLNSwap.claim} separately. */ PR_PAID = 1, /** * Swap escrow HTLC has been created on the destination chain. Continue by claiming it with the * {@link FromBTCLNSwap.claim} or {@link FromBTCLNSwap.txsClaim} function. */ CLAIM_COMMITED = 2, /** * Swap successfully settled and funds received on the destination chain */ CLAIM_CLAIMED = 3 } const FromBTCLNSwapStateDescription = { [FromBTCLNSwapState.FAILED]: "Swap has failed as the user didn't settle the HTLC on the destination before expiration", [FromBTCLNSwapState.QUOTE_EXPIRED]: "Swap has expired for good and there is no way how it can be executed anymore", [FromBTCLNSwapState.QUOTE_SOFT_EXPIRED]: "Swap is expired, though there is still a chance that it will be processed", [FromBTCLNSwapState.EXPIRED]: "Swap HTLC on the destination chain has expired, it is not safe anymore to settle (claim) the swap on the destination smart chain.", [FromBTCLNSwapState.PR_CREATED]: "Swap quote was created, pay the bolt11 lightning network invoice to initiate the swap, then use the wait till the lightning network payment is received by the intermediary (LP)", [FromBTCLNSwapState.PR_PAID]: "Lightning network payment has been received by the intermediary (LP), the user can now settle the swap on the destination smart chain side.", [FromBTCLNSwapState.CLAIM_COMMITED]: "Swap escrow HTLC has been created on the destination chain. Continue by claiming it.", [FromBTCLNSwapState.CLAIM_CLAIMED]: "Swap successfully settled and funds received on the destination chain" }; export type FromBTCLNSwapInit<T extends SwapData> = IEscrowSelfInitSwapInit<T> & { pr?: string, secret?: string, initialSwapData: T, lnurl?: string, lnurlK1?: string, lnurlCallback?: string }; export function isFromBTCLNSwapInit<T extends SwapData>(obj: any): obj is FromBTCLNSwapInit<T> { return (obj.pr==null || typeof obj.pr==="string") && (obj.secret==null || typeof obj.secret==="string") && (obj.lnurl==null || typeof(obj.lnurl)==="string") && (obj.lnurlK1==null || typeof(obj.lnurlK1)==="string") && (obj.lnurlCallback==null || typeof(obj.lnurlCallback)==="string") && isIEscrowSelfInitSwapInit(obj); } /** * Legacy escrow (HTLC) based swap for Bitcoin Lightning -> Smart chains, requires manual settlement * of the swap on the destination network once the lightning network payment is received by the LP. * * @category Swaps/Legacy/Lightning → Smart chain */ export class FromBTCLNSwap<T extends ChainType = ChainType> extends IFromBTCSelfInitSwap<T, FromBTCLNDefinition<T>, FromBTCLNSwapState> implements IAddressSwap, IClaimableSwap<T, FromBTCLNDefinition<T>, FromBTCLNSwapState> { protected readonly TYPE = SwapType.FROM_BTCLN; /** * @internal */ protected readonly swapStateName = (state: number) => FromBTCLNSwapState[state]; /** * @internal */ protected readonly swapStateDescription = FromBTCLNSwapStateDescription; /** * @internal */ protected readonly logger: LoggerType; /** * @internal */ protected readonly inputToken: BtcToken<true> = BitcoinTokens.BTCLN; private readonly lnurlFailSignal: AbortController = new AbortController(); private readonly usesClaimHashAsId: boolean; private readonly initialSwapData: T["Data"]; /** * In case the swap is recovered from on-chain data, the pr saved here is just a payment hash, * as it is impossible to retrieve the actual lightning network invoice paid purely from on-chain * data * @private */ private pr?: string; private secret?: string; private lnurl?: string; private lnurlK1?: string; private lnurlCallback?: string; private prPosted?: boolean = false; /** * Sets the LNURL data for the swap * * @internal */ _setLNURLData(lnurl: string, lnurlK1: string, lnurlCallback: string) { this.lnurl = lnurl; this.lnurlK1 = lnurlK1; this.lnurlCallback = lnurlCallback; } constructor(wrapper: FromBTCLNWrapper<T>, init: FromBTCLNSwapInit<T["Data"]>); constructor(wrapper: FromBTCLNWrapper<T>, obj: any); constructor( wrapper: FromBTCLNWrapper<T>, initOrObject: FromBTCLNSwapInit<T["Data"]> | any ) { if(isFromBTCLNSwapInit(initOrObject) && initOrObject.url!=null) initOrObject.url += "/frombtcln"; super(wrapper, initOrObject); if(isFromBTCLNSwapInit(initOrObject)) { this._state = FromBTCLNSwapState.PR_CREATED; this.pr = initOrObject.pr; this.secret = initOrObject.secret; this.initialSwapData = initOrObject.initialSwapData; this.lnurl = initOrObject.lnurl; this.lnurlK1 = initOrObject.lnurlK1; this.lnurlCallback = initOrObject.lnurlCallback; this.usesClaimHashAsId = true; } else { this.pr = initOrObject.pr; this.secret = initOrObject.secret; if(initOrObject.initialSwapData==null) { this.initialSwapData = this._data!; } else { this.initialSwapData = SwapData.deserialize<T["Data"]>(initOrObject.initialSwapData); } this.lnurl = initOrObject.lnurl; this.lnurlK1 = initOrObject.lnurlK1; this.lnurlCallback = initOrObject.lnurlCallback; this.prPosted = initOrObject.prPosted; if(this._state===FromBTCLNSwapState.PR_CREATED && this._data!=null) { this.initialSwapData = this._data; delete this._data; } this.usesClaimHashAsId = initOrObject.usesClaimHashAsId ?? false; } this.tryRecomputeSwapPrice(); this.logger = getLogger("FromBTCLN("+this.getIdentifierHashString()+"): "); } /** * @inheritDoc * @internal */ protected getSwapData(): T["Data"] { return this._data ?? this.initialSwapData; } /** * @inheritDoc * @internal */ protected upgradeVersion() { if (this.version == null) { switch (this._state) { case -2: this._state = FromBTCLNSwapState.QUOTE_EXPIRED; break; case -1: this._state = FromBTCLNSwapState.FAILED; break; case 0: this._state = FromBTCLNSwapState.PR_CREATED break; case 1: this._state = FromBTCLNSwapState.PR_PAID break; case 2: this._state = FromBTCLNSwapState.CLAIM_COMMITED break; case 3: this._state = FromBTCLNSwapState.CLAIM_CLAIMED break; } this.version = 1; } } ////////////////////////////// //// Getters & utils /** * @inheritDoc * @internal */ protected getIdentifierHash(): Buffer { const idBuffer: Buffer = this.usesClaimHashAsId ? Buffer.from(this.getClaimHash(), "hex") : this.getPaymentHash()!; if(this._randomNonce==null) return idBuffer; return Buffer.concat([idBuffer, Buffer.from(this._randomNonce, "hex")]); } /** * Returns the payment hash of the swap and lightning network invoice, or `null` if not known (i.e. if * the swap was recovered from on-chain data, the payment hash might not be known) * * @internal */ protected getPaymentHash(): Buffer | null { if(this.pr==null) return null; if(this.pr.toLowerCase().startsWith("ln")) { const parsed = bolt11Decode(this.pr); if(parsed.tagsObject.payment_hash==null) throw new Error("Swap invoice has no payment hash field!"); return Buffer.from(parsed.tagsObject.payment_hash, "hex"); } return Buffer.from(this.pr, "hex"); } /** * @inheritDoc * @internal */ protected canCommit(skipQuoteExpiryChecks?: boolean): boolean { return this._state===FromBTCLNSwapState.PR_PAID || (!!skipQuoteExpiryChecks && this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED); } /** * @inheritDoc */ getInputAddress(): string | null { return this.lnurl ?? this.pr ?? null; } /** * @inheritDoc */ getInputTxId(): string | null { const paymentHash = this.getPaymentHash(); if(paymentHash==null) return null; return paymentHash.toString("hex"); } /** * Returns the lightning network BOLT11 invoice that needs to be paid as an input to the swap. * * In case the swap is recovered from on-chain data, the address returned might be just a payment hash, * as it is impossible to retrieve the actual lightning network invoice paid purely from on-chain * data. */ getAddress(): string { return this.pr ?? ""; } /** * A hyperlink representation of the address + amount that the user needs to sends on the source chain. * This is suitable to be displayed in a form of QR code. * * @remarks * In case the swap is recovered from on-chain data, the address returned might be just a payment hash, * as it is impossible to retrieve the actual lightning network invoice paid purely from on-chain * data. */ getHyperlink(): string { return this.pr==null ? "" : "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 || !this.pr.toLowerCase().startsWith("ln")) return 0; const decoded = bolt11Decode(this.pr); if(decoded.timeExpireDate==null) throw new Error("Swap invoice doesn't contain expiry date field!"); 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 { if(this._data==null) return null; return Number(this.wrapper._getHtlcTimeout(this._data))*1000; } /** * Returns timeout time (in UNIX milliseconds) when the LN invoice will expire */ getTimeoutTime(): number { if(this.pr==null || !this.pr.toLowerCase().startsWith("ln")) return 0; const decoded = bolt11Decode(this.pr); if(decoded.timeExpireDate==null) throw new Error("Swap invoice doesn't contain expiry date field!"); return (decoded.timeExpireDate*1000); } /** * @inheritDoc */ isFinished(): boolean { return this._state===FromBTCLNSwapState.CLAIM_CLAIMED || this._state===FromBTCLNSwapState.QUOTE_EXPIRED || this._state===FromBTCLNSwapState.FAILED; } /** * @inheritDoc */ isClaimable(): boolean { return this._state===FromBTCLNSwapState.CLAIM_COMMITED; } /** * @inheritDoc */ isSuccessful(): boolean { return this._state===FromBTCLNSwapState.CLAIM_CLAIMED; } /** * @inheritDoc */ isFailed(): boolean { return this._state===FromBTCLNSwapState.FAILED || this._state===FromBTCLNSwapState.EXPIRED; } /** * @inheritDoc */ isInProgress(): boolean { return (this._state===FromBTCLNSwapState.PR_CREATED && this.initiated) || (this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED && this.initiated) || this._state===FromBTCLNSwapState.PR_PAID || this._state===FromBTCLNSwapState.CLAIM_COMMITED; } /** * @inheritDoc */ isQuoteExpired(): boolean { return this._state===FromBTCLNSwapState.QUOTE_EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired(): boolean { return this._state===FromBTCLNSwapState.QUOTE_EXPIRED || this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED; } /** * @inheritDoc * @internal */ _verifyQuoteDefinitelyExpired(): Promise<boolean> { if(this._state===FromBTCLNSwapState.PR_CREATED || (this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED && this.signatureData==null)) { return Promise.resolve(this.getDefinitiveExpiryTime()<Date.now()); } return super._verifyQuoteDefinitelyExpired(); } /** * @inheritDoc * @internal */ _verifyQuoteValid(): Promise<boolean> { if( this._state===FromBTCLNSwapState.PR_CREATED || (this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED && this.signatureData==null) ) { return Promise.resolve(this.getTimeoutTime()>Date.now()); } return super._verifyQuoteValid(); } ////////////////////////////// //// Amounts & fees /** * @inheritDoc */ getInputToken(): BtcToken<true> { return BitcoinTokens.BTCLN; } /** * @inheritDoc */ getInput(): TokenAmount<BtcToken<true>> { if(this.pr==null || !this.pr.toLowerCase().startsWith("ln")) return toTokenAmount(null, this.inputToken, this.wrapper._prices, this.pricingInfo); const parsed = bolt11Decode(this.pr); if(parsed.millisatoshis==null) throw new Error("Swap invoice doesn't contain msat amount field!"); const amount = (BigInt(parsed.millisatoshis) + 999n) / 1000n; return toTokenAmount(amount, this.inputToken, this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ getSmartChainNetworkFee(): Promise<TokenAmount<SCToken<T["ChainId"]>, true>> { return this.getCommitAndClaimNetworkFee(); } /** * @inheritDoc */ async hasEnoughForTxFees(): Promise<{ enoughBalance: boolean, balance: TokenAmount<SCToken<T["ChainId"]>, true>, required: TokenAmount<SCToken<T["ChainId"]>, true> }> { const [balance, feeRate] = await Promise.all([ this._contract.getBalance(this._getInitiator(), this.wrapper._chain.getNativeCurrencyAddress(), false), this.feeRate!=null ? Promise.resolve<string>(this.feeRate) : this._contract.getInitFeeRate( this.getSwapData().getOfferer(), this.getSwapData().getClaimer(), this.getSwapData().getToken(), this.getSwapData().getClaimHash() ) ]); const commitFee = await this._contract.getCommitFee(this._getInitiator(), this.getSwapData(), feeRate); const claimFee = await this._contract.getClaimFee(this._getInitiator(), this.getSwapData(), feeRate); const totalFee = commitFee + claimFee + this.getSwapData().getTotalDeposit(); return { enoughBalance: balance >= totalFee, balance: toTokenAmount(balance, this.wrapper._getNativeToken(), this.wrapper._prices, this.pricingInfo), required: toTokenAmount(totalFee, this.wrapper._getNativeToken(), this.wrapper._prices, this.pricingInfo) }; } private isValidSecretPreimage(secret: string) { const paymentHash = Buffer.from(sha256(Buffer.from(secret, "hex"))); const claimHash = this._contract.getHashForHtlc(paymentHash).toString("hex"); return this.getSwapData().getClaimHash()===claimHash; } /** * Sets the secret preimage for the swap, in case it is not known already * * @param secret Secret preimage that matches the expected payment hash * * @throws {Error} If an invalid secret preimage is provided */ setSecretPreimage(secret: string) { if(!this.isValidSecretPreimage(secret)) throw new Error("Invalid secret preimage provided, hash doesn't match!"); this.secret = secret; } /** * Returns whether the secret preimage for this swap is known */ hasSecretPreimage(): boolean { return this.secret != null; } ////////////////////////////// //// Execution /** * Executes the swap with the provided bitcoin lightning network wallet or LNURL * * @param dstSigner Signer on the destination network, needs to have the same address as the one specified when * quote was created, this is required for legacy swaps because the destination wallet needs to actively claim * the swap funds on the destination (this also means you need native token to cover gas costs) * @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 * @param options.secret A swap secret to use for the claim transaction, generally only needed if the swap * was recovered from on-chain data, or the pre-image was generated outside the SDK */ async execute( dstSigner: T["Signer"] | T["NativeSigner"], walletOrLnurlWithdraw?: MinimalLightningNetworkWalletInterface | LNURLWithdraw | string | null | undefined, callbacks?: { onSourceTransactionReceived?: (sourceTxId: string) => void, onDestinationCommitSent?: (destinationCommitTxId: string) => void, onDestinationClaimSent?: (destinationClaimTxId: string) => void, onSwapSettled?: (destinationTxId: string) => void }, options?: { abortSignal?: AbortSignal, secret?: string, lightningTxCheckIntervalSeconds?: number, delayBetweenCommitAndClaimSeconds?: number } ): Promise<boolean> { if(this._state===FromBTCLNSwapState.FAILED) throw new Error("Swap failed!"); if(this._state===FromBTCLNSwapState.EXPIRED) throw new Error("Swap HTLC expired!"); if(this._state===FromBTCLNSwapState.QUOTE_EXPIRED || this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED) throw new Error("Swap quote expired!"); if(this._state===FromBTCLNSwapState.CLAIM_CLAIMED) throw new Error("Swap already settled!"); let abortSignal = options?.abortSignal; if(this._state===FromBTCLNSwapState.PR_CREATED) { if(walletOrLnurlWithdraw!=null && this.lnurl==null) { if(this.pr==null || !this.pr.toLowerCase().startsWith("ln")) throw new Error("Input lightning network invoice not available, the swap was probably recovered!"); 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; } } const paymentSuccess = await this.waitForPayment(callbacks?.onSourceTransactionReceived, options?.lightningTxCheckIntervalSeconds, abortSignal); if (!paymentSuccess) throw new Error("Failed to receive lightning network payment"); } if(this._state===FromBTCLNSwapState.PR_PAID || this._state===FromBTCLNSwapState.CLAIM_COMMITED) { if(this.canCommitAndClaimInOneShot()) { await this.commitAndClaim(dstSigner, options?.abortSignal, undefined, callbacks?.onDestinationCommitSent, callbacks?.onDestinationClaimSent, options?.secret); } else { if(this._state===FromBTCLNSwapState.PR_PAID) { await this.commit(dstSigner, options?.abortSignal, undefined, callbacks?.onDestinationCommitSent); if(options?.delayBetweenCommitAndClaimSeconds!=null) await timeoutPromise(options.delayBetweenCommitAndClaimSeconds * 1000, options?.abortSignal); } if(this._state===FromBTCLNSwapState.CLAIM_COMMITED) { await this.claim(dstSigner, options?.abortSignal, callbacks?.onDestinationClaimSent, options?.secret); } } } // @ts-ignore if(this._state===FromBTCLNSwapState.CLAIM_CLAIMED) { if(callbacks?.onSwapSettled!=null) callbacks.onSwapSettled(this.getOutputTxId()!); } return true; } /** * @internal */ protected async _getExecutionStatus(options?: { secret?: string }) { if(options?.secret!=null) this.setSecretPreimage(options.secret); const state = this._state; let lightningPaymentStatus: SwapExecutionStepPayment<"LIGHTNING">["status"] = "inactive"; let destinationSettlementStatus: SwapExecutionStepSettlement<T["ChainId"], "awaiting_manual">["status"] = "inactive"; let buildCurrentAction: (actionOptions?: { skipChecks?: boolean }) => Promise< SwapExecutionActionSendToAddress<true> | SwapExecutionActionSignSmartChainTx<T> | undefined > = async () => undefined; switch(state) { case FromBTCLNSwapState.PR_CREATED: { const quoteValid = await this._verifyQuoteValid(); lightningPaymentStatus = quoteValid ? "awaiting" : "soft_expired"; if(quoteValid && this.pr!=null && this.pr.toLowerCase().startsWith("ln")) { buildCurrentAction = this._buildLightningPaymentAction.bind(this); } break; } case FromBTCLNSwapState.QUOTE_SOFT_EXPIRED: if(this.signatureData==null) { lightningPaymentStatus = "soft_expired"; } else { lightningPaymentStatus = "received"; destinationSettlementStatus = "soft_expired"; } break; case FromBTCLNSwapState.PR_PAID: case FromBTCLNSwapState.CLAIM_COMMITED: lightningPaymentStatus = "received"; destinationSettlementStatus = "awaiting_manual"; if( (state!==FromBTCLNSwapState.PR_PAID || await this._verifyQuoteValid()) && this.hasSecretPreimage() ) { buildCurrentAction = this._buildClaimSmartChainTxAction.bind(this); } break; case FromBTCLNSwapState.CLAIM_CLAIMED: lightningPaymentStatus = "confirmed"; destinationSettlementStatus = "settled"; break; case FromBTCLNSwapState.EXPIRED: case FromBTCLNSwapState.FAILED: lightningPaymentStatus = "expired"; destinationSettlementStatus = "expired"; break; case FromBTCLNSwapState.QUOTE_EXPIRED: if(this.signatureData==null) { lightningPaymentStatus = "expired"; } else { lightningPaymentStatus = "expired"; destinationSettlementStatus = "expired"; } break; } return { steps: [ { type: "Payment", side: "source", chain: "LIGHTNING", title: "Lightning payment", description: "Pay the Lightning network invoice to initiate the swap", status: lightningPaymentStatus, initTxId: this.getInputTxId(), settleTxId: lightningPaymentStatus==="confirmed" ? this.getInputTxId() : undefined }, { type: "Settlement", side: "destination", chain: this.chainIdentifier, title: "Destination settlement", description: `Manually settle the swap on the ${this.chainIdentifier} side`, status: destinationSettlementStatus, initTxId: this._commitTxId, settleTxId: this._claimTxId } ] as [ SwapExecutionStepPayment<"LIGHTNING">, SwapExecutionStepSettlement<T["ChainId"], "awaiting_manual"> ], buildCurrentAction, state }; } /** * @internal */ private async _buildLightningPaymentAction(): Promise<SwapExecutionActionSendToAddress<true>> { return { type: "SendToAddress", name: "Deposit on Lightning", description: "Pay the lightning network invoice to initiate the swap", chain: "LIGHTNING", txs: [{ type: "BOLT11_PAYMENT_REQUEST", address: this.getAddress(), hyperlink: this.getHyperlink(), amount: this.getInput() }], waitForTransactions: async ( maxWaitTimeSeconds?: number, pollIntervalSeconds?: number, abortSignal?: AbortSignal ) => { const abortController = extendAbortController( abortSignal, maxWaitTimeSeconds, "Timed out waiting for lightning payment" ); const success = await this.waitForPayment( undefined, pollIntervalSeconds, abortController.signal ); if(!success) throw new Error("Quote expired while waiting for Lightning payment"); return this.getInputTxId(); } } as SwapExecutionActionSendToAddress<true>; } /** * @inheritDoc * @internal */ async _submitExecutionTransactions(txs: (T["SignedTXType"] | string)[], abortSignal?: AbortSignal, requiredStates?: FromBTCLNSwapState[], 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._claimTxId===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===FromBTCLNSwapState.PR_PAID || this._state===FromBTCLNSwapState.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); await this.waitTillClaimed(undefined, abortSignal); return txIds; } if(this._state===FromBTCLNSwapState.CLAIM_COMMITED) { const txIds = await this.wrapper._chain.sendSignedAndConfirm(parsedTxs, true, abortSignal, false); await this.waitTillClaimed(undefined, abortSignal); return txIds; } throw new Error("Invalid swap state for transaction submission!"); } /** * @internal */ private async _buildClaimSmartChainTxAction(actionOptions?: { skipChecks?: boolean, secret?: string }): Promise<SwapExecutionActionSignSmartChainTx<T>> { return { type: "SignSmartChainTransaction", name: "Settle manually", description: "Create the HTLC escrow and settle the swap on the destination smart chain", chain: this.chainIdentifier, txs: await this.prepareTransactions(this.txsCommitAndClaim(actionOptions?.skipChecks, actionOptions?.secret)), submitTransactions: async (txs: (T["SignedTXType"] | string)[], abortSignal?: AbortSignal, idempotent?: boolean) => { return this._submitExecutionTransactions( txs, abortSignal, [ FromBTCLNSwapState.PR_PAID, FromBTCLNSwapState.QUOTE_SOFT_EXPIRED, FromBTCLNSwapState.CLAIM_COMMITED ], idempotent ); }, requiredSigner: this._getInitiator() } as SwapExecutionActionSignSmartChainTx<T>; } /** * @inheritDoc * * @param options * @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.secret A swap secret to use for the claim transaction, generally only needed if the swap * was recovered from on-chain data, or the pre-image was generated outside the SDK */ async getExecutionAction(options?: { skipChecks?: boolean, secret?: string }): Promise< SwapExecutionActionSendToAddress<true> | SwapExecutionActionSignSmartChainTx<T> | undefined > { const executionStatus = await this._getExecutionStatus(options); return executionStatus.buildCurrentAction(options); } /** * @inheritDoc */ // TODO: Figure how we gonna trigger an LNURL-withdraw with the execution actions async getExecutionStatus(options?: { skipBuildingAction?: boolean, skipChecks?: boolean, secret?: string }): Promise<{ steps: [ SwapExecutionStepPayment<"LIGHTNING">, SwapExecutionStepSettlement<T["ChainId"], "awaiting_manual"> ], currentAction: SwapExecutionActionSendToAddress<true> | SwapExecutionActionSignSmartChainTx<T> | undefined, stateInfo: SwapStateInfo<FromBTCLNSwapState> }> { const executionStatus = await this._getExecutionStatus(options); return { steps: executionStatus.steps, currentAction: options?.skipBuildingAction ? undefined : await executionStatus.buildCurrentAction(options), stateInfo: this._getStateInfo(executionStatus.state) }; } /** * @inheritDoc */ async getExecutionSteps(): Promise<[ SwapExecutionStepPayment<"LIGHTNING">, SwapExecutionStepSettlement<T["ChainId"], "awaiting_manual"> ]> { return (await this._getExecutionStatus()).steps; } ////////////////////////////// //// 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 * * @internal */ async _checkIntermediaryPaymentReceived(save: boolean = true): Promise<boolean | null> { if( this._state===FromBTCLNSwapState.PR_PAID || this._state===FromBTCLNSwapState.CLAIM_COMMITED || this._state===FromBTCLNSwapState.CLAIM_CLAIMED || this._state===FromBTCLNSwapState.FAILED || this._state===FromBTCLNSwapState.EXPIRED ) return true; if(this._state===FromBTCLNSwapState.QUOTE_EXPIRED || (this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED && this.signatureData!=null)) return false; if(this.url==null) return false; const paymentHash = this.getPaymentHash(); if(paymentHash==null) throw new Error("Failed to check LP payment received, payment hash not known (probably recovered swap?)"); const resp = await this.wrapper._lpApi.getPaymentAuthorization(this.url, paymentHash.toString("hex")); switch(resp.code) { case PaymentAuthorizationResponseCodes.AUTH_DATA: const data = new (this.wrapper._swapDataDeserializer(this._contractVersion))(resp.data.data); try { await this.checkIntermediaryReturnedAuthData(this._getInitiator(), data, resp.data); this.expiry = await this._contract.getInitAuthorizationExpiry( data, resp.data ); this._state = FromBTCLNSwapState.PR_PAID; this._data = data; this.signatureData = { prefix: resp.data.prefix, timeout: resp.data.timeout, signature: resp.data.signature }; this.initiated = true; if(save) await this._saveAndEmit(); return true; } catch (e) {} return null; case PaymentAuthorizationResponseCodes.EXPIRED: this._state = FromBTCLNSwapState.QUOTE_EXPIRED; this.initiated = true; if(save) await this._saveAndEmit(); return false; default: return null; } } /** * Checks the data returned by the intermediary in the payment auth request * * @param signer Smart chain signer's address initiating the swap * @param data Parsed swap data as returned by the intermediary * @param signature Signature data as returned by the intermediary * * @throws {IntermediaryError} If the returned are not valid * @throws {SignatureVerificationError} If the returned signature is not valid * @throws {Error} If the swap is already committed on-chain * * @internal */ protected async checkIntermediaryReturnedAuthData(signer: string, data: T["Data"], signature: SignatureData): Promise<void> { data.setClaimer(signer); 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() !== 0n) throw new IntermediaryError("Invalid claimer bounty!"); 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"); await Promise.all([ this._contract.isValidInitAuthorization(this._getInitiator(), data, signature, this.feeRate), this._contract.getCommitStatus(data.getClaimer(), data) .then(status => { if (status?.type !== SwapCommitStateType.NOT_COMMITED) throw new Error("Swap already committed on-chain!"); }) ]); } /** * Waits till a lightning network payment is received by the intermediary and client * can continue by initiating (committing) & settling (claiming) the HTLC by calling * either the {@link commitAndClaim} function (if the underlying chain allows commit * and claim in a single transaction - check with {@link canCommitAndClaimInOneShot}). * Or call {@link commit} and then {@link claim} separately. * * If this swap is using an LNURL-withdraw link as input, it automatically posts the * generated invoice to the LNURL service to pay it. * * @param onPaymentReceived Callback as for when the LP reports having received the ln payment * @param abortSignal Abort signal to stop waiting for payment * @param checkIntervalSeconds How often to poll the intermediary for answer (default 5 seconds) */ async waitForPayment(onPaymentReceived?: (txId: string) => void, checkIntervalSeconds?: number, abortSignal?: AbortSignal): Promise<boolean> { checkIntervalSeconds ??= 5; if( this._state!==FromBTCLNSwapState.PR_CREATED && (this._state!==FromBTCLNSwapState.QUOTE_SOFT_EXPIRED || this.signatureData!=null) ) throw new Error("Must be in PR_CREATED state!"); if(this.url==null) throw new Error("LP URL not known, cannot await the payment!"); const abortController = new AbortController(); if(abortSignal!=null) abortSignal.addEventListener("abort", () => abortController.abort(abortSignal.reason)); let save = false; if(this.lnurl!=null && this.lnurlK1!=null && this.lnurlCallback!=null && !this.prPosted) { if(this.pr==null || !this.pr.toLowerCase().startsWith("ln")) throw new Error("Input lightning network invoice not available, the swap was probably recovered!"); 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(); const paymentHash = this.getPaymentHash(); if(paymentHash==null) throw new Error("Swap payment hash not available, the swap was probably recovered!"); let resp: PaymentAuthorizationResponse = {code: PaymentAuthorizationResponseCodes.PENDING, msg: ""}; while(!abortController.signal.aborted && resp.code===PaymentAuthorizationResponseCodes.PENDING) { resp = await this.wrapper._lpApi.getPaymentAuthorization(this.url, paymentHash.toString("hex")); if(resp.code===PaymentAuthorizationResponseCodes.PENDING) await timeoutPromise(checkIntervalSeconds*1000, abortController.signal); } this.lnurlFailSignal.signal.removeEventListener("abort", lnurlFailListener); abortController.signal.throwIfAborted(); if(resp.code===PaymentAuthorizationResponseCodes.AUTH_DATA) { const sigData = resp.data; const swapData = new (this.wrapper._swapDataDeserializer(this._contractVersion))(resp.data.data); await this.checkIntermediaryReturnedAuthData(this._getInitiator(), swapData, sigData); this.expiry = await this._contract.getInitAuthorizationExpiry( swapData, sigData ); if(onPaymentReceived!=null) onPaymentReceived(this.getInputTxId()!); if(this._state===FromBTCLNSwapState.PR_CREATED || this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED) { this._data = swapData; this.signatureData = { prefix: sigData.prefix, timeout: sigData.timeout, signature: sigData.signature }; await this._saveAndEmit(FromBTCLNSwapState.PR_PAID); } return true; } if(this._state===FromBTCLNSwapState.PR_CREATED || this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED) { if(resp.code===PaymentAuthorizationResponseCodes.EXPIRED) { await this._saveAndEmit(FromBTCLNSwapState.QUOTE_EXPIRED); } return false; } throw new IntermediaryError("Invalid response from the LP"); } ////////////////////////////// //// Commit /** * @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); let txCount = 0; const txs = await this.txsCommit(skipChecks); const result = await this.wrapper._chain.sendAndConfirm( signer, txs, true, abortSignal, undefined, (txId: string) => { txCount++; if(onBeforeTxSent!=null && txCount===txs.length) onBeforeTxSent(txId); return Promise.resolve(); } ); this._commitTxId = result[result.length-1]; if(this._state===FromBTCLNSwapState.PR_PAID || this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED || this._state===FromBTCLNSwapState.QUOTE_EXPIRED) { await this._saveAndEmit(FromBTCLNSwapState.CLAIM_COMMITED); } return this._commitTxId; } /** * @inheritDoc */ async waitTillCommited(abortSignal?: AbortSignal): Promise<void> { if(this._state===FromBTCLNSwapState.CLAIM_COMMITED || this._state===FromBTCLNSwapState.CLAIM_CLAIMED) return Promise.resolve(); if(this._state!==FromBTCLNSwapState.PR_PAID && (this._state!==FromBTCLNSwapState.QUOTE_SOFT_EXPIRED && this.signatureData!=null)) throw new Error("Invalid state"); const abortController = extendAbortController(abortSignal); const result = await Promise.race([ this.watchdogWaitTillCommited(undefined, abortController.signal), this.waitTillState(FromBTCLNSwapState.CLAIM_COMMITED, "gte", abortController.signal).then(() => 0) ]); abortController.abort(); if(result===0) { this.logger.debug("waitTillCommited(): Resolved from state changed"); } else if(result!=null) { this.logger.debug("waitTillCommited(): Resolved from watchdog - commited"); } if(result===null) { this.logger.debug("waitTillCommited(): Resolved from watchdog - signature expired"); if( this._state===FromBTCLNSwapState.PR_PAID || this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED ) { await this._saveAndEmit(FromBTCLNSwapState.QUOTE_EXPIRED); } return; } if( this._state===FromBTCLNSwapState.PR_PAID || this._state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED ) { if(typeof(result)==="object" && (result as any).getInitTxId!=null && this._commitTxId==null) this._commitTxId = await (result as any).getInitTxId(); await this._saveAndEmit(FromBTCLNSwapState.CLAIM_COMMITED); } } ////////////////////////////// //// Claim /** * Unsafe txs claim getter without state checking! * * @param _signer * @param secret A swap secret to use for the claim transaction, generally only needed if the swap * was recovered from on-chain data, or the pre-image was generated outside the SDK * * @internal */ private async _txsClaim(_signer?: string | T["Signer"] | T["NativeSigner"], secret?: string): Promise<T["TX"][]> { let address: string | undefined = undefined; if(_signer!=null) { if (typeof (_signer) === "string") { address = _signer; } else if (isAbstractSigner(_signer)) { address = _signer.getAddress(); } else { address = (await this.wrapper._chain.wrapSigner(_signer)).getAddress(); } } if(this._data==null) throw new Error("Unknown data, wrong state?"); const useSecret = secret ?? this.secret; if(useSecret==null) throw new Error("Swap secret pre-imag