@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
415 lines (372 loc) • 18.9 kB
text/typescript
import {FromBTCLNSwap, FromBTCLNSwapInit, FromBTCLNSwapState} from "./FromBTCLNSwap";
import {IFromBTCWrapper} from "../IFromBTCWrapper";
import {decode as bolt11Decode, PaymentRequestObject, TagsObject} from "@atomiqlabs/bolt11";
import {
ChainSwapType,
ChainType,
ClaimEvent,
InitializeEvent,
RefundEvent,
SwapData
} from "@atomiqlabs/base";
import {Intermediary} from "../../../intermediaries/Intermediary";
import {Buffer} from "buffer";
import {UserError} from "../../../errors/UserError";
import {sha256} from "@noble/hashes/sha2";
import {IntermediaryError} from "../../../errors/IntermediaryError";
import {SwapType} from "../../SwapType";
import {extendAbortController, randomBytes, tryWithRetries} from "../../../utils/Utils";
import {FromBTCLNResponseType, IntermediaryAPI} from "../../../intermediaries/IntermediaryAPI";
import {RequestError} from "../../../errors/RequestError";
import {LightningNetworkApi, LNNodeLiquidity} from "../../../btc/LightningNetworkApi";
import {ISwapPrice} from "../../../prices/abstract/ISwapPrice";
import {EventEmitter} from "events";
import {AmountData, ISwapWrapperOptions, WrapperCtorTokens} from "../../ISwapWrapper";
import {LNURL, LNURLWithdrawParamsWithUrl} from "../../../utils/LNURL";
import {UnifiedSwapEventListener} from "../../../events/UnifiedSwapEventListener";
import {UnifiedSwapStorage} from "../../UnifiedSwapStorage";
export type FromBTCLNOptions = {
descriptionHash?: Buffer
};
export class FromBTCLNWrapper<
T extends ChainType
> extends IFromBTCWrapper<T, FromBTCLNSwap<T>> {
public readonly TYPE = SwapType.FROM_BTCLN;
public readonly swapDeserializer = FromBTCLNSwap;
protected readonly lnApi: LightningNetworkApi;
/**
* @param chainIdentifier
* @param unifiedStorage Storage interface for the current environment
* @param unifiedChainEvents On-chain event listener
* @param contract Underlying contract handling the swaps
* @param prices Swap pricing handler
* @param tokens
* @param swapDataDeserializer Deserializer for SwapData
* @param lnApi
* @param options
* @param events Instance to use for emitting events
*/
constructor(
chainIdentifier: string,
unifiedStorage: UnifiedSwapStorage<T>,
unifiedChainEvents: UnifiedSwapEventListener<T>,
contract: T["Contract"],
prices: ISwapPrice,
tokens: WrapperCtorTokens,
swapDataDeserializer: new (data: any) => T["Data"],
lnApi: LightningNetworkApi,
options: ISwapWrapperOptions,
events?: EventEmitter
) {
super(chainIdentifier, unifiedStorage, unifiedChainEvents, contract, prices, tokens, swapDataDeserializer, options, events);
this.lnApi = lnApi;
}
public readonly pendingSwapStates = [
FromBTCLNSwapState.PR_CREATED,
FromBTCLNSwapState.QUOTE_SOFT_EXPIRED,
FromBTCLNSwapState.PR_PAID,
FromBTCLNSwapState.CLAIM_COMMITED,
FromBTCLNSwapState.EXPIRED
];
public readonly tickSwapState = [
FromBTCLNSwapState.PR_CREATED,
FromBTCLNSwapState.PR_PAID,
FromBTCLNSwapState.CLAIM_COMMITED
];
protected async processEventInitialize(swap: FromBTCLNSwap<T>, event: InitializeEvent<T["Data"]>): Promise<boolean> {
if(swap.state===FromBTCLNSwapState.PR_PAID || swap.state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED) {
if(swap.state===FromBTCLNSwapState.PR_PAID || swap.state===FromBTCLNSwapState.QUOTE_SOFT_EXPIRED) swap.state = FromBTCLNSwapState.CLAIM_COMMITED;
return true;
}
}
protected processEventClaim(swap: FromBTCLNSwap<T>, event: ClaimEvent<T["Data"]>): Promise<boolean> {
if(swap.state!==FromBTCLNSwapState.FAILED) {
swap.state = FromBTCLNSwapState.CLAIM_CLAIMED;
return Promise.resolve(true);
}
return Promise.resolve(false);
}
protected processEventRefund(swap: FromBTCLNSwap<T>, event: RefundEvent<T["Data"]>): Promise<boolean> {
if(swap.state!==FromBTCLNSwapState.CLAIM_CLAIMED) {
swap.state = FromBTCLNSwapState.FAILED;
return Promise.resolve(true);
}
return Promise.resolve(false);
}
/**
* Returns the swap expiry, leaving enough time for the user to claim the HTLC
*
* @param data Parsed swap data
*/
getHtlcTimeout(data: SwapData): bigint {
return data.getExpiry() - 600n;
}
/**
* Generates a new 32-byte secret to be used as pre-image for lightning network invoice & HTLC swap\
*
* @private
* @returns Hash pre-image & payment hash
*/
private getSecretAndHash(): {secret: Buffer, paymentHash: Buffer} {
const secret = randomBytes(32);
const paymentHash = Buffer.from(sha256(secret));
return {secret, paymentHash};
}
/**
* Pre-fetches intermediary's LN node capacity, doesn't throw, instead returns null
*
* @param pubkeyPromise Promise that resolves when we receive "lnPublicKey" param from the intermediary thorugh
* streaming
* @private
* @returns LN Node liquidity
*/
private preFetchLnCapacity(pubkeyPromise: Promise<string>): Promise<LNNodeLiquidity | null> {
return pubkeyPromise.then(pubkey => {
if(pubkey==null) return null;
return this.lnApi.getLNNodeLiquidity(pubkey)
}).catch(e => {
this.logger.warn("preFetchLnCapacity(): Error: ", e);
return null;
})
}
/**
* Verifies response returned from intermediary
*
* @param resp Response as returned by the intermediary
* @param amountData
* @param lp Intermediary
* @param options Options as passed to the swap creation function
* @param decodedPr Decoded bolt11 lightning network invoice
* @param amountIn Amount in sats that will be paid for the swap
* @private
* @throws {IntermediaryError} in case the response is invalid
*/
private verifyReturnedData(
resp: FromBTCLNResponseType,
amountData: AmountData,
lp: Intermediary,
options: FromBTCLNOptions,
decodedPr: PaymentRequestObject & {tagsObject: TagsObject},
amountIn: bigint
): void {
if(lp.getAddress(this.chainIdentifier)!==resp.intermediaryKey) throw new IntermediaryError("Invalid intermediary address/pubkey");
if(options.descriptionHash!=null && decodedPr.tagsObject.purpose_commit_hash!==options.descriptionHash.toString("hex"))
throw new IntermediaryError("Invalid pr returned - description hash");
if(!amountData.exactIn) {
if(resp.total != amountData.amount) throw new IntermediaryError("Invalid amount returned");
} else {
if(amountIn !== amountData.amount) throw new IntermediaryError("Invalid payment request returned, amount mismatch");
}
}
/**
* Verifies whether the intermediary's lightning node has enough inbound capacity to receive the LN payment
*
* @param lp Intermediary
* @param decodedPr Decoded bolt11 lightning network invoice
* @param amountIn Amount to be paid for the swap in sats
* @param lnCapacityPrefetchPromise Pre-fetch for LN node capacity, preFetchLnCapacity()
* @param abortSignal
* @private
* @throws {IntermediaryError} if the lightning network node doesn't have enough inbound liquidity
* @throws {Error} if the lightning network node's inbound liquidity might be enough, but the swap would
* deplete more than half of the liquidity
*/
private async verifyLnNodeCapacity(
lp: Intermediary,
decodedPr: PaymentRequestObject & {tagsObject: TagsObject},
amountIn: bigint,
lnCapacityPrefetchPromise: Promise<LNNodeLiquidity | null>,
abortSignal?: AbortSignal
): Promise<void> {
let result: LNNodeLiquidity = await lnCapacityPrefetchPromise;
if(result==null) result = await this.lnApi.getLNNodeLiquidity(decodedPr.payeeNodeKey);
if(abortSignal!=null) abortSignal.throwIfAborted();
if(result===null) throw new IntermediaryError("LP's lightning node not found in the lightning network graph!");
lp.lnData = result
if(decodedPr.payeeNodeKey!==result.publicKey) throw new IntermediaryError("Invalid pr returned - payee pubkey");
if(result.capacity < amountIn)
throw new IntermediaryError("LP's lightning node doesn't have enough inbound capacity for the swap!");
if((result.capacity / 2n) < amountIn)
throw new Error("LP's lightning node probably doesn't have enough inbound capacity for the swap!");
}
/**
* Returns a newly created swap, receiving 'amount' on lightning network
*
* @param signer Smart chain signer's address intiating the swap
* @param amountData Amount of token & amount to swap
* @param lps LPs (liquidity providers) to get the quotes from
* @param options Quote options
* @param additionalParams Additional parameters sent to the LP when creating the swap
* @param abortSignal Abort signal for aborting the process
* @param preFetches
*/
create(
signer: string,
amountData: AmountData,
lps: Intermediary[],
options: FromBTCLNOptions,
additionalParams?: Record<string, any>,
abortSignal?: AbortSignal,
preFetches?: {
pricePrefetchPromise?: Promise<bigint>,
feeRatePromise?: Promise<any>
}
): {
quote: Promise<FromBTCLNSwap<T>>,
intermediary: Intermediary
}[] {
if(options==null) options = {};
if(preFetches==null) preFetches = {};
if(options.descriptionHash!=null && options.descriptionHash.length!==32)
throw new UserError("Invalid description hash length");
const {secret, paymentHash} = this.getSecretAndHash();
const claimHash = this.contract.getHashForHtlc(paymentHash);
const _abortController = extendAbortController(abortSignal);
preFetches.pricePrefetchPromise ??= this.preFetchPrice(amountData, _abortController.signal);
const nativeTokenAddress = this.contract.getNativeCurrencyAddress();
preFetches.feeRatePromise ??= this.preFetchFeeRate(signer, amountData, claimHash.toString("hex"), _abortController);
return lps.map(lp => {
return {
intermediary: lp,
quote: (async () => {
const abortController = extendAbortController(_abortController.signal);
const liquidityPromise: Promise<bigint> = this.preFetchIntermediaryLiquidity(amountData, lp, abortController);
const {lnCapacityPromise, resp} = await tryWithRetries(async(retryCount: number) => {
const {lnPublicKey, response} = IntermediaryAPI.initFromBTCLN(
this.chainIdentifier, lp.url, nativeTokenAddress,
{
paymentHash,
amount: amountData.amount,
claimer: signer,
token: amountData.token.toString(),
descriptionHash: options.descriptionHash,
exactOut: !amountData.exactIn,
feeRate: preFetches.feeRatePromise,
additionalParams
},
this.options.postRequestTimeout, abortController.signal, retryCount>0 ? false : null
);
return {
lnCapacityPromise: this.preFetchLnCapacity(lnPublicKey),
resp: await response
};
}, null, RequestError, abortController.signal);
const decodedPr = bolt11Decode(resp.pr);
const amountIn = (BigInt(decodedPr.millisatoshis) + 999n) / 1000n;
try {
this.verifyReturnedData(resp, amountData, lp, options, decodedPr, amountIn);
const [pricingInfo] = await Promise.all([
this.verifyReturnedPrice(
lp.services[SwapType.FROM_BTCLN], false, amountIn, resp.total,
amountData.token, resp, preFetches.pricePrefetchPromise, abortController.signal
),
this.verifyIntermediaryLiquidity(resp.total, liquidityPromise),
this.verifyLnNodeCapacity(lp, decodedPr, amountIn, lnCapacityPromise, abortController.signal)
]);
const quote = new FromBTCLNSwap<T>(this, {
pricingInfo,
url: lp.url,
expiry: decodedPr.timeExpireDate*1000,
swapFee: resp.swapFee,
feeRate: await preFetches.feeRatePromise,
initialSwapData: await this.contract.createSwapData(
ChainSwapType.HTLC, lp.getAddress(this.chainIdentifier), signer, amountData.token,
resp.total, claimHash.toString("hex"),
this.getRandomSequence(), BigInt(Math.floor(Date.now()/1000)), false, true,
resp.securityDeposit, 0n, nativeTokenAddress
),
pr: resp.pr,
secret: secret.toString("hex"),
exactIn: amountData.exactIn ?? true
} as FromBTCLNSwapInit<T["Data"]>);
await quote._save();
return quote;
} catch (e) {
abortController.abort(e);
throw e;
}
})()
}
});
}
/**
* Parses and fetches lnurl withdraw params from the specified lnurl
*
* @param lnurl LNURL to be parsed and fetched
* @param abortSignal
* @private
* @throws {UserError} if the LNURL is invalid or if it's not a LNURL-withdraw
*/
private async getLNURLWithdraw(lnurl: string | LNURLWithdrawParamsWithUrl, abortSignal: AbortSignal): Promise<LNURLWithdrawParamsWithUrl> {
if(typeof(lnurl)!=="string") return lnurl;
const res = await LNURL.getLNURL(lnurl, true, this.options.getRequestTimeout, abortSignal);
if(res==null) throw new UserError("Invalid LNURL");
if(res.tag!=="withdrawRequest") throw new UserError("Not a LNURL-withdrawal");
return res;
}
/**
* Returns a newly created swap, receiving 'amount' from the lnurl-withdraw
*
* @param signer Smart chains signer's address intiating the swap
* @param lnurl LNURL-withdraw to withdraw funds from
* @param amountData Amount of token & amount to swap
* @param lps LPs (liquidity providers) to get the quotes from
* @param additionalParams Additional parameters sent to the LP when creating the swap
* @param abortSignal Abort signal for aborting the process
*/
async createViaLNURL(
signer: string,
lnurl: string | LNURLWithdrawParamsWithUrl,
amountData: AmountData,
lps: Intermediary[],
additionalParams?: Record<string, any>,
abortSignal?: AbortSignal
): Promise<{
quote: Promise<FromBTCLNSwap<T>>,
intermediary: Intermediary
}[]> {
if(!this.isInitialized) throw new Error("Not initialized, call init() first!");
const abortController = extendAbortController(abortSignal);
const preFetches = {
pricePrefetchPromise: this.preFetchPrice(amountData, abortController.signal),
feeRatePromise: this.preFetchFeeRate(signer, amountData, null, abortController)
};
try {
const exactOutAmountPromise: Promise<bigint> = !amountData.exactIn ? preFetches.pricePrefetchPromise.then(price =>
this.prices.getToBtcSwapAmount(this.chainIdentifier, amountData.amount, amountData.token, abortController.signal, price)
).catch(e => {
abortController.abort(e);
return null;
}) : null;
const withdrawRequest = await this.getLNURLWithdraw(lnurl, abortController.signal);
const min = BigInt(withdrawRequest.minWithdrawable) / 1000n;
const max = BigInt(withdrawRequest.maxWithdrawable) / 1000n;
if(amountData.exactIn) {
if(amountData.amount < min) throw new UserError("Amount less than LNURL-withdraw minimum");
if(amountData.amount > max) throw new UserError("Amount more than LNURL-withdraw maximum");
} else {
const amount = await exactOutAmountPromise;
abortController.signal.throwIfAborted();
if((amount * 95n / 100n) < min) throw new UserError("Amount less than LNURL-withdraw minimum");
if((amount * 105n / 100n) > max) throw new UserError("Amount more than LNURL-withdraw maximum");
}
return this.create(signer, amountData, lps, null, additionalParams, abortSignal, preFetches).map(data => {
return {
quote: data.quote.then(quote => {
quote.lnurl = withdrawRequest.url;
quote.lnurlK1 = withdrawRequest.k1;
quote.lnurlCallback = withdrawRequest.callback;
const amountIn = quote.getInput().rawAmount;
if(amountIn < min) throw new UserError("Amount less than LNURL-withdraw minimum");
if(amountIn > max) throw new UserError("Amount more than LNURL-withdraw maximum");
return quote;
}),
intermediary: data.intermediary
}
});
} catch (e) {
abortController.abort(e);
throw e;
}
}
}