@atomiqlabs/sdk
Version:
atomiq labs SDK for cross-chain swaps between smart chains and bitcoin
611 lines (555 loc) • 26.7 kB
text/typescript
import {decode as bolt11Decode} from "@atomiqlabs/bolt11";
import {Address, Transaction} from "@scure/btc-signer";
import {LNURL} from "../lnurl/LNURL";
import {BTC_NETWORK} from "@scure/btc-signer/utils";
import {SwapType} from "../enums/SwapType";
import {ChainIds, MultiChain, Swapper} from "./Swapper";
import {IBitcoinWallet} from "../bitcoin/wallet/IBitcoinWallet";
import {SingleAddressBitcoinWallet} from "../bitcoin/wallet/SingleAddressBitcoinWallet";
import {BigIntBufferUtils, ChainSwapType, isAbstractSigner} from "@atomiqlabs/base";
import {bigIntMax, fromDecimal, randomBytes} from "../utils/Utils";
import {MinimalBitcoinWalletInterface} from "../types/wallets/MinimalBitcoinWalletInterface";
import {TokenAmount, toTokenAmount} from "../types/TokenAmount";
import {BitcoinTokens, SCToken} from "../types/Token";
import {isLNURLWithdraw, LNURLWithdraw} from "../types/lnurl/LNURLWithdraw";
import {isLNURLPay, LNURLPay} from "../types/lnurl/LNURLPay";
import {toBitcoinWallet} from "../utils/BitcoinWalletUtils";
/**
* Utility class providing helper methods for address parsing, token balances, serialization
* and other miscellaneous things.
*
* @category Core
*/
export class SwapperUtils<T extends MultiChain> {
readonly bitcoinNetwork: BTC_NETWORK;
private readonly root: Swapper<T>;
constructor(root: Swapper<T>) {
this.bitcoinNetwork = root._btcNetwork;
this.root = root;
}
/**
* Checks whether a passed address is a valid address on the smart chain
*
* @param address Address
* @param chainId Smart chain identifier string to check the address for
*/
isValidSmartChainAddress(address: string, chainId?: ChainIds<T>): boolean {
if(chainId!=null) {
if(this.root._chains[chainId]==null) throw new Error(`Unknown chain id: ${chainId}`);
return this.root._chains[chainId].chainInterface.isValidAddress(address);
}
for(let chainId of this.root.getSmartChains()) {
if(this.root._chains[chainId].chainInterface.isValidAddress(address)) return true;
}
return false;
}
/**
* Checks whether an address is a valid BOLT11 bitcoin lightning invoice
*
* @param address Address to check
*/
isLightningInvoice(address: string): boolean {
try {
bolt11Decode(address);
return true;
} catch (e) {}
return false;
}
/**
* Checks whether an address is a valid bitcoin address
*
* @param address Address to check
*/
isValidBitcoinAddress(address: string): boolean {
try {
Address(this.bitcoinNetwork).decode(address);
return true;
} catch (e) {
return false;
}
}
/**
* Checks whether an address is a valid BOLT11 bitcoin lightning invoice WITH AMOUNT
*
* @param address Address to check
*/
isValidLightningInvoice(address: string): boolean {
try {
const parsed = bolt11Decode(address);
if(parsed.millisatoshis!=null) return true;
} catch (e) {}
return false;
}
/**
* Checks whether an address is a valid LNURL (no checking on type is performed)
*
* @param address Address to check
*/
isValidLNURL(address: string): boolean {
return LNURL.isLNURL(address);
}
/**
* Returns type and data about an LNURL
*
* @param lnurl LNURL link to check, can be either `pay` or `withdraw` type
* @param shouldRetry Optional whether HTTP requests should retried on failure
*/
getLNURLTypeAndData(lnurl: string, shouldRetry?: boolean): Promise<LNURLPay | LNURLWithdraw | null> {
return LNURL.getLNURLType(lnurl, shouldRetry);
}
/**
* Returns satoshi value of BOLT11 bitcoin lightning invoice WITH AMOUNT, returns null otherwise
*
* @param lnpr
*/
getLightningInvoiceValue(lnpr: string): bigint | null {
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
} | null {
let _amount: bigint | undefined = undefined;
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 ? undefined : toTokenAmount(_amount, BitcoinTokens.BTC, this.root.prices)
};
}
return null;
}
private parseLNURLSync(resultText: string): {
address: string,
type: "LNURL",
swapType: null
} | null {
if(this.isValidLNURL(resultText)) {
return {
address: resultText,
type: "LNURL",
swapType: null
};
}
return 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
} | null> {
if(this.isValidLNURL(resultText)) {
try {
const result = await this.getLNURLTypeAndData(resultText);
if(result==null) throw new Error("Invalid LNURL specified!");
const swapType = isLNURLPay(result) ? SwapType.TO_BTCLN : isLNURLWithdraw(result) ? SwapType.FROM_BTCLN : null;
if(swapType==null) return null;
const response = {
address: resultText,
type: "LNURL",
swapType,
lnurl: result
} as const;
if(result.min===result.max) {
return {
...response,
amount: result.min==null ? undefined : toTokenAmount(result.min, BitcoinTokens.BTCLN, this.root.prices)
}
} else {
return {
...response,
min: result.min==null ? undefined : toTokenAmount(result.min, BitcoinTokens.BTCLN, this.root.prices),
max: result.min==null ? undefined : 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.");
}
}
return null;
}
private parseLightningInvoice(resultText: string): {
address: string,
type: "LIGHTNING",
swapType: SwapType.TO_BTCLN,
amount: TokenAmount
} | null {
if(this.isLightningInvoice(resultText)) {
if(this.isValidLightningInvoice(resultText)) {
const amount = this.getLightningInvoiceValue(resultText);
if(amount==null) throw new Error();
return {
address: resultText,
type: "LIGHTNING",
swapType: SwapType.TO_BTCLN,
amount: toTokenAmount(amount, BitcoinTokens.BTCLN, this.root.prices)
}
} else {
throw new Error("Lightning invoice needs to contain an amount!");
}
}
return null;
}
private parseSmartchainAddress(resultText: string): {
address: string,
type: ChainIds<T>,
swapType: null,
min?: TokenAmount,
max?: TokenAmount
} | null {
for(let chainId of this.root.getSmartChains()) {
if(this.root._chains[chainId].chainInterface.isValidAddress(resultText)) {
return {
address: resultText,
type: chainId,
swapType: null
}
}
}
return null;
}
/**
* General parser for bitcoin addresses, LNURLs, lightning invoices, smart chain addresses. Also fetches LNURL data
* (hence async and 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
} | null> {
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, returns `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
} | null {
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);
}
/**
* Strips the URL encoding around `bitcoin:` and `lightning:` addresses, leaving just the raw address
*
* @param addressString Address to strip
*
* @returns Raw clean address
*/
stripAddress(addressString: string): string {
if(addressString.startsWith("lightning:") || addressString.startsWith("bitcoin:")) {
addressString = addressString.substring(addressString.indexOf(":")+1);
const delimeterIndex = addressString.indexOf("?");
if(delimeterIndex!==-1) addressString = addressString.substring(0, delimeterIndex);
}
return addressString;
}
/**
* Returns a random PSBT that can be used for fee estimation for SPV vault (UTXO-controlled vault) based swaps
* {@link SwapType.SPV_VAULT_FROM_BTC}, the last output (the LP output) is omitted to allow for coinselection
* algorithm to determine maximum sendable amount there
*
* @param chainIdentifier Smart chain to swap to
* @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 Bitcoin wallet to check the spendable balance for, can either be a simple
* bitcoin address string or a wallet object
* @param targetChain Destination smart chain for the swap, the ensures proper spendable balance
* is estimated taking into consideration different swap primitives available on different chains
* @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: toTokenAmount(result.balance, BitcoinTokens.BTC, this.root.prices),
feeRate: result.feeRate
}
}
/**
* Returns the maximum spendable balance of the smart chain 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 {defaultVersion, versionedContracts, chainInterface} = this.root._chains[token.chainId];
const {swapContract} = versionedContracts[defaultVersion];
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, chainInterface.randomAddress(), 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 toTokenAmount(finalBalance, token, this.root.prices);
}
/**
* Returns the address of the native currency of the smart 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 whether when swapping to the provided token a gas drop can be requested
*
* @param token
*/
destinationTokenSupportsGasDrop<ChainIdentifier extends ChainIds<T>>(token: SCToken<ChainIdentifier>): boolean {
if(this.root._chains[token.chainId]==null) throw new Error("Invalid chain identifier! Unknown chain: "+token.chainId);
const {chainInterface} = this.root._chains[token.chainId];
if(chainInterface.shouldGetNativeTokenDrop!=null) return chainInterface.shouldGetNativeTokenDrop(token.address);
return chainInterface.getNativeCurrencyAddress() !== token.address;
}
/**
* 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 or bitcoin
*
* @param chainIdentifier
*/
randomAddress<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier | "BITCOIN"): string {
if(chainIdentifier==="BITCOIN") {
// Return random p2wkh address
return Address(this.bitcoinNetwork).encode({
type: "wpkh",
hash: randomBytes(20)
});
}
if(this.root._chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier);
return this.root._chains[chainIdentifier].chainInterface.randomAddress();
}
/**
* Signs and broadcasts the supplied smart chain transaction
*
* @param chainIdentifier Smart chain identifier string
* @param signer Signer to use for signing the transactions
* @param txs An array of transactions to sign
* @param abortSignal Abort signal
* @param onBeforePublish Callback invoked before a transaction is sent (invoked for every transaction to be sent)
*/
sendAndConfirm<ChainIdentifier extends ChainIds<T>>(
chainIdentifier: ChainIdentifier,
signer: T[ChainIdentifier]["NativeSigner"] | T[ChainIdentifier]["Signer"],
txs: T[ChainIdentifier]["TX"][],
abortSignal?: AbortSignal,
onBeforePublish?: (txId: string, rawTx: string) => Promise<void>
): Promise<string[]> {
if(this.root._chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier);
return this.root._chains[chainIdentifier].chainInterface.sendAndConfirm(signer, txs, true, abortSignal, false, onBeforePublish);
}
/**
* Broadcasts already signed smart chain transactions
*
* @param chainIdentifier Smart chain identifier string
* @param txs An array of already signed transactions
* @param abortSignal Abort signal
* @param onBeforePublish Callback invoked before a transaction is sent (invoked for every transaction to be sent)
*/
sendSignedAndConfirm<ChainIdentifier extends ChainIds<T>>(
chainIdentifier: ChainIdentifier,
txs: T[ChainIdentifier]["SignedTXType"][],
abortSignal?: AbortSignal,
onBeforePublish?: (txId: string, rawTx: string) => Promise<void>
): Promise<string[]> {
if(this.root._chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier);
return this.root._chains[chainIdentifier].chainInterface.sendSignedAndConfirm(txs, true, abortSignal, false, onBeforePublish);
}
/**
* Prepares a set of unsigned transactions for signing, by adding required nonces or recent blockhashes, might
* also add hints of account deployment on e.g. Starknet
*
* @param chainIdentifier A chain for which to prepare the txs
* @param txs Transactions to prepare
*/
prepareUnsignedTransactions<ChainIdentifier extends ChainIds<T>>(
chainIdentifier: ChainIdentifier,
txs: T[ChainIdentifier]["TX"][]
): Promise<T[ChainIdentifier]["TX"][]> {
if(this.root._chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier);
const chainInterface = this.root._chains[chainIdentifier].chainInterface;
if(chainInterface.prepareTxs==null) throw new Error("Chain doesn't support tx preparation, chainId: "+chainIdentifier);
return chainInterface.prepareTxs(txs);
}
/**
* Serializes an unsigned smart chain transaction
*
* @param chainIdentifier Smart chain string identifier
* @param tx An unsigned transaction to serialize
*/
serializeUnsignedTransaction<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier, tx: T[ChainIdentifier]["TX"]): Promise<string> {
if(this.root._chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier);
return this.root._chains[chainIdentifier].chainInterface.serializeTx(tx);
}
/**
* Deserializes an unsigned smart chain transaction
*
* @param chainIdentifier Smart chain string identifier
* @param tx Serialized unsigned transaction
*/
deserializeUnsignedTransaction<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier, tx: string): Promise<T[ChainIdentifier]["TX"]> {
if(this.root._chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier);
return this.root._chains[chainIdentifier].chainInterface.deserializeTx(tx);
}
/**
* Serializes a signed smart chain transaction
*
* @param chainIdentifier Smart chain string identifier
* @param tx A signed transaction to serialize
*/
serializeSignedTransaction<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier, tx: T[ChainIdentifier]["SignedTXType"]): Promise<string> {
if(this.root._chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier);
return this.root._chains[chainIdentifier].chainInterface.serializeSignedTx(tx);
}
/**
* Deserializes a signed smart chain transaction
*
* @param chainIdentifier Smart chain string identifier
* @param tx Serialized signed transaction
*/
deserializeSignedTransaction<ChainIdentifier extends ChainIds<T>>(chainIdentifier: ChainIdentifier, tx: string): Promise<T[ChainIdentifier]["SignedTXType"]> {
if(this.root._chains[chainIdentifier]==null) throw new Error("Invalid chain identifier! Unknown chain: "+chainIdentifier);
return this.root._chains[chainIdentifier].chainInterface.deserializeSignedTx(tx);
}
}