@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
510 lines (457 loc) • 24.5 kB
text/typescript
import {decode as bolt11Decode, PaymentRequestObject, TagsObject} from "@atomiqlabs/bolt11";
import {
ChainSwapType,
ChainType,
ClaimEvent,
InitializeEvent, Messenger,
RefundEvent
} from "@atomiqlabs/base";
import {Intermediary} from "../../../../intermediaries/Intermediary";
import {Buffer} from "buffer";
import {UserError} from "../../../../errors/UserError";
import {IntermediaryError} from "../../../../errors/IntermediaryError";
import {SwapType} from "../../../enums/SwapType";
import {extendAbortController, randomBytes, tryWithRetries} from "../../../../utils/Utils";
import {
FromBTCLNAutoResponseType,
IntermediaryAPI
} from "../../../../intermediaries/IntermediaryAPI";
import {RequestError} from "../../../../errors/RequestError";
import {LightningNetworkApi} from "../../../../btc/LightningNetworkApi";
import {ISwapPrice} from "../../../../prices/abstract/ISwapPrice";
import {EventEmitter} from "events";
import {AmountData, ISwapWrapperOptions, WrapperCtorTokens} from "../../../ISwapWrapper";
import {LNURLWithdrawParamsWithUrl} from "../../../../utils/LNURL";
import {UnifiedSwapEventListener} from "../../../../events/UnifiedSwapEventListener";
import {UnifiedSwapStorage} from "../../../../storage/UnifiedSwapStorage";
import {ISwap} from "../../../ISwap";
import {FromBTCLNAutoSwap, FromBTCLNAutoSwapInit, FromBTCLNAutoSwapState} from "./FromBTCLNAutoSwap";
import { IFromBTCLNWrapper } from "../IFromBTCLNWrapper";
import {IClaimableSwapWrapper} from "../../../IClaimableSwapWrapper";
export type FromBTCLNAutoOptions = {
descriptionHash?: Buffer,
unsafeSkipLnNodeCheck?: boolean,
gasAmount?: bigint,
unsafeZeroWatchtowerFee?: boolean,
feeSafetyFactor?: number
};
export type FromBTCLNAutoWrapperOptions = ISwapWrapperOptions & {
safetyFactor?: number,
bitcoinBlocktime?: number,
unsafeSkipLnNodeCheck?: boolean
};
export class FromBTCLNAutoWrapper<
T extends ChainType
> extends IFromBTCLNWrapper<T, FromBTCLNAutoSwap<T>, FromBTCLNAutoWrapperOptions> implements IClaimableSwapWrapper<FromBTCLNAutoSwap<T>> {
public readonly claimableSwapStates = [FromBTCLNAutoSwapState.CLAIM_COMMITED];
public readonly TYPE = SwapType.FROM_BTCLN_AUTO;
public readonly swapDeserializer = FromBTCLNAutoSwap;
protected readonly lnApi: LightningNetworkApi;
readonly messenger: Messenger;
/**
* @param chainIdentifier
* @param unifiedStorage Storage interface for the current environment
* @param unifiedChainEvents On-chain event listener
* @param chain
* @param contract Underlying contract handling the swaps
* @param prices Swap pricing handler
* @param tokens
* @param swapDataDeserializer Deserializer for SwapData
* @param lnApi
* @param messenger
* @param options
* @param events Instance to use for emitting events
*/
constructor(
chainIdentifier: string,
unifiedStorage: UnifiedSwapStorage<T>,
unifiedChainEvents: UnifiedSwapEventListener<T>,
chain: T["ChainInterface"],
contract: T["Contract"],
prices: ISwapPrice,
tokens: WrapperCtorTokens,
swapDataDeserializer: new (data: any) => T["Data"],
lnApi: LightningNetworkApi,
messenger: Messenger,
options: FromBTCLNAutoWrapperOptions,
events?: EventEmitter<{swapState: [ISwap]}>
) {
options.safetyFactor ??= 2;
options.bitcoinBlocktime ??= 10*60;
super(chainIdentifier, unifiedStorage, unifiedChainEvents, chain, contract, prices, tokens, swapDataDeserializer, lnApi, options, events);
this.messenger = messenger;
}
public readonly pendingSwapStates = [
FromBTCLNAutoSwapState.PR_CREATED,
FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED,
FromBTCLNAutoSwapState.PR_PAID,
FromBTCLNAutoSwapState.CLAIM_COMMITED,
FromBTCLNAutoSwapState.EXPIRED
];
public readonly tickSwapState = [
FromBTCLNAutoSwapState.PR_CREATED,
FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED,
FromBTCLNAutoSwapState.PR_PAID,
FromBTCLNAutoSwapState.CLAIM_COMMITED
];
protected async processEventInitialize(swap: FromBTCLNAutoSwap<T>, event: InitializeEvent<T["Data"]>): Promise<boolean> {
if(swap.state===FromBTCLNAutoSwapState.PR_PAID || swap.state===FromBTCLNAutoSwapState.PR_CREATED || swap.state===FromBTCLNAutoSwapState.QUOTE_SOFT_EXPIRED) {
if(swap.data==null) {
//Obtain data from the initialize event
const eventData = await event.swapData();
try {
await swap._saveRealSwapData(eventData, false);
this.logger.info("processEventInitialize("+swap.getId()+"): Successfully taken swap data from on-chain event!");
} catch (e) {
this.logger.error("processEventInitialize("+swap.getId()+"): Error when saving swap data for swap: ", e);
return false;
}
}
if(swap._getEscrowHash()!==event.escrowHash) {
this.logger.error("processEventInitialize("+swap.getId()+"): Error when processing event, escrow hashes don't match!");
return false;
}
swap.commitTxId = event.meta.txId;
swap.state = FromBTCLNAutoSwapState.CLAIM_COMMITED;
swap._broadcastSecret().catch(e => {
this.logger.error("processEventInitialize("+swap.getId()+"): Error when broadcasting swap secret: ", e);
});
return true;
}
return false;
}
protected processEventClaim(swap: FromBTCLNAutoSwap<T>, event: ClaimEvent<T["Data"]>): Promise<boolean> {
if(swap.state!==FromBTCLNAutoSwapState.FAILED && swap.state!==FromBTCLNAutoSwapState.CLAIM_CLAIMED) {
swap.claimTxId = event.meta.txId;
swap.state = FromBTCLNAutoSwapState.CLAIM_CLAIMED;
return Promise.resolve(true);
}
return Promise.resolve(false);
}
protected processEventRefund(swap: FromBTCLNAutoSwap<T>, event: RefundEvent<T["Data"]>): Promise<boolean> {
if(swap.state!==FromBTCLNAutoSwapState.CLAIM_CLAIMED && swap.state!==FromBTCLNAutoSwapState.FAILED) {
swap.state = FromBTCLNAutoSwapState.FAILED;
return Promise.resolve(true);
}
return Promise.resolve(false);
}
/**
* Pre-fetches claimer (watchtower) bounty data for the swap. Doesn't throw, instead returns null and aborts the
* provided abortController
*
* @param signer Smartchain signer address initiating the swap
* @param amountData
* @param options Options as passed to the swap creation function
* @param abortController
* @private
*/
private async preFetchClaimerBounty(
signer: string,
amountData: AmountData,
options: FromBTCLNAutoOptions,
abortController: AbortController
): Promise<bigint | null> {
if(options.unsafeZeroWatchtowerFee) return 0n;
const dummyAmount = BigInt(Math.floor(Math.random()* 0x1000000));
const dummySwapData = await this.contract.createSwapData(
ChainSwapType.HTLC, this.chain.randomAddress(), signer, amountData.token,
dummyAmount, this.contract.getHashForHtlc(randomBytes(32)).toString("hex"),
this.getRandomSequence(), BigInt(Math.floor(Date.now()/1000)), false, true,
BigInt(Math.floor(Math.random() * 0x10000)), BigInt(Math.floor(Math.random() * 0x10000))
);
try {
const result = await tryWithRetries<bigint>(
() => this.contract.getClaimFee(this.chain.randomAddress(), dummySwapData),
null, null, abortController.signal
);
return result * BigInt(Math.floor(options.feeSafetyFactor*1000000)) / 1_000_000n
} catch (e) {
abortController.abort(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 paymentHash Expected payment hash of the bolt11 lightning network invoice
* @param claimerBounty Claimer bounty as request by the user
* @private
* @throws {IntermediaryError} in case the response is invalid
*/
private verifyReturnedData(
resp: FromBTCLNAutoResponseType,
amountData: AmountData,
lp: Intermediary,
options: FromBTCLNAutoOptions,
decodedPr: PaymentRequestObject & {tagsObject: TagsObject},
paymentHash: Buffer,
claimerBounty: 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(!Buffer.from(decodedPr.tagsObject.payment_hash, "hex").equals(paymentHash))
throw new IntermediaryError("Invalid pr returned - payment hash");
const amountIn = (BigInt(decodedPr.millisatoshis) + 999n) / 1000n;
if(resp.btcAmountGas + resp.btcAmountSwap !== amountIn) throw new IntermediaryError("Invalid total btc returned");
if(resp.gasSwapFeeBtc + resp.swapFeeBtc !== resp.totalFeeBtc) throw new IntermediaryError("Invalid total btc fee returned");
if(resp.claimerBounty !== claimerBounty) throw new IntermediaryError("Invalid claimer bounty");
if(resp.totalGas !== options.gasAmount) throw new IntermediaryError("Invalid total gas amount");
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");
}
}
/**
* 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: FromBTCLNAutoOptions,
additionalParams?: Record<string, any>,
abortSignal?: AbortSignal,
preFetches?: {
pricePrefetchPromise?: Promise<bigint>,
gasTokenPricePrefetchPromise?: Promise<bigint>,
claimerBountyPrefetch?: Promise<bigint>,
}
): {
quote: Promise<FromBTCLNAutoSwap<T>>,
intermediary: Intermediary
}[] {
if(options==null) options = {};
options.unsafeSkipLnNodeCheck ??= this.options.unsafeSkipLnNodeCheck;
options.gasAmount ??= 0n;
options.feeSafetyFactor ??= 1.25; //No need to add much of a margin, since the claim should happen rather soon
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.chain.getNativeCurrencyAddress();
preFetches.claimerBountyPrefetch ??= this.preFetchClaimerBounty(signer, amountData, options, _abortController);
if(options.gasAmount!==0n || !options.unsafeZeroWatchtowerFee)
preFetches.gasTokenPricePrefetchPromise ??= this.preFetchPrice({token: nativeTokenAddress}, _abortController.signal);
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.initFromBTCLNAuto(
this.chainIdentifier, lp.url,
{
paymentHash,
amount: amountData.amount,
claimer: signer,
token: amountData.token.toString(),
descriptionHash: options.descriptionHash,
exactOut: !amountData.exactIn,
additionalParams,
gasToken: this.chain.getNativeCurrencyAddress(),
gasAmount: options.gasAmount,
claimerBounty: preFetches.claimerBountyPrefetch
},
this.options.postRequestTimeout, abortController.signal, retryCount>0 ? false : null
);
return {
lnCapacityPromise: options.unsafeSkipLnNodeCheck ? null : this.preFetchLnCapacity(lnPublicKey),
resp: await response
};
}, null, RequestError, abortController.signal);
const decodedPr = bolt11Decode(resp.pr);
const amountIn = (BigInt(decodedPr.millisatoshis) + 999n) / 1000n;
const claimerBounty = await preFetches.claimerBountyPrefetch;
try {
this.verifyReturnedData(resp, amountData, lp, options, decodedPr, paymentHash, claimerBounty);
const [pricingInfo] = await Promise.all([
this.verifyReturnedPrice(
lp.services[SwapType.FROM_BTCLN_AUTO],
false, resp.btcAmountSwap,
resp.total,
amountData.token, {}, preFetches.pricePrefetchPromise, abortController.signal
),
options.gasAmount===0n ? Promise.resolve() : this.verifyReturnedPrice(
{...lp.services[SwapType.FROM_BTCLN_AUTO], swapBaseFee: 0}, //Base fee should be charged only on the amount, not on gas
false, resp.btcAmountGas,
resp.totalGas + resp.claimerBounty,
nativeTokenAddress, {}, preFetches.gasTokenPricePrefetchPromise, abortController.signal
),
this.verifyIntermediaryLiquidity(resp.total, liquidityPromise),
options.unsafeSkipLnNodeCheck ? Promise.resolve() : this.verifyLnNodeCapacity(lp, decodedPr, lnCapacityPromise, abortController.signal)
]);
const swapInit: FromBTCLNAutoSwapInit<T["Data"]> = {
pricingInfo,
url: lp.url,
expiry: decodedPr.timeExpireDate*1000,
swapFee: resp.swapFee,
gasSwapFee: resp.gasSwapFee,
swapFeeBtc: resp.swapFeeBtc,
gasSwapFeeBtc: resp.gasSwapFeeBtc,
btcAmountGas: resp.btcAmountGas,
btcAmountSwap: resp.btcAmountSwap,
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,
options.gasAmount + resp.claimerBounty, resp.claimerBounty, nativeTokenAddress
),
pr: resp.pr,
secret: secret.toString("hex"),
exactIn: amountData.exactIn ?? true
};
const quote = new FromBTCLNAutoSwap<T>(this, swapInit);
await quote._save();
this.logger.debug("create(): Created new FromBTCLNAutoSwap quote, claimHash (pseudo escrowHash): ", quote._getEscrowHash());
return quote;
} catch (e) {
abortController.abort(e);
throw e;
}
})()
}
});
}
/**
* 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 options
* @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[],
options: FromBTCLNAutoOptions,
additionalParams?: Record<string, any>,
abortSignal?: AbortSignal
): Promise<{
quote: Promise<FromBTCLNAutoSwap<T>>,
intermediary: Intermediary
}[]> {
options ??= {};
options.unsafeSkipLnNodeCheck ??= this.options.unsafeSkipLnNodeCheck;
options.gasAmount ??= 0n;
options.feeSafetyFactor ??= 1.25; //No need to add much of a margin, since the claim should happen rather soon
if(!this.isInitialized) throw new Error("Not initialized, call init() first!");
const abortController = extendAbortController(abortSignal);
const preFetches = {
pricePrefetchPromise: this.preFetchPrice(amountData, abortController.signal),
gasTokenPricePrefetchPromise: options.gasAmount==null && options.unsafeZeroWatchtowerFee ?
null :
this.preFetchPrice({token: this.chain.getNativeCurrencyAddress()}, abortController.signal),
claimerBountyPrefetch: this.preFetchClaimerBounty(signer, amountData, options, 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, options, 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;
}
}
protected async _checkPastSwaps(pastSwaps: FromBTCLNAutoSwap<T>[]): Promise<{
changedSwaps: FromBTCLNAutoSwap<T>[];
removeSwaps: FromBTCLNAutoSwap<T>[]
}> {
const changedSwapSet: Set<FromBTCLNAutoSwap<T>> = new Set();
const swapExpiredStatus: {[escrowHash: string]: boolean} = {};
const checkStatusSwaps: FromBTCLNAutoSwap<T>[] = [];
await Promise.all(pastSwaps.map(async (pastSwap) => {
if(pastSwap._shouldCheckIntermediary()) {
try {
const result = await pastSwap._checkIntermediaryPaymentReceived(false);
if(result!=null) {
changedSwapSet.add(pastSwap);
}
} catch (e) {
this.logger.error(`_checkPastSwaps(): Failed to contact LP regarding swap ${pastSwap.getId()}, error: `, e);
}
}
if(pastSwap._shouldFetchExpiryStatus()) {
//Check expiry
swapExpiredStatus[pastSwap.getEscrowHash()] = await pastSwap._verifyQuoteDefinitelyExpired();
}
if(pastSwap._shouldFetchCommitStatus()) {
//Add to swaps for which status should be checked
checkStatusSwaps.push(pastSwap);
}
}));
const swapStatuses = await this.contract.getCommitStatuses(checkStatusSwaps.map(val => ({signer: val._getInitiator(), swapData: val.data})));
for(let pastSwap of checkStatusSwaps) {
const escrowHash = pastSwap.getEscrowHash();
const shouldSave = await pastSwap._sync(false, swapExpiredStatus[escrowHash], swapStatuses[escrowHash], true);
if(shouldSave) {
changedSwapSet.add(pastSwap);
}
}
const changedSwaps: FromBTCLNAutoSwap<T>[] = [];
const removeSwaps: FromBTCLNAutoSwap<T>[] = [];
changedSwapSet.forEach(val => {
if(val.isQuoteExpired()) {
removeSwaps.push(val);
} else {
changedSwaps.push(val);
}
});
return {
changedSwaps,
removeSwaps
};
}
}