UNPKG

@atomiqlabs/sdk-lib

Version:

Basic SDK functionality library for atomiq

445 lines (400 loc) 18.2 kB
import {decode as bolt11Decode} from "@atomiqlabs/bolt11"; import {Address, Transaction} from "@scure/btc-signer"; import {isLNURLPay, isLNURLWithdraw, LNURL, LNURLPay, LNURLWithdraw} from "../../../utils/LNURL"; import {BTC_NETWORK} from "@scure/btc-signer/utils"; import {SwapType} from "../../enums/SwapType"; import {BitcoinTokens, fromDecimal, SCToken, TokenAmount, toTokenAmount} from "../../../Tokens"; import {ChainIds, MultiChain, Swapper} from "../Swapper"; import {IBitcoinWallet} from "../../../btc/wallet/IBitcoinWallet"; import {SingleAddressBitcoinWallet} from "../../../btc/wallet/SingleAddressBitcoinWallet"; import {BigIntBufferUtils, ChainSwapType, isAbstractSigner} from "@atomiqlabs/base"; import {bigIntMax, randomBytes} from "../../../utils/Utils"; import {MinimalBitcoinWalletInterface} from "../../../btc/wallet/MinimalBitcoinWalletInterface"; import {toBitcoinWallet} from "../../../utils/BitcoinHelpers"; export class SwapperUtils<T extends MultiChain> { readonly bitcoinNetwork: BTC_NETWORK; private readonly root: Swapper<T>; constructor(root: Swapper<T>) { this.bitcoinNetwork = root.bitcoinNetwork; this.root = root; } /** * Returns true if string is a valid BOLT11 bitcoin lightning invoice * * @param lnpr */ isLightningInvoice(lnpr: string): boolean { try { bolt11Decode(lnpr); return true; } catch (e) {} return false; } /** * Returns true if string is a valid bitcoin address * * @param addr */ isValidBitcoinAddress(addr: string): boolean { try { Address(this.bitcoinNetwork).decode(addr); return true; } catch (e) { return false; } } /** * Returns true if string is a valid BOLT11 bitcoin lightning invoice WITH AMOUNT * * @param lnpr */ isValidLightningInvoice(lnpr: string): boolean { try { const parsed = bolt11Decode(lnpr); if(parsed.millisatoshis!=null) return true; } catch (e) {} return false; } /** * Returns true if string is a valid LNURL (no checking on type is performed) * * @param lnurl */ isValidLNURL(lnurl: string): boolean { return LNURL.isLNURL(lnurl); } /** * Returns type and data about an LNURL * * @param lnurl * @param shouldRetry */ getLNURLTypeAndData(lnurl: string, shouldRetry?: boolean): Promise<LNURLPay | LNURLWithdraw | null> { return LNURL.getLNURLType(lnurl, shouldRetry); } /** * Returns satoshi value of BOLT11 bitcoin lightning invoice WITH AMOUNT * * @param lnpr */ getLightningInvoiceValue(lnpr: string): bigint { const parsed = bolt11Decode(lnpr); if(parsed.millisatoshis!=null) return (BigInt(parsed.millisatoshis) + 999n) / 1000n; return null; } private parseBitcoinAddress(resultText: string): { address: string, type: "BITCOIN", swapType: SwapType.TO_BTC, amount?: TokenAmount } { let _amount: bigint = null; if(resultText.includes("?")) { const arr = resultText.split("?"); resultText = arr[0]; const params = arr[1].split("&"); for(let param of params) { const arr2 = param.split("="); const key = arr2[0]; const value = decodeURIComponent(arr2[1]); if(key==="amount") { _amount = fromDecimal(parseFloat(value).toFixed(8), 8); } } } if(this.isValidBitcoinAddress(resultText)) { return { address: resultText, type: "BITCOIN", swapType: SwapType.TO_BTC, amount: _amount==null ? null : toTokenAmount(_amount, BitcoinTokens.BTC, this.root.prices) }; } } private parseLNURLSync(resultText: string): { address: string, type: "LNURL", swapType: null } { if(this.isValidLNURL(resultText)) { return { address: resultText, type: "LNURL", swapType: null }; } } private async parseLNURL(resultText: string): Promise<{ address: string, type: "LNURL", swapType: SwapType.TO_BTCLN | SwapType.FROM_BTCLN, lnurl: LNURLPay | LNURLWithdraw, min?: TokenAmount, max?: TokenAmount, amount?: TokenAmount }> { if(this.isValidLNURL(resultText)) { try { const result = await this.getLNURLTypeAndData(resultText); if(result==null) throw new Error("Invalid LNURL specified!"); const response = { address: resultText, type: "LNURL", swapType: isLNURLPay(result) ? SwapType.TO_BTCLN : isLNURLWithdraw(result) ? SwapType.FROM_BTCLN : null, lnurl: result } as const; if(result.min===result.max) { return { ...response, amount: result.min==null ? null : toTokenAmount(result.min, BitcoinTokens.BTCLN, this.root.prices) } } else { return { ...response, min: result.min==null ? null : toTokenAmount(result.min, BitcoinTokens.BTCLN, this.root.prices), max: result.min==null ? null : toTokenAmount(result.max, BitcoinTokens.BTCLN, this.root.prices) } } } catch (e) { throw new Error("Failed to contact LNURL service, check your internet connection and retry later."); } } } private parseLightningInvoice(resultText: string): { address: string, type: "LIGHTNING", swapType: SwapType.TO_BTCLN, amount: TokenAmount } { if(this.isLightningInvoice(resultText)) { if(this.isValidLightningInvoice(resultText)) { const amountBN = this.getLightningInvoiceValue(resultText); return { address: resultText, type: "LIGHTNING", swapType: SwapType.TO_BTCLN, amount: toTokenAmount(amountBN, BitcoinTokens.BTCLN, this.root.prices) } } else { throw new Error("Lightning invoice needs to contain an amount!"); } } } private parseSmartchainAddress(resultText: string): { address: string, type: ChainIds<T>, swapType: SwapType.SPV_VAULT_FROM_BTC, min?: TokenAmount, max?: TokenAmount } { for(let chainId of this.root.getSmartChains()) { if(this.root.chains[chainId].chainInterface.isValidAddress(resultText)) { if(this.root.supportsSwapType(chainId, SwapType.SPV_VAULT_FROM_BTC)) { return { address: resultText, type: chainId, swapType: SwapType.SPV_VAULT_FROM_BTC } } else { return { address: resultText, type: chainId, swapType: null } } } } } /** * General parser for bitcoin addresses, LNURLs, lightning invoices, smart chain addresses, also fetches LNURL data * (hence returns Promise) * * @param addressString Address to parse * @throws {Error} Error in address parsing * @returns Address data or null if address doesn't conform to any known format */ async parseAddress(addressString: string): Promise<{ address: string, type: "BITCOIN" | "LIGHTNING" | "LNURL" | ChainIds<T>, swapType: SwapType.TO_BTC | SwapType.TO_BTCLN | SwapType.SPV_VAULT_FROM_BTC | SwapType.FROM_BTCLN | null, lnurl?: LNURLPay | LNURLWithdraw, min?: TokenAmount, max?: TokenAmount, amount?: TokenAmount }> { if(addressString.startsWith("bitcoin:")) { const parsedBitcoinAddress = this.parseBitcoinAddress(addressString.substring(8)); if(parsedBitcoinAddress!=null) return parsedBitcoinAddress; throw new Error("Invalid bitcoin address!"); } const parsedBitcoinAddress = this.parseBitcoinAddress(addressString); if(parsedBitcoinAddress!=null) return parsedBitcoinAddress; if(addressString.startsWith("lightning:")) { const resultText = addressString.substring(10); const resultLnurl = await this.parseLNURL(resultText); if(resultLnurl!=null) return resultLnurl; const resultLightningInvoice = this.parseLightningInvoice(resultText); if(resultLightningInvoice!=null) return resultLightningInvoice; throw new Error("Invalid lightning network invoice or LNURL!"); } const resultLnurl = await this.parseLNURL(addressString); if(resultLnurl!=null) return resultLnurl; const resultLightningInvoice = this.parseLightningInvoice(addressString); if(resultLightningInvoice!=null) return resultLightningInvoice; return this.parseSmartchainAddress(addressString); } /** * Synchronous general parser for bitcoin addresses, LNURLs, lightning invoices, smart chain addresses, doesn't fetch * LNURL data, reports swapType: null instead to prevent returning a Promise * * @param addressString Address to parse * @throws {Error} Error in address parsing * @returns Address data or null if address doesn't conform to any known format */ parseAddressSync(addressString: string): { address: string, type: "BITCOIN" | "LIGHTNING" | "LNURL" | ChainIds<T>, swapType: SwapType.TO_BTC | SwapType.TO_BTCLN | SwapType.SPV_VAULT_FROM_BTC | null, min?: TokenAmount, max?: TokenAmount, amount?: TokenAmount } { if(addressString.startsWith("bitcoin:")) { const parsedBitcoinAddress = this.parseBitcoinAddress(addressString.substring(8)); if(parsedBitcoinAddress!=null) return parsedBitcoinAddress; throw new Error("Invalid bitcoin address!"); } const parsedBitcoinAddress = this.parseBitcoinAddress(addressString); if(parsedBitcoinAddress!=null) return parsedBitcoinAddress; if(addressString.startsWith("lightning:")) { const resultText = addressString.substring(10); const resultLnurl = this.parseLNURLSync(resultText); if(resultLnurl!=null) return resultLnurl; const resultLightningInvoice = this.parseLightningInvoice(resultText); if(resultLightningInvoice!=null) return resultLightningInvoice; throw new Error("Invalid lightning network invoice or LNURL!"); } const resultLnurl = this.parseLNURLSync(addressString); if(resultLnurl!=null) return resultLnurl; const resultLightningInvoice = this.parseLightningInvoice(addressString); if(resultLightningInvoice!=null) return resultLightningInvoice; return this.parseSmartchainAddress(addressString); } /** * Returns a random PSBT that can be used for fee estimation, the last output (the LP output) is omitted * to allow for coinselection algorithm to determine maximum sendable amount there * * @param chainIdentifier * @param includeGasToken Whether to return the PSBT also with the gas token amount (increases the vSize by 8) */ getRandomSpvVaultPsbt<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier, includeGasToken?: boolean): Transaction { const wrapper = this.root.chains[chainIdentifier].wrappers[SwapType.SPV_VAULT_FROM_BTC]; if(wrapper==null) throw new Error("Chain doesn't support spv vault swaps!"); return wrapper.getDummySwapPsbt(includeGasToken); } /** * Returns the spendable balance of a bitcoin wallet * * @param wallet * @param targetChain * @param options Additional options */ async getBitcoinSpendableBalance( wallet: string | IBitcoinWallet | MinimalBitcoinWalletInterface, targetChain?: ChainIds<T>, options?: { gasDrop?: boolean, feeRate?: number, minFeeRate?: number } ): Promise<{ balance: TokenAmount, feeRate: number }> { let bitcoinWallet: IBitcoinWallet; if(typeof(wallet)==="string") { bitcoinWallet = new SingleAddressBitcoinWallet(this.root.bitcoinRpc, this.bitcoinNetwork, {address: wallet, publicKey: ""}); } else { bitcoinWallet = toBitcoinWallet(wallet, this.root.bitcoinRpc, this.bitcoinNetwork); } let feeRate = options?.feeRate ?? await bitcoinWallet.getFeeRate(); if(options?.minFeeRate!=null) feeRate = Math.max(feeRate, options.minFeeRate); let result: {balance: bigint, feeRate: number, totalFee: number}; if(targetChain!=null && this.root.supportsSwapType(targetChain, SwapType.SPV_VAULT_FROM_BTC)) { result = await bitcoinWallet.getSpendableBalance(this.getRandomSpvVaultPsbt(targetChain, options?.gasDrop), feeRate); } else { result = await bitcoinWallet.getSpendableBalance(undefined, feeRate); } return { balance: result.balance==null ? null : toTokenAmount(result.balance, BitcoinTokens.BTC, this.root.prices), feeRate: result.feeRate } } /** * Returns the maximum spendable balance of the wallet, deducting the fee needed to initiate a swap for native balances */ async getSpendableBalance<ChainIdentifier extends ChainIds<T>>(wallet: string | T[ChainIdentifier]["Signer"] | T[ChainIdentifier]["NativeSigner"], token: SCToken<ChainIdentifier>, options?: { feeMultiplier?: number, feeRate?: any }): Promise<TokenAmount> { if(this.root.chains[token.chainId]==null) throw new Error("Invalid chain identifier! Unknown chain: "+token.chainId); const {swapContract, chainInterface} = this.root.chains[token.chainId]; let signer: string; if(typeof(wallet)==="string") { signer = wallet; } else { const abstractSigner = isAbstractSigner(wallet) ? wallet : await chainInterface.wrapSigner(wallet); signer = abstractSigner.getAddress(); } let finalBalance: bigint; if(chainInterface.getNativeCurrencyAddress()!==token.address) { finalBalance = await chainInterface.getBalance(signer, token.address); } else { let [balance, commitFee] = await Promise.all([ chainInterface.getBalance(signer, token.address), swapContract.getCommitFee( signer, //Use large amount, such that the fee for wrapping more tokens is always included! await swapContract.createSwapData( ChainSwapType.HTLC, signer, null, token.address, 0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffn, swapContract.getHashForHtlc(randomBytes(32)).toString("hex"), BigIntBufferUtils.fromBuffer(randomBytes(8)), BigInt(Math.floor(Date.now()/1000)), true, false, BigIntBufferUtils.fromBuffer(randomBytes(2)), BigIntBufferUtils.fromBuffer(randomBytes(2)) ), options?.feeRate ) ]); if(options?.feeMultiplier!=null) { commitFee = commitFee * (BigInt(Math.floor(options.feeMultiplier*1000000))) / 1000000n; } finalBalance = bigIntMax(balance - commitFee, 0n); } return finalBalance==null ? null : toTokenAmount(finalBalance, token, this.root.prices); } /** * Returns the address of the native currency of the chain */ getNativeToken<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier): SCToken<ChainIdentifier> { if(this.root.chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier); return this.root.tokens[chainIdentifier][this.root.chains[chainIdentifier].chainInterface.getNativeCurrencyAddress()] as SCToken<ChainIdentifier>; } /** * Returns a random signer for a given smart chain * * @param chainIdentifier */ randomSigner<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier): T[ChainIdentifier]["Signer"] { if(this.root.chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier); return this.root.chains[chainIdentifier].chainInterface.randomSigner(); } /** * Returns a random address for a given smart chain * * @param chainIdentifier */ randomAddress<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier): string { if(this.root.chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier); return this.root.chains[chainIdentifier].chainInterface.randomAddress(); } }