@atomiqlabs/sdk-lib
Version:
Basic SDK functionality library for atomiq
445 lines (400 loc) • 18.2 kB
text/typescript
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();
}
}