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