@atomiqlabs/sdk
Version:
atomiq labs SDK for cross-chain swaps between smart chains and bitcoin
754 lines (680 loc) • 25.9 kB
text/typescript
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);
}
}