UNPKG

@atomiqlabs/sdk

Version:

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

754 lines (680 loc) 25.9 kB
import {decode as bolt11Decode} from "@atomiqlabs/bolt11"; import {SwapType} from "../../../enums/SwapType"; import {ChainType} from "@atomiqlabs/base"; import {LnForGasSwapTypeDefinition, LnForGasWrapper} from "./LnForGasWrapper"; import {extendAbortController, toBigInt} from "../../../utils/Utils"; import {isISwapInit, ISwap, ISwapInit} from "../../ISwap"; import {TrustedInvoiceStatusResponseCodes} from "../../../intermediaries/apis/IntermediaryAPI"; import {Fee} from "../../../types/fees/Fee"; import {IAddressSwap} from "../../IAddressSwap"; import {FeeType} from "../../../enums/FeeType"; import {ppmToPercentage} from "../../../types/fees/PercentagePPM"; 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 { SwapExecutionActionSendToAddress, SwapExecutionActionWait } from "../../../types/SwapExecutionAction"; import { SwapExecutionStepPayment, SwapExecutionStepSettlement } from "../../../types/SwapExecutionStep"; import {SwapStateInfo} from "../../../types/SwapStateInfo"; /** * State enum for trusted Lightning gas swaps * * @category Swaps/Trusted Gas Swaps */ export enum LnForGasSwapState { /** * The swap quote expired before the user paid the Lightning invoice */ EXPIRED = -2, /** * The swap has failed before the destination payout completed, and the held Lightning invoice was released */ FAILED = -1, /** * Swap was created, pay the provided Lightning invoice which will remain held until destination payout succeeds */ PR_CREATED = 0, /** * The Lightning invoice was paid and is currently held until the user receives the destination funds */ PR_PAID = 1, /** * The swap is finished after the destination payout succeeded and the held Lightning invoice was settled */ FINISHED = 2 } const LnForGasSwapStateDescription = { [LnForGasSwapState.EXPIRED]: "The swap quote expired before the user paid the Lightning invoice", [LnForGasSwapState.FAILED]: "The swap failed before destination payout completed, and the held Lightning invoice was released back to the user", [LnForGasSwapState.PR_CREATED]: "Swap was created, pay the provided Lightning invoice. The invoice will remain held until destination payout succeeds", [LnForGasSwapState.PR_PAID]: "The Lightning invoice was paid and is currently held. It will only settle once the user receives the destination funds", [LnForGasSwapState.FINISHED]: "The swap is finished after the destination payout succeeded and the held Lightning invoice was settled" } export type LnForGasSwapInit = ISwapInit & { pr: string; outputAmount: bigint; recipient: string; token: string; }; export function isLnForGasSwapInit(obj: any): obj is LnForGasSwapInit { return typeof(obj.pr)==="string" && typeof(obj.outputAmount) === "bigint" && typeof(obj.recipient)==="string" && typeof(obj.token)==="string" && isISwapInit(obj); } /** * Trusted swap for Bitcoin Lightning -> Smart chains, to be used for minor amounts to get gas tokens on * the destination chain, which is only needed for Solana, which still uses legacy swaps * * @category Swaps/Trusted Gas Swaps */ export class LnForGasSwap<T extends ChainType = ChainType> extends ISwap<T, LnForGasSwapTypeDefinition<T>, LnForGasSwapState> implements IAddressSwap { protected readonly TYPE: SwapType.TRUSTED_FROM_BTCLN = SwapType.TRUSTED_FROM_BTCLN; /** * @internal */ protected readonly swapStateDescription = LnForGasSwapStateDescription; /** * @internal */ protected readonly swapStateName = (state: number) => LnForGasSwapState[state]; /** * @internal */ protected readonly currentVersion: number = 2; /** * @internal */ protected readonly logger: LoggerType; //State: PR_CREATED private readonly pr: string; private readonly outputAmount: bigint; private readonly recipient: string; private readonly token: string; //State: FINISHED /** * Destination transaction ID on the smart chain side * @private */ private scTxId?: string; constructor(wrapper: LnForGasWrapper<T>, init: LnForGasSwapInit); constructor(wrapper: LnForGasWrapper<T>, obj: any); constructor( wrapper: LnForGasWrapper<T>, initOrObj: LnForGasSwapInit | any ) { if(isLnForGasSwapInit(initOrObj) && initOrObj.url!=null) initOrObj.url += "/lnforgas"; super(wrapper, initOrObj); if(isLnForGasSwapInit(initOrObj)) { this.pr = initOrObj.pr; this.outputAmount = initOrObj.outputAmount; this.recipient = initOrObj.recipient; this.token = initOrObj.token; this._state = LnForGasSwapState.PR_CREATED; } else { this.pr = initOrObj.pr; this.outputAmount = toBigInt(initOrObj.outputAmount); this.recipient = initOrObj.recipient; this.token = initOrObj.token; this.scTxId = initOrObj.scTxId; } this.tryRecomputeSwapPrice(); if(this.pr!=null) { const decoded = bolt11Decode(this.pr); if(decoded.timeExpireDate!=null) this.expiry = decoded.timeExpireDate*1000; } this.logger = getLogger("LnForGas("+this.getId()+"): "); } /** * @inheritDoc * @internal */ protected upgradeVersion() { if(this.version == 1) { if(this._state===1) this._state = LnForGasSwapState.FINISHED; this.version = 2; } if(this.version == null) { //Noop this.version = 1; } } /** * @inheritDoc * @internal */ protected tryRecomputeSwapPrice() { if(this.swapFeeBtc==null && this.swapFee!=null) { this.swapFeeBtc = this.swapFee * this.getInput().rawAmount / this.getOutAmountWithoutFee(); } super.tryRecomputeSwapPrice(); } ////////////////////////////// //// Getters & utils /** * @inheritDoc * @internal */ _getEscrowHash(): string { return this.getId(); } /** * @inheritDoc */ getOutputAddress(): string | null { return this.recipient; } /** * @inheritDoc */ getInputAddress(): string | null { return this.pr; } /** * @inheritDoc */ getInputTxId(): string | null { return this.getId(); } /** * @inheritDoc */ getOutputTxId(): string | null { return this.scTxId ?? null; } /** * @inheritDoc */ getId(): string { if(this.pr==null) throw new Error("No payment request assigned to this swap!"); const decodedPR = bolt11Decode(this.pr); if(decodedPR.tagsObject.payment_hash==null) throw new Error("Lightning invoice has no payment hash!"); return decodedPR.tagsObject.payment_hash; } /** * Returns the lightning network BOLT11 invoice that needs to be paid as an input to the swap */ getAddress(): string { return this.pr; } /** * Returns a string that can be displayed as QR code representation of the lightning invoice (with lightning: prefix) */ getHyperlink(): string { return "lightning:"+this.pr.toUpperCase(); } /** * @inheritDoc */ requiresAction(): boolean { return false; } /** * @inheritDoc */ isFinished(): boolean { return this._state===LnForGasSwapState.FINISHED || this._state===LnForGasSwapState.FAILED || this._state===LnForGasSwapState.EXPIRED; } /** * @inheritDoc */ isQuoteExpired(): boolean { return this._state===LnForGasSwapState.EXPIRED; } /** * @inheritDoc */ isQuoteSoftExpired(): boolean { return this.expiry<Date.now(); } /** * @inheritDoc */ isFailed(): boolean { return this._state===LnForGasSwapState.FAILED; } /** * @inheritDoc */ isSuccessful(): boolean { return this._state===LnForGasSwapState.FINISHED; } /** * @inheritDoc */ isInProgress(): boolean { return (this._state===LnForGasSwapState.PR_CREATED && this.initiated) || this._state===LnForGasSwapState.PR_PAID; } /** * @inheritDoc * @internal */ _verifyQuoteDefinitelyExpired(): Promise<boolean> { return Promise.resolve(this.expiry<Date.now()); } /** * @inheritDoc * @internal */ _verifyQuoteValid(): Promise<boolean> { return Promise.resolve(this.expiry>Date.now()); } ////////////////////////////// //// Amounts & fees /** * Returns an output amount in base units without a swap fee included, hence this value * is larger than the actual output amount * * @internal */ protected getOutAmountWithoutFee(): bigint { return this.outputAmount + (this.swapFee ?? 0n); } /** * @inheritDoc */ getOutputToken(): SCToken<T["ChainId"]> { return this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()]; } /** * @inheritDoc */ getOutput(): TokenAmount<SCToken<T["ChainId"]>, true> { return toTokenAmount( this.outputAmount, this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()], this.wrapper._prices, this.pricingInfo ); } /** * @inheritDoc */ getInputToken(): BtcToken<true> { return BitcoinTokens.BTCLN; } /** * @inheritDoc */ getInput(): TokenAmount<BtcToken<true>, true> { const parsed = bolt11Decode(this.pr); const msats = parsed.millisatoshis; if(msats==null) throw new Error("Swap lightning invoice has no msat amount field!"); const amount = (BigInt(msats) + 999n) / 1000n; return toTokenAmount(amount, BitcoinTokens.BTCLN, this.wrapper._prices, this.pricingInfo); } /** * @inheritDoc */ getInputWithoutFee(): TokenAmount<BtcToken<true>, true> { const parsed = bolt11Decode(this.pr); const msats = parsed.millisatoshis; if(msats==null) throw new Error("Swap lightning invoice has no msat amount field!"); const amount = (BigInt(msats) + 999n) / 1000n; return toTokenAmount( amount - (this.swapFeeBtc ?? 0n), BitcoinTokens.BTCLN, this.wrapper._prices, this.pricingInfo ); } /** * Returns the swap fee charged by the intermediary (LP) on this swap * * @internal */ protected getSwapFee(): Fee<T["ChainId"], BtcToken<true>, SCToken<T["ChainId"]>> { if(this.pricingInfo==null) throw new Error("No pricing info known, cannot estimate swap fee!"); const feeWithoutBaseFee = this.swapFeeBtc==null ? 0n : this.swapFeeBtc - this.pricingInfo.satsBaseFee; const swapFeePPM = feeWithoutBaseFee * 1000000n / this.getInputWithoutFee().rawAmount; const amountInSrcToken = toTokenAmount(this.swapFeeBtc ?? 0n, BitcoinTokens.BTCLN, this.wrapper._prices, this.pricingInfo); return { amountInSrcToken, amountInDstToken: toTokenAmount(this.swapFee ?? 0n, this.wrapper._tokens[this.wrapper._chain.getNativeCurrencyAddress()], this.wrapper._prices, this.pricingInfo), currentUsdValue: amountInSrcToken.currentUsdValue, usdValue: amountInSrcToken.usdValue, pastUsdValue: amountInSrcToken.pastUsdValue, composition: { base: toTokenAmount(this.pricingInfo.satsBaseFee, BitcoinTokens.BTCLN, this.wrapper._prices, this.pricingInfo), percentage: ppmToPercentage(swapFeePPM) } }; } /** * @inheritDoc */ getFee(): Fee<T["ChainId"], BtcToken<true>, SCToken<T["ChainId"]>> { return this.getSwapFee(); } /** * @inheritDoc */ getFeeBreakdown(): [{type: FeeType.SWAP, fee: Fee<T["ChainId"], BtcToken<true>, SCToken<T["ChainId"]>>}] { return [{ type: FeeType.SWAP, fee: this.getSwapFee() }]; } ////////////////////////////// //// Payment /** * @remarks Not supported */ async execute(): Promise<boolean> { throw new Error("Not supported"); } /** * @internal */ protected async _getExecutionStatus() { const state = this._state; let lightningPaymentStatus: SwapExecutionStepPayment<"LIGHTNING">["status"] = "inactive"; let destinationSettlementStatus: SwapExecutionStepSettlement<T["ChainId"]>["status"] = "inactive"; let buildCurrentAction: () => Promise< SwapExecutionActionSendToAddress<true> | SwapExecutionActionWait<"LP"> | undefined > = async () => undefined; switch(state) { case LnForGasSwapState.PR_CREATED: { const quoteValid = await this._verifyQuoteValid(); lightningPaymentStatus = quoteValid ? "awaiting" : "soft_expired"; if(quoteValid) { buildCurrentAction = this._buildLightningPaymentAction.bind(this); } break; } case LnForGasSwapState.EXPIRED: lightningPaymentStatus = "expired"; break; case LnForGasSwapState.PR_PAID: lightningPaymentStatus = "received"; destinationSettlementStatus = "waiting_lp"; buildCurrentAction = this._buildWaitLpAction.bind(this); break; case LnForGasSwapState.FAILED: lightningPaymentStatus = "expired"; destinationSettlementStatus = "expired"; break; case LnForGasSwapState.FINISHED: lightningPaymentStatus = "confirmed"; destinationSettlementStatus = "settled"; break; } return { steps: [ { type: "Payment", side: "source", chain: "LIGHTNING", title: "Lightning payment", description: "Pay the Lightning network invoice to initiate the swap", status: lightningPaymentStatus }, { type: "Settlement", side: "destination", chain: this.chainIdentifier, title: "Destination payout", description: "Wait for the intermediary to send the gas tokens on the destination smart chain", status: destinationSettlementStatus } ] as [ SwapExecutionStepPayment<"LIGHTNING">, SwapExecutionStepSettlement<T["ChainId"], never> ], buildCurrentAction, state }; } /** * @internal * @inheritDoc */ _submitExecutionTransactions(): Promise<string[]> { throw new Error("Invalid swap state for transaction submission!"); } /** * @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.pr, 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" ); let lightningTxId: string | undefined; try { const success = await this.waitForPayment( pollIntervalSeconds, abortController.signal, (txId: string) => { lightningTxId = txId; abortController.abort(); } ); if(!success) throw new Error("Quote expired while waiting for lightning payment"); } catch (e) { if(lightningTxId!=null) return lightningTxId; throw e; } return this.getInputTxId()!; } } as SwapExecutionActionSendToAddress<true>; } /** * @internal */ private async _buildWaitLpAction(): Promise<SwapExecutionActionWait<"LP">> { return { type: "Wait", name: "Awaiting LP payout", description: "Wait for the intermediary to send the gas tokens on the destination smart chain", pollTimeSeconds: 5, expectedTimeSeconds: 10, wait: async ( maxWaitTimeSeconds?: number, pollIntervalSeconds?: number, abortSignal?: AbortSignal ) => { const abortController = extendAbortController( abortSignal, maxWaitTimeSeconds, "Timed out waiting for LP payout" ); await this.waitForPayment(pollIntervalSeconds, abortController.signal); } } as SwapExecutionActionWait<"LP">; } /** * @inheritDoc */ async getExecutionAction(): Promise< SwapExecutionActionSendToAddress<true> | SwapExecutionActionWait<"LP"> | undefined > { const executionStatus = await this._getExecutionStatus(); return executionStatus.buildCurrentAction(); } /** * @inheritDoc */ async getExecutionStatus(options?: {skipBuildingAction?: boolean}): Promise<{ steps: [ SwapExecutionStepPayment<"LIGHTNING">, SwapExecutionStepSettlement<T["ChainId"], never> ], currentAction: SwapExecutionActionSendToAddress<true> | SwapExecutionActionWait<"LP"> | undefined, stateInfo: SwapStateInfo<LnForGasSwapState> }> { const executionStatus = await this._getExecutionStatus(); return { steps: executionStatus.steps, currentAction: options?.skipBuildingAction ? undefined : await executionStatus.buildCurrentAction(), stateInfo: this._getStateInfo(executionStatus.state) }; } /** * @inheritDoc */ async getExecutionSteps(): Promise<[ SwapExecutionStepPayment<"LIGHTNING">, SwapExecutionStepSettlement<T["ChainId"], never> ]> { return (await this._getExecutionStatus()).steps; } /** * Queries the intermediary (LP) node for the state of the swap * * @param save Whether the save the result or not * * @returns Whether the swap was successful as `boolean` or `null` if the swap is still pending * @internal */ protected async checkInvoicePaid(save: boolean = true): Promise<boolean | null> { if(this._state===LnForGasSwapState.FAILED || this._state===LnForGasSwapState.EXPIRED) return false; if(this._state===LnForGasSwapState.FINISHED) return true; if(this.url==null) return false; const decodedPR = bolt11Decode(this.pr); const paymentHash = decodedPR.tagsObject.payment_hash; if(paymentHash==null) throw new Error("Invalid swap invoice, payment hash not found!"); const response = await this.wrapper._lpApi.getTrustedInvoiceStatus( this.url, paymentHash, this.wrapper._options.getRequestTimeout ); this.logger.debug("checkInvoicePaid(): LP response: ", response); switch(response.code) { case TrustedInvoiceStatusResponseCodes.PAID: this.scTxId = response.data.txId; const txStatus = await this.wrapper._chain.getTxIdStatus(this.scTxId); if(txStatus==="success") { this._state = LnForGasSwapState.FINISHED; if(save) await this._saveAndEmit(); return true; } return null; case TrustedInvoiceStatusResponseCodes.EXPIRED: if(this._state===LnForGasSwapState.PR_CREATED) { this._state = LnForGasSwapState.EXPIRED; } else { this._state = LnForGasSwapState.FAILED; } if(save) await this._saveAndEmit(); return false; case TrustedInvoiceStatusResponseCodes.TX_SENT: this.scTxId = response.data.txId; if(this._state===LnForGasSwapState.PR_CREATED) { this._state = LnForGasSwapState.PR_PAID; if(save) await this._saveAndEmit(); } return null; case TrustedInvoiceStatusResponseCodes.PENDING: if(this._state===LnForGasSwapState.PR_CREATED) { this._state = LnForGasSwapState.PR_PAID; if(save) await this._saveAndEmit(); } return null; case TrustedInvoiceStatusResponseCodes.AWAIT_PAYMENT: return null; default: this._state = LnForGasSwapState.FAILED; if(save) await this._saveAndEmit(); return false; } } /** * A blocking promise resolving when payment was received by the intermediary and client can continue, * rejecting in case of failure. The swap must be in {@link LnForGasSwapState.PR_CREATED} or * {@link LnForGasSwapState.PR_PAID} state! * * @param checkIntervalSeconds How often to poll the intermediary for answer (default 5 seconds) * @param abortSignal Abort signal * @param onPaymentReceived Callback as for when the LP reports having received the ln payment * @throws {Error} When in invalid state (not PR_CREATED) */ async waitForPayment(checkIntervalSeconds?: number, abortSignal?: AbortSignal, onPaymentReceived?: (txId: string) => void): Promise<boolean> { if(this._state!==LnForGasSwapState.PR_CREATED && this._state!==LnForGasSwapState.PR_PAID) throw new Error("Must be in PR_CREATED or PR_PAID state!"); if(!this.initiated) { this.initiated = true; await this._saveAndEmit(); } while(!abortSignal?.aborted && (this._state===LnForGasSwapState.PR_CREATED || this._state===LnForGasSwapState.PR_PAID)) { await this.checkInvoicePaid(true); if((this._state as LnForGasSwapState)===LnForGasSwapState.PR_PAID) { if(onPaymentReceived!=null) { onPaymentReceived(this.getInputTxId()!); onPaymentReceived = undefined; // Set to null so it only triggers once } } if(this._state===LnForGasSwapState.PR_CREATED || this._state===LnForGasSwapState.PR_PAID) await timeoutPromise((checkIntervalSeconds ?? 5)*1000, abortSignal); } if(abortSignal!=null) abortSignal.throwIfAborted(); if(this.isFailed()) throw new Error("Swap failed"); return !this.isQuoteExpired(); } ////////////////////////////// //// Storage /** * @inheritDoc */ serialize(): any{ return { ...super.serialize(), pr: this.pr, outputAmount: this.outputAmount==null ? null : this.outputAmount.toString(10), recipient: this.recipient, token: this.token, scTxId: this.scTxId }; } /** * @inheritDoc * @internal */ _getInitiator(): string { return this.recipient; } ////////////////////////////// //// Swap ticks & sync /** * @inheritDoc * @internal */ async _sync(save?: boolean): Promise<boolean> { if(this._state===LnForGasSwapState.PR_CREATED) { //Check if it's maybe already paid const res = await this.checkInvoicePaid(false); if(res!==null) { if(save) await this._saveAndEmit(); return true; } } return false; } /** * @inheritDoc * @internal */ _tick(save?: boolean): Promise<boolean> { return Promise.resolve(false); } }