diff --git a/packages/bitcoin/package.json b/packages/bitcoin/package.json index 6992edb27..868af4282 100644 --- a/packages/bitcoin/package.json +++ b/packages/bitcoin/package.json @@ -29,9 +29,9 @@ "@noble/hashes": "1.3.3", "@noble/secp256k1": "2.0.0", "@scure/base": "1.1.3", - "@scure/bip32": "1.3.2", + "@scure/bip32": "1.3.3", "@scure/bip39": "1.2.1", - "@scure/btc-signer": "1.1.0", + "@scure/btc-signer": "1.2.1", "@stacks/common": "6.8.1", "@stacks/transactions": "6.9.0", "bip32": "4.0.0", diff --git a/packages/constants/index.ts b/packages/constants/index.ts index f57fd4fd6..61b0aacaf 100644 --- a/packages/constants/index.ts +++ b/packages/constants/index.ts @@ -77,6 +77,9 @@ export interface NetworkConfiguration { }; } +export const BESTINSLOT_API_BASE_URL_MAINNET = 'https://leatherapi.bestinslot.xyz/v3'; +export const BESTINSLOT_API_BASE_URL_TESTNET = 'https://leatherapi_testnet.bestinslot.xyz/v3'; + export const HIRO_API_BASE_URL_MAINNET = 'https://api.hiro.so'; export const HIRO_API_BASE_URL_TESTNET = 'https://api.testnet.hiro.so'; export const HIRO_INSCRIPTIONS_API_URL = 'https://api.hiro.so/ordinals/v1/inscriptions'; diff --git a/packages/query/index.ts b/packages/query/index.ts index e210b0a80..270741bb9 100644 --- a/packages/query/index.ts +++ b/packages/query/index.ts @@ -10,8 +10,9 @@ export * from './src/bitcoin/bitcoin-client'; export * from './src/bitcoin/contract/send-accepted-bitcoin-contract-offer'; export * from './src/bitcoin/fees/fee-estimates.hooks'; export * from './src/bitcoin/fees/fee-estimates.query'; -export * from './src/bitcoin/ordinals/brc20/brc-20.utils'; +export * from './src/bitcoin/ordinals/brc20/brc20-tokens.hooks'; export * from './src/bitcoin/ordinals/brc20/brc20-tokens.query'; +export * from './src/bitcoin/ordinals/brc20/brc20-tokens.utils'; export * from './src/bitcoin/ordinals/inscription-by-id.query'; export * from './src/bitcoin/ordinals/inscription-text-content.query'; export * from './src/bitcoin/ordinals/inscription.hooks'; @@ -19,8 +20,25 @@ export * from './src/bitcoin/ordinals/inscription.query'; export * from './src/bitcoin/ordinals/inscriptions-by-param.query'; export * from './src/bitcoin/ordinals/inscriptions.hooks'; export * from './src/bitcoin/ordinals/inscriptions.query'; +export * from './src/bitcoin/ordinalsbot-client'; +export * from './src/bitcoin/runes/runes-outputs-by-address.query'; +export * from './src/bitcoin/runes/runes-ticker-info.query'; +export * from './src/bitcoin/runes/runes-wallet-balances.query'; +export * from './src/bitcoin/runes/runes.hooks'; +export * from './src/bitcoin/stamps/stamps-by-address.hooks'; export * from './src/bitcoin/stamps/stamps-by-address.query'; export * from './src/bitcoin/transaction/transaction.query'; export * from './src/bitcoin/transaction/use-bitcoin-broadcast-transaction'; export * from './src/bitcoin/transaction/use-check-utxos'; +export * from './src/common/market-data/market-data.hooks'; +export * from './src/common/market-data/market-data.query'; +export * from './src/common/market-data/vendors/binance-market-data.query'; +export * from './src/common/market-data/vendors/coincap-market-data.query'; +export * from './src/common/market-data/vendors/coingecko-market-data.query'; +export * from './src/common/remote-config/remote-config.query'; export * from './src/leather-query-provider'; +export * from './types/account'; +export * from './types/api-types'; +export * from './types/contract-types'; +export * from './types/inscription'; +export * from './types/utxo'; diff --git a/packages/query/package.json b/packages/query/package.json index 71f4d4aa1..964da98ea 100644 --- a/packages/query/package.json +++ b/packages/query/package.json @@ -29,8 +29,8 @@ "@leather-wallet/utils": "workspace:*", "@noble/hashes": "1.3.3", "@scure/base": "1.1.3", - "@scure/bip32": "1.3.2", - "@scure/btc-signer": "1.1.0", + "@scure/bip32": "1.3.3", + "@scure/btc-signer": "1.2.1", "@stacks/common": "6.8.1", "@stacks/rpc-client": "0.8.18", "@stacks/stacks-blockchain-api-types": "7.8.2", @@ -40,7 +40,11 @@ "@tanstack/react-query-persist-client": "4.35.7", "axios": "1.6.7", "bignumber.js": "9.1.2", - "yup": "1.3.3" + "lodash.get": "4.4.2", + "p-queue": "8.0.1", + "url-join": "5.0.0", + "yup": "1.3.3", + "zod": "3.23.6" }, "devDependencies": { "@leather-wallet/eslint-config": "workspace:*", @@ -48,6 +52,7 @@ "@leather-wallet/tsconfig-config": "workspace:*", "@tanstack/eslint-plugin-query": "5.20.1", "@types/jsdom": "21.1.3", + "@types/lodash.get": "4.4.9", "@types/react": "18.2.64", "@vitest/coverage-istanbul": "0.34.6", "eslint": "8.53.0", diff --git a/packages/query/src/bitcoin/address/transactions-by-address.query.ts b/packages/query/src/bitcoin/address/transactions-by-address.query.ts index 48db13fb0..5439ee6ab 100644 --- a/packages/query/src/bitcoin/address/transactions-by-address.query.ts +++ b/packages/query/src/bitcoin/address/transactions-by-address.query.ts @@ -1,12 +1,12 @@ import type { BitcoinTx } from '@leather-wallet/models'; -import { useQueries, useQuery } from '@tanstack/react-query'; +import { QueryFunctionContext, useQueries, useQuery } from '@tanstack/react-query'; import { AppUseQueryConfig } from '../../query-config'; import { useBitcoinClient } from '../bitcoin-client'; const staleTime = 10 * 1000; -const queryOptions = { staleTime, refetchInterval: staleTime }; +const queryOptions = { staleTime, cacheTime: Infinity, refetchInterval: staleTime }; export function useGetBitcoinTransactionsByAddressQuery( address: string, @@ -17,12 +17,11 @@ export function useGetBitcoinTransactionsByAddressQuery client.addressApi.getTransactionsByAddress(address), + queryFn: async ({ signal }) => client.addressApi.getTransactionsByAddress(address, signal), ...queryOptions, ...options, }); } - export function useGetBitcoinTransactionsByAddressesQuery( addresses: string[], options?: AppUseQueryConfig @@ -34,7 +33,8 @@ export function useGetBitcoinTransactionsByAddressesQuery client.addressApi.getTransactionsByAddress(address), + queryFn: async ({ signal }: QueryFunctionContext) => + client.addressApi.getTransactionsByAddress(address, signal), ...queryOptions, ...options, }; diff --git a/packages/query/src/bitcoin/address/utxos-by-address.hooks.ts b/packages/query/src/bitcoin/address/utxos-by-address.hooks.ts index ca160c1df..9cef89dcf 100644 --- a/packages/query/src/bitcoin/address/utxos-by-address.hooks.ts +++ b/packages/query/src/bitcoin/address/utxos-by-address.hooks.ts @@ -2,7 +2,9 @@ import { useCallback } from 'react'; import type { InscriptionResponseItem } from '../../../types/inscription'; import { UtxoResponseItem, UtxoWithDerivationPath } from '../../../types/utxo'; +import { RunesOutputsByAddress } from '../bitcoin-client'; import { useInscriptionsByAddressQuery } from '../ordinals/inscriptions.query'; +import { useRunesEnabled, useRunesOutputsByAddress } from '../runes/runes.hooks'; import { useBitcoinPendingTransactionsInputs } from './transactions-by-address.hooks'; import { useGetUtxosByAddressQuery } from './utxos-by-address.query'; @@ -18,9 +20,20 @@ export function filterUtxosWithInscriptions( ); } +export function filterUtxosWithRunes(runes: RunesOutputsByAddress[], utxos: UtxoResponseItem[]) { + return utxos.filter(utxo => { + const hasRuneOutput = runes.find(rune => { + return rune.output === `${utxo.txid}:${utxo.vout}`; + }); + + return !hasRuneOutput; + }); +} + const defaultArgs = { filterInscriptionUtxos: true, filterPendingTxsUtxos: true, + filterRunesUtxos: true, }; /** @@ -28,12 +41,13 @@ const defaultArgs = { * we set `filterInscriptionUtxos` and `filterPendingTxsUtxos` to true */ export function useCurrentNativeSegwitUtxos(nativeSegwitAddress: string, args = defaultArgs) { - const { filterInscriptionUtxos, filterPendingTxsUtxos } = args; + const { filterInscriptionUtxos, filterPendingTxsUtxos, filterRunesUtxos } = args; return useNativeSegwitUtxosByAddress({ address: nativeSegwitAddress, filterInscriptionUtxos, filterPendingTxsUtxos, + filterRunesUtxos, }); } @@ -41,6 +55,7 @@ interface UseFilterUtxosByAddressArgs { address: string; filterInscriptionUtxos: boolean; filterPendingTxsUtxos: boolean; + filterRunesUtxos: boolean; } type filterUtxoFunctionType = (utxos: UtxoResponseItem[]) => UtxoResponseItem[]; @@ -49,11 +64,14 @@ export function useNativeSegwitUtxosByAddress({ address, filterInscriptionUtxos, filterPendingTxsUtxos, + filterRunesUtxos, }: UseFilterUtxosByAddressArgs) { - const filterOutInscriptions = useFilterInscriptionsByAddress(address); - const filterOutPendingTxsUtxos = useFilterPendingUtxosByAddress(address); + const { filterOutInscriptions, isInitialLoadingInscriptions } = + useFilterInscriptionsByAddress(address); + const { filterOutPendingTxsUtxos, isInitialLoading } = useFilterPendingUtxosByAddress(address); + const { filterOutRunesUtxos, isInitialLoadingRunesData } = useFilterRuneUtxosByAddress(address); - return useGetUtxosByAddressQuery(address, { + const utxosQuery = useGetUtxosByAddressQuery(address, { select(utxos) { const filters = []; if (filterPendingTxsUtxos) { @@ -64,6 +82,10 @@ export function useNativeSegwitUtxosByAddress({ filters.push(filterOutInscriptions); } + if (filterRunesUtxos) { + filters.push(filterOutRunesUtxos); + } + return filters.reduce( (filteredUtxos: UtxoResponseItem[], filterFunc: filterUtxoFunctionType) => filterFunc(filteredUtxos), @@ -71,33 +93,67 @@ export function useNativeSegwitUtxosByAddress({ ); }, }); + + return { + ...utxosQuery, + isInitialLoading: + utxosQuery.isInitialLoading || + isInitialLoading || + isInitialLoadingInscriptions || + isInitialLoadingRunesData, + }; } function useFilterInscriptionsByAddress(address: string) { const { data: inscriptionsList, hasNextPage: hasMoreInscriptionsToLoad, - isLoading: isLoadingInscriptions, + isInitialLoading: isInitialLoadingInscriptions, } = useInscriptionsByAddressQuery(address); - return useCallback( + const filterOutInscriptions = useCallback( (utxos: UtxoResponseItem[]) => { - // While infinite query checks if has more data to load, or Stamps - // are loading, assume nothing is spendable - if (hasMoreInscriptionsToLoad || isLoadingInscriptions) return []; - const inscriptions = inscriptionsList?.pages.flatMap(page => page.results) ?? []; return filterUtxosWithInscriptions(inscriptions, utxos); }, - [hasMoreInscriptionsToLoad, inscriptionsList?.pages, isLoadingInscriptions] + [inscriptionsList?.pages] ); + + return { + filterOutInscriptions, + isInitialLoadingInscriptions: hasMoreInscriptionsToLoad || isInitialLoadingInscriptions, + }; +} + +function useFilterRuneUtxosByAddress(address: string) { + // TO-DO what if data is undefined? + const { data = [], isInitialLoading } = useRunesOutputsByAddress(address); + const runesEnabled = useRunesEnabled(); + + const filterOutRunesUtxos = useCallback( + (utxos: UtxoResponseItem[]) => { + // If Runes are not enabled, return all utxos + if (!runesEnabled) { + return utxos; + } + + return filterUtxosWithRunes(data, utxos); + }, + [data, runesEnabled] + ); + + return { + filterOutRunesUtxos, + isInitialLoadingRunesData: isInitialLoading, + }; } function useFilterPendingUtxosByAddress(address: string) { - const { data: pendingInputs = [] } = useBitcoinPendingTransactionsInputs(address); + const { data: pendingInputs = [], isInitialLoading } = + useBitcoinPendingTransactionsInputs(address); - return useCallback( + const filterOutPendingTxsUtxos = useCallback( (utxos: UtxoResponseItem[]) => { return utxos.filter( utxo => @@ -108,4 +164,9 @@ function useFilterPendingUtxosByAddress(address: string) { }, [address, pendingInputs] ); + + return { + filterOutPendingTxsUtxos, + isInitialLoading, + }; } diff --git a/packages/query/src/bitcoin/address/utxos-by-address.query.ts b/packages/query/src/bitcoin/address/utxos-by-address.query.ts index d202ff4ad..d78336f19 100644 --- a/packages/query/src/bitcoin/address/utxos-by-address.query.ts +++ b/packages/query/src/bitcoin/address/utxos-by-address.query.ts @@ -22,16 +22,17 @@ export function useGetUtxosByAddressQuery ) { const client = useBitcoinClient(); + return useQuery({ enabled: !!address, queryKey: ['btc-utxos-by-address', address], - queryFn: () => client.addressApi.getUtxosByAddress(address), + queryFn: async ({ signal }) => client.addressApi.getUtxosByAddress(address, signal), ...queryOptions, ...options, }); } -const stopSearchAfterNumberAddressesWithoutUtxos = 20; +const stopSearchAfterNumberAddressesWithoutUtxos = 5; /** * Returns all utxos for the user's current taproot account. The search for diff --git a/packages/query/src/bitcoin/balance/btc-balance.hooks.ts b/packages/query/src/bitcoin/balance/btc-balance.hooks.ts index a118985ea..d9dab84c2 100644 --- a/packages/query/src/bitcoin/balance/btc-balance.hooks.ts +++ b/packages/query/src/bitcoin/balance/btc-balance.hooks.ts @@ -4,16 +4,32 @@ import { createMoney, isUndefined, sumNumbers } from '@leather-wallet/utils'; import BigNumber from 'bignumber.js'; import { useNativeSegwitUtxosByAddress } from '../address/utxos-by-address.hooks'; +import { useRunesEnabled } from '../runes/runes.hooks'; export function useGetBitcoinBalanceByAddress(address: string) { - const { data: utxos } = useNativeSegwitUtxosByAddress({ + const runesEnabled = useRunesEnabled(); + + const { + data: utxos, + isInitialLoading, + isLoading, + isFetching, + } = useNativeSegwitUtxosByAddress({ address, filterInscriptionUtxos: true, filterPendingTxsUtxos: true, + filterRunesUtxos: runesEnabled, }); - return useMemo(() => { + const balance = useMemo(() => { if (isUndefined(utxos)) return createMoney(new BigNumber(0), 'BTC'); return createMoney(sumNumbers(utxos.map(utxo => utxo.value)), 'BTC'); }, [utxos]); + + return { + balance, + isInitialLoading, + isLoading, + isFetching, + }; } diff --git a/packages/query/src/bitcoin/balance/btc-native-segwit-balance.hooks.ts b/packages/query/src/bitcoin/balance/btc-native-segwit-balance.hooks.ts index 9dbd48320..1ca38badc 100644 --- a/packages/query/src/bitcoin/balance/btc-native-segwit-balance.hooks.ts +++ b/packages/query/src/bitcoin/balance/btc-native-segwit-balance.hooks.ts @@ -5,6 +5,18 @@ import { useGetBitcoinBalanceByAddress } from './btc-balance.hooks'; // Balance is derived from a single query in address reuse mode export function useNativeSegwitBalance(address: string) { - const balance = useGetBitcoinBalanceByAddress(address); - return useMemo(() => createBitcoinCryptoCurrencyAssetTypeWrapper(balance), [balance]); + const { balance, isInitialLoading, isLoading, isFetching } = + useGetBitcoinBalanceByAddress(address); + + const wrappedBalance = useMemo( + () => createBitcoinCryptoCurrencyAssetTypeWrapper(balance), + [balance] + ); + + return { + btcBalance: wrappedBalance, + isInitialLoading, + isLoading, + isFetching, + }; } diff --git a/packages/query/src/bitcoin/balance/btc-taproot-balance.hooks.ts b/packages/query/src/bitcoin/balance/btc-taproot-balance.hooks.ts index 609b6030c..0b35cc448 100644 --- a/packages/query/src/bitcoin/balance/btc-taproot-balance.hooks.ts +++ b/packages/query/src/bitcoin/balance/btc-taproot-balance.hooks.ts @@ -18,11 +18,14 @@ export function useCurrentTaprootAccountUninscribedUtxos({ nativeSegwitAddress: string; }) { const { data: utxos = [] } = useTaprootAccountUtxosQuery({ - taprootKeychain, + taprootKeychain: taprootKeychain, currentAccountIndex, }); - const query = useGetInscriptionsInfiniteQuery({ taprootKeychain, nativeSegwitAddress }); + const query = useGetInscriptionsInfiniteQuery({ + taprootKeychain, + nativeSegwitAddress, + }); return useMemo(() => { const inscriptions = query.data?.pages?.flatMap(page => page.inscriptions) ?? []; diff --git a/packages/query/src/bitcoin/bitcoin-client.ts b/packages/query/src/bitcoin/bitcoin-client.ts index 96fd78a22..0d662a942 100644 --- a/packages/query/src/bitcoin/bitcoin-client.ts +++ b/packages/query/src/bitcoin/bitcoin-client.ts @@ -1,9 +1,14 @@ -import { HIRO_API_BASE_URL_MAINNET } from '@leather-wallet/constants'; +import { + BESTINSLOT_API_BASE_URL_MAINNET, + BESTINSLOT_API_BASE_URL_TESTNET, + type BitcoinNetworkModes, +} from '@leather-wallet/constants'; +import type { BitcoinTx, MarketData, Money } from '@leather-wallet/models'; import axios from 'axios'; -import type { Paginated } from '../../types/api-types'; import { UtxoResponseItem } from '../../types/utxo'; import { useLeatherNetwork } from '../leather-query-provider'; +import { getBlockstreamRatelimiter } from './blockstream-rate-limiter'; interface BestinslotInscription { inscription_name: string | null; @@ -26,30 +31,27 @@ interface BestinslotInscription { byte_size: number; } -export interface BestinslotInscriptionByIdResponse { +export interface BestinSlotInscriptionByIdResponse { data: BestinslotInscription; block_height: number; } -export interface BestinslotInscriptionsByTxIdResponse { +export interface BestinSlotInscriptionsByTxIdResponse { data: { inscription_id: string }[]; blockHeight: number; } -interface Brc20TokenResponse { +/* BRC-20 */ +export interface Brc20Balance { ticker: string; overall_balance: string; available_balance: string; transferrable_balance: string; image_url: string | null; + min_listed_unit_price: number | null; } -export interface Brc20Token extends Brc20TokenResponse { - decimals: number; - holderAddress: string; -} - -interface Brc20TokenTicker { +export interface Brc20TickerInfo { id: string; number: number; block_height: number; @@ -64,94 +66,233 @@ interface Brc20TokenTicker { tx_count: number; } -interface Brc20TickerResponse { - data: Brc20TokenTicker; +interface Brc20TickerInfoResponse { + block_height: number; + data: Brc20TickerInfo; +} + +interface Brc20WalletBalancesResponse { + block_height: number; + data: Brc20Balance[]; +} + +export interface Brc20Token { + balance: Money | null; + holderAddress: string; + marketData: MarketData | null; + tokenData: Brc20Balance & Brc20TickerInfo; +} + +/* RUNES */ +export interface RuneBalance { + pkscript: string; + rune_id: string; + rune_name: string; + spaced_rune_name: string; + total_balance: string; + wallet_addr: string; +} + +interface RunesWalletBalancesResponse { block_height: number; + data: RuneBalance[]; } -interface BestinslotBrc20AddressBalanceResponse { +export interface RuneTickerInfo { + rune_id: string; + rune_number: string; + rune_name: string; + spaced_rune_name: string; + symbol: string; + decimals: number; + per_mint_amount: string; + mint_cnt: string; + mint_cnt_limit: string; + premined_supply: string; + total_minted_supply: string; + burned_supply: string; + circulating_supply: string; + mint_progress: number; + mint_start_block: number | null; + mint_end_block: number | null; + genesis_block: number; + deploy_ts: string; + deploy_txid: string; + auto_upgrade: boolean; + holder_count: number; + event_count: number; + mintable: boolean; +} +interface RunesTickerInfoResponse { block_height: number; - data: Brc20TokenResponse[]; + data: RuneTickerInfo; +} + +export interface RuneToken { + balance: Money; + tokenData: RuneBalance & RuneTickerInfo; } -function BestinslotApi() { - const url = 'https://api.bestinslot.xyz/v3'; +export interface RunesOutputsByAddress { + pkscript: string; + wallet_addr: string; + output: string; + rune_ids: string[]; + balances: number[]; + rune_names: string[]; + spaced_rune_names: string[]; +} + +interface RunesOutputsByAddressArgs { + address: string; + network?: BitcoinNetworkModes; + sortBy?: 'output'; + order?: 'asc' | 'desc'; + offset?: number; + count?: number; +} + +interface RunesOutputsByAddressResponse { + block_height: number; + data: RunesOutputsByAddress[]; +} + +function BestinSlotApi() { + const url = BESTINSLOT_API_BASE_URL_MAINNET; + const testnetUrl = BESTINSLOT_API_BASE_URL_TESTNET; + const defaultOptions = { headers: { 'x-api-key': `${process.env.BESTINSLOT_API_KEY}`, }, }; - return { - async getInscriptionsByTransactionId(id: string) { - const resp = await axios.get( - `${url}/inscription/in_transaction?tx_id=${id}`, - { - ...defaultOptions, - } - ); - return resp.data; - }, + async function getInscriptionsByTransactionId(id: string) { + const resp = await axios.get( + `${url}/inscription/in_transaction?tx_id=${id}`, + { + ...defaultOptions, + } + ); - async getInscriptionById(id: string) { - const resp = await axios.get( - `${url}/inscription/single_info_id?inscription_id=${id}`, - { - ...defaultOptions, - } - ); - return resp.data; - }, + return resp.data; + } - async getBrc20Balance(address: string) { - const resp = await axios.get( - `${url}/brc20/wallet_balances?address=${address}`, - { - ...defaultOptions, - } - ); - return resp.data; - }, + async function getInscriptionById(id: string) { + const resp = await axios.get( + `${url}/inscription/single_info_id?inscription_id=${id}`, + { + ...defaultOptions, + } + ); + return resp.data; + } - async getBrc20TickerData(ticker: string) { - const resp = await axios.get( - `${url}/brc20/ticker_info?ticker=${ticker}`, - { - ...defaultOptions, - } - ); - return resp.data; - }, - }; -} + /* BRC-20 */ + async function getBrc20Balances(address: string) { + const resp = await axios.get( + `${url}/brc20/wallet_balances?address=${address}`, + { + ...defaultOptions, + } + ); + return resp.data; + } -function HiroApi() { - const url = HIRO_API_BASE_URL_MAINNET; - return { - async getBrc20Balance(address: string) { - const resp = await axios.get>( - `${url}/ordinals/v1/brc-20/balances/${address}` - ); - return resp.data; - }, + async function getBrc20TickerInfo(ticker: string) { + const resp = await axios.get( + `${url}/brc20/ticker_info?ticker=${ticker}`, + { + ...defaultOptions, + } + ); + return resp.data; + } - async getBrc20TickerData(ticker: string) { - const resp = await axios.get>( - `${url}/ordinals/v1/brc-20/tokens?ticker=${ticker}` - ); - return resp.data; - }, + /* RUNES */ + async function getRunesWalletBalances(address: string, network: BitcoinNetworkModes) { + const baseUrl = network === 'mainnet' ? url : testnetUrl; + const resp = await axios.get( + `${baseUrl}/runes/wallet_balances?address=${address}`, + { ...defaultOptions } + ); + return resp.data.data; + } + + async function getRunesTickerInfo(runeName: string, network: BitcoinNetworkModes) { + const baseUrl = network === 'mainnet' ? url : testnetUrl; + const resp = await axios.get( + `${baseUrl}/runes/ticker_info?rune_name=${runeName}`, + { ...defaultOptions } + ); + return resp.data.data; + } + + async function getRunesBatchOutputsInfo(outputs: string[], network: BitcoinNetworkModes) { + const baseUrl = network === 'mainnet' ? url : testnetUrl; + + const resp = await axios.post( + `${baseUrl}/runes/batch_output_info`, + { queries: outputs }, + { ...defaultOptions } + ); + return resp.data.data; + } + + /** + * @see https://docs.bestinslot.xyz/reference/api-reference/ordinals-and-brc-20-and-runes-and-bitmap-v3-api-mainnet+testnet/runes#runes-wallet-valid-outputs + */ + async function getRunesOutputsByAddress({ + address, + network = 'mainnet', + sortBy = 'output', + order = 'asc', + offset = 0, + count = 100, + }: RunesOutputsByAddressArgs) { + const baseUrl = network === 'mainnet' ? url : testnetUrl; + const queryParams = new URLSearchParams({ + address, + sort_by: sortBy, + order, + offset: offset.toString(), + count: count.toString(), + }); + + const resp = await axios.get( + `${baseUrl}/runes/wallet_valid_outputs?${queryParams}`, + { ...defaultOptions } + ); + return resp.data.data; + } + + return { + getInscriptionsByTransactionId, + getInscriptionById, + getBrc20Balances, + getBrc20TickerInfo, + getRunesWalletBalances, + getRunesTickerInfo, + getRunesBatchOutputsInfo, + getRunesOutputsByAddress, }; } function AddressApi(basePath: string) { + const rateLimiter = getBlockstreamRatelimiter(basePath); return { - async getTransactionsByAddress(address: string) { - const resp = await axios.get(`${basePath}/address/${address}/txs`); + async getTransactionsByAddress(address: string, signal?: AbortSignal) { + const resp = await rateLimiter.add( + () => axios.get(`${basePath}/address/${address}/txs`), + { signal, throwOnTimeout: true } + ); return resp.data; }, - async getUtxosByAddress(address: string): Promise { - const resp = await axios.get(`${basePath}/address/${address}/utxo`); + async getUtxosByAddress(address: string, signal?: AbortSignal): Promise { + const resp = await rateLimiter.add( + () => axios.get(`${basePath}/address/${address}/utxo`), + { signal, priority: 1, throwOnTimeout: true } + ); return resp.data.sort((a, b) => a.vout - b.vout); }, }; @@ -249,17 +390,15 @@ export interface BitcoinClient { addressApi: ReturnType; feeEstimatesApi: ReturnType; transactionsApi: ReturnType; - BestinslotApi: ReturnType; - HiroApi: ReturnType; + BestinSlotApi: ReturnType; } -function bitcoinClient(basePath: string): BitcoinClient { +export function bitcoinClient(basePath: string): BitcoinClient { return { addressApi: AddressApi(basePath), feeEstimatesApi: FeeEstimatesApi(), transactionsApi: TransactionsApi(basePath), - BestinslotApi: BestinslotApi(), - HiroApi: HiroApi(), + BestinSlotApi: BestinSlotApi(), }; } diff --git a/packages/query/src/bitcoin/blockstream-rate-limiter.ts b/packages/query/src/bitcoin/blockstream-rate-limiter.ts new file mode 100644 index 000000000..35add7a7b --- /dev/null +++ b/packages/query/src/bitcoin/blockstream-rate-limiter.ts @@ -0,0 +1,17 @@ +import { BITCOIN_API_BASE_URL_TESTNET } from '@leather-wallet/constants'; +import PQueue from 'p-queue'; + +const blockstreamMainnetApiLimiter = new PQueue({ + interval: 5000, + intervalCap: 30, +}); + +const blockstreamTestnetApiLimiter = new PQueue({ + interval: 5000, + intervalCap: 30, +}); + +export function getBlockstreamRatelimiter(url: string): PQueue { + if (url.includes(BITCOIN_API_BASE_URL_TESTNET)) return blockstreamTestnetApiLimiter; + return blockstreamMainnetApiLimiter; +} diff --git a/packages/query/src/bitcoin/fees/fee-estimates.hooks.ts b/packages/query/src/bitcoin/fees/fee-estimates.hooks.ts index 98f7317cd..51fe49494 100644 --- a/packages/query/src/bitcoin/fees/fee-estimates.hooks.ts +++ b/packages/query/src/bitcoin/fees/fee-estimates.hooks.ts @@ -8,12 +8,9 @@ import { import { useGetAllBitcoinFeeEstimatesQuery } from './fee-estimates.query'; export function useAverageBitcoinFeeRates() { - // const analytics = useAnalytics(); return useGetAllBitcoinFeeEstimatesQuery({ - // onError: err => logger.error('Error getting all apis bitcoin fee estimates', { err }), select(feeEstimates) { if (feeEstimates.every(isRejected)) { - // void analytics.track('error_using_fallback_bitcoin_fees'); return { fastestFee: initBigNumber(15), halfHourFee: initBigNumber(10), diff --git a/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.hooks.ts b/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.hooks.ts new file mode 100644 index 000000000..8f5e1e1da --- /dev/null +++ b/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.hooks.ts @@ -0,0 +1,82 @@ +import type { Signer } from '@leather-wallet/bitcoin'; +import { type Money, createMarketData, createMarketPair } from '@leather-wallet/models'; +import { + useCalculateBitcoinFiatValue, + useConfigOrdinalsbot, + useGetBrc20TokensQuery, + useLeatherNetwork, +} from '@leather-wallet/query'; +import { createMoney, unitToFractionalUnit } from '@leather-wallet/utils'; +import { P2TROut } from '@scure/btc-signer'; +import BigNumber from 'bignumber.js'; + +import type { Brc20Token } from '../../bitcoin-client'; + +// ts-unused-exports:disable-next-line +export function useBrc20FeatureFlag() { + const currentNetwork = useLeatherNetwork(); + + const ordinalsbotConfig = useConfigOrdinalsbot(); + + if (!ordinalsbotConfig.integrationEnabled) { + return { enabled: false, reason: 'BRC-20 transfers are temporarily disabled' } as const; + } + + const supportedNetwork = + currentNetwork.chain.bitcoin.bitcoinNetwork === 'mainnet' || + currentNetwork.chain.bitcoin.bitcoinNetwork === 'signet'; + + if (!supportedNetwork) return { enabled: false, reason: 'Unsupported network' } as const; + + // TODO: Add api availability check + + return { enabled: true } as const; +} + +function makeBrc20Token(priceAsFiat: Money, token: Brc20Token) { + return { + ...token, + balance: createMoney( + unitToFractionalUnit(token.tokenData.decimals)( + new BigNumber(token.tokenData.overall_balance) + ), + token.tokenData.ticker, + token.tokenData.decimals + ), + marketData: token.tokenData.min_listed_unit_price + ? createMarketData( + // TODO: unsafe `as` + createMarketPair(token.tokenData.ticker as 'BTC' | 'STX', 'USD'), + createMoney(priceAsFiat.amount, 'USD') + ) + : null, + }; +} + +export function useBrc20Tokens({ + nativeSegwitAddress, + createTaprootSigner, +}: { + nativeSegwitAddress: string; + createTaprootSigner: ((addressIndex: number) => Signer) | undefined; +}) { + const calculateBitcoinFiatValue = useCalculateBitcoinFiatValue(); + const { data: allBrc20TokensResponse } = useGetBrc20TokensQuery({ + nativeSegwitAddress, + createTaprootSigner, + }); + + const tokens = allBrc20TokensResponse?.pages + .flatMap(page => page.brc20Tokens) + .filter(token => token.length > 0) + .flatMap(token => token); + + return ( + tokens?.map(token => { + const priceAsFiat = calculateBitcoinFiatValue( + createMoney(new BigNumber(token.tokenData.min_listed_unit_price ?? 0), 'BTC') + ); + return makeBrc20Token(priceAsFiat, token); + }) ?? [] + ); +} diff --git a/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.query.ts b/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.query.ts index 0b1474c1c..8d15a1e13 100644 --- a/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.query.ts +++ b/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.query.ts @@ -5,7 +5,6 @@ import { createNumArrayOfRange } from '@leather-wallet/utils'; import { P2TROut } from '@scure/btc-signer'; import { useInfiniteQuery } from '@tanstack/react-query'; -// import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; import { useLeatherNetwork } from '../../../leather-query-provider'; import { QueryPrefixes } from '../../../query-prefixes'; import { Brc20Token, useBitcoinClient } from '../../bitcoin-client'; @@ -22,7 +21,6 @@ export function useGetBrc20TokensQuery({ }) { const network = useLeatherNetwork(); const currentNsBitcoinAddress = nativeSegwitAddress; - // const analytics = useAnalytics(); const client = useBitcoinClient(); if (!createTaprootSigner) throw new Error('No signer'); @@ -53,19 +51,21 @@ export function useGetBrc20TokensQuery({ } const brc20TokensPromises = addressesData.map(async address => { - const brc20Tokens = await client.HiroApi.getBrc20Balance(address); + const brc20Tokens = await client.BestinSlotApi.getBrc20Balances(address); const tickerPromises = await Promise.all( - brc20Tokens.results.map(token => { - return client.HiroApi.getBrc20TickerData(token.ticker); + brc20Tokens.data.map(token => { + return client.BestinSlotApi.getBrc20TickerInfo(token.ticker); }) ); - return brc20Tokens.results.map((token, index) => { + // Initialize token with token data + return brc20Tokens.data.map((token, index) => { return { - ...token, - decimals: tickerPromises[index].results[0].decimals, + balance: null, holderAddress: address, + marketData: null, + tokenData: { ...token, ...tickerPromises[index].data }, }; }); }); @@ -94,7 +94,7 @@ export function useGetBrc20TokensQuery({ }, refetchOnMount: false, refetchOnReconnect: false, - refetchOnWindowFocus: false, + refetchOnWindowFocus: true, staleTime: 5 * 60 * 1000, }); @@ -105,17 +105,5 @@ export function useGetBrc20TokensQuery({ } }, [query, query.data]); - // useEffect(() => { - // const brc20AcrossAddressesCount = query.data?.pages.reduce((acc, page) => { - // return acc + page.brc20Tokens.flatMap(item => item).length; - // }, 0); - - // // if (!query.hasNextPage && brc20AcrossAddressesCount && brc20AcrossAddressesCount > 0) { - // // void analytics.identify({ - // // brc20_across_addresses_count: brc20AcrossAddressesCount, - // // }); - // // } - // // eslint-disable-next-line react-hooks/exhaustive-deps - // }, [query.hasNextPage]); return query; } diff --git a/packages/query/src/bitcoin/ordinals/brc20/brc-20.utils.spec.ts b/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.utils.spec.ts similarity index 86% rename from packages/query/src/bitcoin/ordinals/brc20/brc-20.utils.spec.ts rename to packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.utils.spec.ts index d9f2b8f4e..d916ffc4a 100644 --- a/packages/query/src/bitcoin/ordinals/brc20/brc-20.utils.spec.ts +++ b/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.utils.spec.ts @@ -1,4 +1,4 @@ -import { encodeBrc20TransferInscription } from './brc-20.utils'; +import { encodeBrc20TransferInscription } from './brc20-tokens.utils'; describe(encodeBrc20TransferInscription.name, () => { test('that it encodes the BRC-20 transfer correctly', () => { diff --git a/packages/query/src/bitcoin/ordinals/brc20/brc-20.utils.ts b/packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.utils.ts similarity index 100% rename from packages/query/src/bitcoin/ordinals/brc20/brc-20.utils.ts rename to packages/query/src/bitcoin/ordinals/brc20/brc20-tokens.utils.ts diff --git a/packages/query/src/bitcoin/ordinals/inscription-by-id.query.ts b/packages/query/src/bitcoin/ordinals/inscription-by-id.query.ts index f82557394..d064891a0 100644 --- a/packages/query/src/bitcoin/ordinals/inscription-by-id.query.ts +++ b/packages/query/src/bitcoin/ordinals/inscription-by-id.query.ts @@ -4,5 +4,5 @@ import axios from 'axios'; import { InscriptionResponseItem } from '../../../types/inscription'; export async function fetchInscripionById(id: string) { - return axios.get(`${HIRO_INSCRIPTIONS_API_URL}}/${id}`); + return axios.get(`${HIRO_INSCRIPTIONS_API_URL}/${id}`); } diff --git a/packages/query/src/bitcoin/ordinalsbot-client.ts b/packages/query/src/bitcoin/ordinalsbot-client.ts new file mode 100644 index 000000000..05b7feaee --- /dev/null +++ b/packages/query/src/bitcoin/ordinalsbot-client.ts @@ -0,0 +1,163 @@ +import { useMemo } from 'react'; + +import axios from 'axios'; +import urlJoin from 'url-join'; + +import { useConfigOrdinalsbot } from '../common/remote-config/remote-config.query'; +import { useLeatherNetwork } from '../leather-query-provider'; + +interface InscriptionOrderSuccessResponse { + status: 'ok'; + receiveAddress: string; + texts: string[]; + lowPostage: boolean; + fee: number; + files: { + dataURL: string; + name: string; + size: number; + }[]; + charge: { + id: string; + description: string; + desc_hash: boolean; + created_at: number; + status: string; + amount: number; + callback_url: string; + success_url: any; + hosted_checkout_url: string; + order_id: any; + currency: string; + source_fiat_value: number; + fiat_value: number; + auto_settle: boolean; + notif_email: any; + address: string; + chain_invoice: { + address: string; + }; + uri: string; + ttl: number; + lightning_invoice: { + expires_at: number; + payreq: string; + }; + }; + id: string; + chainFee: number; + serviceFee: number; + baseFee: number; + orderType: string; + createdAt: { + '.sv': string; + }; +} + +interface InscriptionOrderArgs { + file: string; + receiveAddress: string; + // in vbytes + fee: number; + // in bytes + size: number; + name: string; +} + +interface OrderStatusSuccessResponse { + status: 'success'; + paid: boolean; + underpaid: boolean; + expired: boolean; + id: string; + state: string; + charge: { + id: string; + description: string; + desc_hash: boolean; + created_at: number; + status: string; + amount: number; + callback_url: string; + success_url: any; + hosted_checkout_url: string; + order_id: any; + currency: string; + source_fiat_value: number; + fiat_value: number; + auto_settle: boolean; + notif_email: any; + address: string; + chain_invoice: { + address: string; + }; + uri: string; + ttl: number; + lightning_invoice: { + expires_at: number; + payreq: string; + }; + }; + files: { + dataURL: string; + name: string; + size: number; + type: string; + url: string; + processing: boolean; + + tx?: { + commit: string; + fees: number; + inscription: string; + reveal: string; + }; + }[]; + + sent: string; +} + +interface OrderStatusErrorResponse { + status: 'error'; + error: string; +} + +function OrdinalsbotClient(basePath: string) { + return { + async isAvailable() { + return axios.get<{ status: string }>(urlJoin(basePath, 'status')); + }, + + async order({ receiveAddress, file, fee, size, name }: InscriptionOrderArgs) { + return axios.post(urlJoin(basePath, 'order'), { + receiveAddress, + files: [{ dataURL: file, size, name, type: 'plain/text' }], + fee, + lowPostage: true, + }); + }, + + async orderStatus(id: string) { + return axios.get( + urlJoin(basePath, 'order'), + { + params: { id }, + } + ); + }, + }; +} + +function useOrdinalsbotApiUrl() { + const currentNetwork = useLeatherNetwork(); + const ordinalsbotConfig = useConfigOrdinalsbot(); + + if (currentNetwork.chain.bitcoin.bitcoinNetwork === 'mainnet') + return ordinalsbotConfig.mainnetApiUrl; + return ordinalsbotConfig.signetApiUrl; +} + +export function useOrdinalsbotClient() { + const apiUrl = useOrdinalsbotApiUrl(); + return useMemo(() => OrdinalsbotClient(apiUrl), [apiUrl]); +} diff --git a/packages/query/src/bitcoin/runes/runes-outputs-by-address.query.ts b/packages/query/src/bitcoin/runes/runes-outputs-by-address.query.ts new file mode 100644 index 000000000..6352570f3 --- /dev/null +++ b/packages/query/src/bitcoin/runes/runes-outputs-by-address.query.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import { useLeatherNetwork } from '../../leather-query-provider'; +import { AppUseQueryConfig } from '../../query-config'; +import { type RunesOutputsByAddress, useBitcoinClient } from '../bitcoin-client'; +import { useRunesEnabled } from './runes.hooks'; + +const queryOptions = { staleTime: 5 * 60 * 1000 }; + +export function useGetRunesOutputsByAddressQuery( + address: string, + options?: AppUseQueryConfig +) { + const client = useBitcoinClient(); + const runesEnabled = useRunesEnabled(); + const network = useLeatherNetwork(); + + return useQuery({ + enabled: !!address && runesEnabled, + queryKey: ['runes-outputs-by-address', address], + queryFn: () => + client.BestinSlotApi.getRunesOutputsByAddress({ + address, + network: network.chain.bitcoin.bitcoinNetwork, + }), + ...queryOptions, + ...options, + }); +} diff --git a/packages/query/src/bitcoin/runes/runes-ticker-info.query.ts b/packages/query/src/bitcoin/runes/runes-ticker-info.query.ts new file mode 100644 index 000000000..35d82cb8b --- /dev/null +++ b/packages/query/src/bitcoin/runes/runes-ticker-info.query.ts @@ -0,0 +1,25 @@ +import { type UseQueryResult, useQueries } from '@tanstack/react-query'; + +import { useConfigRunesEnabled } from '../../common/remote-config/remote-config.query'; +import { useLeatherNetwork } from '../../leather-query-provider'; +import { type RuneTickerInfo, useBitcoinClient } from '../bitcoin-client'; + +const queryOptions = { staleTime: 5 * 60 * 1000 }; + +export function useGetRunesTickerInfoQuery(runeNames: string[]): UseQueryResult[] { + const client = useBitcoinClient(); + const network = useLeatherNetwork(); + const runesEnabled = useConfigRunesEnabled(); + + return useQueries({ + queries: runeNames.map(runeName => { + return { + enabled: !!runeName && (network.chain.bitcoin.bitcoinNetwork === 'testnet' || runesEnabled), + queryKey: ['runes-ticker-info', runeName], + queryFn: () => + client.BestinSlotApi.getRunesTickerInfo(runeName, network.chain.bitcoin.bitcoinNetwork), + ...queryOptions, + }; + }), + }); +} diff --git a/packages/query/src/bitcoin/runes/runes-wallet-balances.query.ts b/packages/query/src/bitcoin/runes/runes-wallet-balances.query.ts new file mode 100644 index 000000000..ac2d48909 --- /dev/null +++ b/packages/query/src/bitcoin/runes/runes-wallet-balances.query.ts @@ -0,0 +1,33 @@ +import { useQueries } from '@tanstack/react-query'; + +import { useConfigRunesEnabled } from '../../common/remote-config/remote-config.query'; +import { useLeatherNetwork } from '../../leather-query-provider'; +import { AppUseQueryConfig } from '../../query-config'; +import { type RuneBalance, useBitcoinClient } from '../bitcoin-client'; + +const queryOptions = { staleTime: 5 * 60 * 1000 }; + +export function useGetRunesWalletBalancesByAddressesQuery( + addresses: string[], + options?: AppUseQueryConfig +) { + const client = useBitcoinClient(); + const network = useLeatherNetwork(); + const runesEnabled = useConfigRunesEnabled(); + + return useQueries({ + queries: addresses.map(address => { + return { + enabled: !!address && (network.chain.bitcoin.bitcoinNetwork === 'testnet' || runesEnabled), + queryKey: ['runes-wallet-balances', address], + queryFn: () => + client.BestinSlotApi.getRunesWalletBalances( + address, + network.chain.bitcoin.bitcoinNetwork + ), + ...queryOptions, + ...options, + }; + }), + }); +} diff --git a/packages/query/src/bitcoin/runes/runes.hooks.ts b/packages/query/src/bitcoin/runes/runes.hooks.ts new file mode 100644 index 000000000..33a9d27b0 --- /dev/null +++ b/packages/query/src/bitcoin/runes/runes.hooks.ts @@ -0,0 +1,54 @@ +import { createMoney, isDefined } from '@leather-wallet/utils'; + +import { useConfigRunesEnabled } from '../../common/remote-config/remote-config.query'; +import { useLeatherNetwork } from '../../leather-query-provider'; +import type { RuneBalance, RuneTickerInfo, RuneToken } from '../bitcoin-client'; +import { useGetRunesOutputsByAddressQuery } from './runes-outputs-by-address.query'; +import { useGetRunesTickerInfoQuery } from './runes-ticker-info.query'; +import { useGetRunesWalletBalancesByAddressesQuery } from './runes-wallet-balances.query'; + +const defaultRunesSymbol = 'ยค'; + +function makeRuneToken(runeBalance: RuneBalance, tickerInfo: RuneTickerInfo): RuneToken { + return { + balance: createMoney( + Number(runeBalance.total_balance), + tickerInfo.rune_name, + tickerInfo.decimals + ), + tokenData: { + ...runeBalance, + ...tickerInfo, + symbol: tickerInfo.symbol ?? defaultRunesSymbol, + }, + }; +} + +export function useRunesEnabled() { + const runesEnabled = useConfigRunesEnabled(); + const network = useLeatherNetwork(); + + return runesEnabled || network.chain.bitcoin.bitcoinNetwork === 'testnet'; +} + +export function useRuneTokens(addresses: string[]) { + const runesBalances = useGetRunesWalletBalancesByAddressesQuery(addresses) + .flatMap(query => query.data) + .filter(isDefined); + + const runesTickerInfo = useGetRunesTickerInfoQuery(runesBalances.map(r => r.rune_name)) + .flatMap(query => query.data) + .filter(isDefined); + + return runesBalances + .map(r => { + const tickerInfo = runesTickerInfo.find(t => t.rune_name === r.rune_name); + if (!tickerInfo) return; + return makeRuneToken(r, tickerInfo); + }) + .filter(isDefined); +} + +export function useRunesOutputsByAddress(address: string) { + return useGetRunesOutputsByAddressQuery(address); +} diff --git a/packages/query/src/bitcoin/stamps/stamps-by-address.hooks.ts b/packages/query/src/bitcoin/stamps/stamps-by-address.hooks.ts new file mode 100644 index 000000000..1eeb7efca --- /dev/null +++ b/packages/query/src/bitcoin/stamps/stamps-by-address.hooks.ts @@ -0,0 +1,17 @@ +import { useStampsByAddressQuery } from './stamps-by-address.query'; + +export function useStampsByAddress(address: string) { + return useStampsByAddressQuery(address, { + select(data) { + return data.data?.stamps; + }, + }); +} + +export function useSrc20TokensByAddress(address: string) { + return useStampsByAddressQuery(address, { + select(data) { + return data.data?.src20; + }, + }); +} diff --git a/packages/query/src/bitcoin/stamps/stamps-by-address.query.ts b/packages/query/src/bitcoin/stamps/stamps-by-address.query.ts index af48751d4..093a07611 100644 --- a/packages/query/src/bitcoin/stamps/stamps-by-address.query.ts +++ b/packages/query/src/bitcoin/stamps/stamps-by-address.query.ts @@ -1,74 +1,81 @@ import { useQuery } from '@tanstack/react-query'; import axios from 'axios'; +import { z } from 'zod'; import { AppUseQueryConfig } from '../../query-config'; import { QueryPrefixes } from '../../query-prefixes'; -export interface Stamp { - stamp: number; - block_index: number; - cpid: string; - asset_longname: string; - creator: string; - divisible: number; - keyburn: number; - locked: number; - message_index: number; - stamp_base64: string; - stamp_mimetype: string; - stamp_url: string; - supply: number; - timestamp: string; - tx_hash: string; - tx_index: number; - src_data: string; - ident: string; - creator_name: string; - stamp_gen: string; - stamp_hash: string; - is_btc_stamp: number; - is_reissue: number; - file_hash: string; -} +const stampSchema = z.object({ + stamp: z.number(), + block_index: z.number(), + cpid: z.string(), + asset_longname: z.string(), + creator: z.string(), + divisible: z.number(), + keyburn: z.number(), + locked: z.number(), + message_index: z.number(), + stamp_base64: z.string(), + stamp_mimetype: z.string(), + stamp_url: z.string(), + supply: z.number(), + timestamp: z.string(), + tx_hash: z.string(), + tx_index: z.number(), + src_data: z.string(), + ident: z.string(), + creator_name: z.string(), + stamp_gen: z.string(), + stamp_hash: z.string(), + is_btc_stamp: z.number(), + is_reissue: z.number(), + file_hash: z.string(), +}); -interface Src20 { - id: string; - address: string; - cpid: string; - p: string; - tick: string; - amt: number; - block_time: string; - last_update: number; -} +export type Stamp = z.infer; -interface StampsByAddressQueryResponse { - page: number; - limit: number; - totalPages: number; - total: number; - last_block: number; - btc: { - address: string; - balance: number; - txCount: number; - unconfirmedBalance: number; - unconfirmedTxCount: number; - }; - data: { - stamps: Stamp[]; - src20: Src20[]; - }; -} +const src20TokenSchema = z.object({ + id: z.string(), + address: z.string(), + cpid: z.string(), + p: z.string(), + tick: z.string(), + amt: z.number(), + block_time: z.string(), + last_update: z.number(), +}); + +export type Src20Token = z.infer; + +const stampsByAdressSchema = z.object({ + page: z.number(), + limit: z.number(), + totalPages: z.number(), + total: z.number(), + last_block: z.number().optional(), + btc: z.object({ + address: z.string(), + balance: z.number(), + txCount: z.number(), + unconfirmedBalance: z.number(), + unconfirmedTxCount: z.number(), + }), + data: z.object({ + stamps: z.array(stampSchema), + src20: z.array(src20TokenSchema), + }), +}); + +type StampsByAddressQueryResponse = z.infer; /** * @see https://stampchain.io/docs#/default/get_api_v2_balance__address_ */ -async function fetchStampsByAddress(address: string): Promise { +async function fetchStampsByAddress(address: string): Promise { const resp = await axios.get( `https://stampchain.io/api/v2/balance/${address}` ); - return resp.data.data.stamps; + return stampsByAdressSchema.parse(resp.data); } type FetchStampsByAddressResp = Awaited>; @@ -80,7 +87,7 @@ export function useStampsByAddressQuery fetchStampsByAddress(address), - ...options, refetchOnWindowFocus: false, + ...options, }); } diff --git a/packages/query/src/bitcoin/transaction/transaction.query.ts b/packages/query/src/bitcoin/transaction/transaction.query.ts index 3faa6ab73..4454d801f 100644 --- a/packages/query/src/bitcoin/transaction/transaction.query.ts +++ b/packages/query/src/bitcoin/transaction/transaction.query.ts @@ -1,6 +1,4 @@ import { BitcoinTx } from '@leather-wallet/models'; -import * as btc from '@scure/btc-signer'; -import { bytesToHex } from '@stacks/common'; import { UseQueryResult, useQueries, useQuery } from '@tanstack/react-query'; import { AppUseQueryConfig } from '../../query-config'; @@ -38,17 +36,14 @@ const queryOptions = { } as const; // ts-unused-exports:disable-next-line -export function useGetBitcoinTransactionQueries( - inputs: btc.TransactionInput[] -): UseQueryResult[] { +export function useGetBitcoinTransactionQueries(txids: string[]): UseQueryResult[] { const client = useBitcoinClient(); return useQueries({ - queries: inputs.map(input => { - const txId = input.txid ? bytesToHex(input.txid) : ''; + queries: txids.map(txid => { return { - queryKey: ['bitcoin-transaction', txId], - queryFn: () => fetchBitcoinTransaction(client)(txId), + queryKey: ['bitcoin-transaction', txid], + queryFn: () => fetchBitcoinTransaction(client)(txid), ...queryOptions, }; }), diff --git a/packages/query/src/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts b/packages/query/src/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts index f1d6004b4..82315c1a1 100644 --- a/packages/query/src/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts +++ b/packages/query/src/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts @@ -5,7 +5,7 @@ import { delay } from '@leather-wallet/utils'; import * as btc from '@scure/btc-signer'; import { useBitcoinClient } from '../bitcoin-client'; -import { filterOutIntentionalUtxoSpend, useCheckInscribedUtxos } from './use-check-utxos'; +import { filterOutIntentionalUtxoSpend, useCheckUnspendableUtxos } from './use-check-utxos'; interface BroadcastCallbackArgs { tx: string; @@ -19,9 +19,7 @@ interface BroadcastCallbackArgs { export function useBitcoinBroadcastTransaction() { const client = useBitcoinClient(); const [isBroadcasting, setIsBroadcasting] = useState(false); - // TODO: analytics should be handled on app level - // const analytics = useAnalytics(); - const { checkIfUtxosListIncludesInscribed } = useCheckInscribedUtxos(); + const { checkIfUtxosListIncludesInscribed } = useCheckUnspendableUtxos(); const broadcastTx = useCallback( async ({ @@ -57,10 +55,6 @@ export function useBitcoinBroadcastTransaction() { return txid; } catch (e) { onError?.(e as Error); - // void analytics.track('error_broadcasting_transaction', { - // errorName: isError(e) ? e.name : 'unknown', - // errorMsg: isError(e) ? e.message : 'unknown', - // }); return; } finally { setIsBroadcasting(false); diff --git a/packages/query/src/bitcoin/transaction/use-check-utxos.ts b/packages/query/src/bitcoin/transaction/use-check-utxos.ts index 20d3f84b4..aa9190b51 100644 --- a/packages/query/src/bitcoin/transaction/use-check-utxos.ts +++ b/packages/query/src/bitcoin/transaction/use-check-utxos.ts @@ -5,11 +5,7 @@ import * as btc from '@scure/btc-signer'; import { bytesToHex } from '@stacks/common'; import { useCurrentNetworkState, useIsLeatherTestingEnv } from '../../leather-query-provider'; -import { - type BestinslotInscriptionByIdResponse, - type BestinslotInscriptionsByTxIdResponse, - useBitcoinClient, -} from '../bitcoin-client'; +import { BitcoinClient, useBitcoinClient } from '../bitcoin-client'; import { getNumberOfInscriptionOnUtxoUsingOrdinalsCom } from '../ordinals/inscriptions.query'; class PreventTransactionError extends Error { @@ -41,27 +37,29 @@ export function filterOutIntentionalUtxoSpend({ interface CheckInscribedUtxosByBestinslotArgs { inputs: btc.TransactionInput[]; txids: string[]; - getInscriptionsByTransactionId(id: string): Promise; - getInscriptionById(id: string): Promise; + client: BitcoinClient; } async function checkInscribedUtxosByBestinslot({ inputs, txids, - getInscriptionsByTransactionId, - getInscriptionById, + client, }: CheckInscribedUtxosByBestinslotArgs): Promise { /** * @description Get the list of inscriptions moving on a transaction * @see https://docs.bestinslot.xyz/reference/api-reference/ordinals-and-brc-20-and-bitmap-v3-api-mainnet+testnet/inscriptions */ - const inscriptionIdsList = await Promise.all(txids.map(id => getInscriptionsByTransactionId(id))); + const inscriptionIdsList = await Promise.all( + txids.map(id => client.BestinSlotApi.getInscriptionsByTransactionId(id)) + ); const inscriptionIds = inscriptionIdsList.flatMap(inscription => inscription.data.map(data => data.inscription_id) ); - const inscriptionsList = await Promise.all(inscriptionIds.map(id => getInscriptionById(id))); + const inscriptionsList = await Promise.all( + inscriptionIds.map(id => client.BestinSlotApi.getInscriptionById(id)) + ); const hasInscribedUtxos = inscriptionsList.some(resp => { return inputs.some(input => { @@ -74,10 +72,8 @@ async function checkInscribedUtxosByBestinslot({ return hasInscribedUtxos; } -export function useCheckInscribedUtxos(blockTxAction?: () => void) { +export function useCheckUnspendableUtxos(blockTxAction?: () => void) { const client = useBitcoinClient(); - // TODO: analytics should be handled on app level - // const analytics = useAnalytics(); const [isLoading, setIsLoading] = useState(false); const { isTestnet } = useCurrentNetworkState(); const isTestEnv = useIsLeatherTestingEnv(); @@ -122,9 +118,6 @@ export function useCheckInscribedUtxos(blockTxAction?: () => void) { // if there are inscribed utxos in the transaction, and no error => prevent the transaction if (hasInscribedUtxo) { - // void analytics.track('utxos_includes_inscribed_one', { - // txids, - // }); preventTransaction(); return true; } @@ -136,21 +129,13 @@ export function useCheckInscribedUtxos(blockTxAction?: () => void) { throw e; } - // void analytics.track('error_checking_utxos_from_ordinalscom', { - // txids, - // }); - const hasInscribedUtxo = await checkInscribedUtxosByBestinslot({ inputs, txids, - getInscriptionsByTransactionId: client.BestinslotApi.getInscriptionsByTransactionId, - getInscriptionById: client.BestinslotApi.getInscriptionById, + client, }); if (hasInscribedUtxo) { - // void analytics.track('utxos_includes_inscribed_one', { - // txids, - // }); preventTransaction(); return true; } diff --git a/packages/query/src/common/market-data/market-data.hooks.ts b/packages/query/src/common/market-data/market-data.hooks.ts index 7c2d21e0d..bc933e024 100644 --- a/packages/query/src/common/market-data/market-data.hooks.ts +++ b/packages/query/src/common/market-data/market-data.hooks.ts @@ -41,7 +41,7 @@ function pullPriceDataFromAvailableResponses(responses: MarketDataVendorWithPric .map(val => convertAmountToFractionalUnit(val, currencyDecimalsMap.USD)); } -export function useCryptoCurrencyMarketData(currency: CryptoCurrencies): MarketData { +export function useCryptoCurrencyMarketDataMeanAverage(currency: CryptoCurrencies): MarketData { const { data: coingecko } = useCoinGeckoMarketDataQuery(currency); const { data: coincap } = useCoincapMarketDataQuery(currency); const { data: binance } = useBinanceMarketDataQuery(currency); @@ -59,7 +59,7 @@ export function useCryptoCurrencyMarketData(currency: CryptoCurrencies): MarketD } export function useCalculateBitcoinFiatValue() { - const btcMarketData = useCryptoCurrencyMarketData('BTC'); + const btcMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); return useCallback( (value: Money) => baseCurrencyAmountInQuote(value, btcMarketData), diff --git a/packages/query/src/common/remote-config/remote-config.query.ts b/packages/query/src/common/remote-config/remote-config.query.ts new file mode 100644 index 000000000..12843e104 --- /dev/null +++ b/packages/query/src/common/remote-config/remote-config.query.ts @@ -0,0 +1,159 @@ +import { createMoney, isUndefined } from '@leather-wallet/utils'; +import { useQuery } from '@tanstack/react-query'; +import axios from 'axios'; +import get from 'lodash.get'; + +import { LeatherEnvironment, useLeatherEnv, useLeatherGithub } from '../../leather-query-provider'; + +export interface HiroMessage { + id: string; + title: string; + text: string; + img?: string; + imgWidth?: string; + purpose: 'error' | 'info' | 'warning'; + publishedAt: string; + dismissible: boolean; + chainTarget: 'all' | 'mainnet' | 'testnet'; + learnMoreUrl?: string; + learnMoreText?: string; +} + +export enum AvailableRegions { + InsideUsa = 'inside-usa-only', + OutsideUsa = 'outside-usa-only', + Global = 'global', +} + +export interface ActiveFiatProvider { + availableRegions: AvailableRegions; + enabled: boolean; + hasFastCheckoutProcess: boolean; + hasTradingFees: boolean; + name: string; +} + +interface FeeEstimationsConfig { + maxValues?: number[]; + maxValuesEnabled?: boolean; + minValues?: number[]; + minValuesEnabled?: boolean; +} + +interface RemoteConfig { + messages: any; + activeFiatProviders?: Record; + bitcoinEnabled: boolean; + bitcoinSendEnabled: boolean; + feeEstimationsMinMax?: FeeEstimationsConfig; + nftMetadataEnabled: boolean; + ordinalsbot: { + integrationEnabled: boolean; + mainnetApiUrl: string; + signetApiUrl: string; + }; +} + +function fetchLeatherMessages(env: string, leatherGh: LeatherEnvironment['github']) { + const IS_DEV_ENV = env === 'development'; + const IS_TESTING_ENV = env === 'testing'; + // TODO: BRANCH_NAME is not working here for config changes on PR branches + // Playwright tests fail with config changes not on main + const defaultBranch = IS_DEV_ENV || IS_TESTING_ENV ? 'dev' : 'main'; + const githubWalletConfigRawUrl = `https://raw.githubusercontent.com/${leatherGh.org}/${leatherGh.repo}/${ + leatherGh.branchName || defaultBranch + }/config/wallet-config.json`; + + return async function fetchLeatherMessagesImpl(): Promise { + const resp = await axios.get(githubWalletConfigRawUrl); + return resp.data; + }; +} + +export function useRemoteConfig() { + const env = useLeatherEnv(); + const leatherGh = useLeatherGithub(); + const { data } = useQuery(['walletConfig'], fetchLeatherMessages(env, leatherGh), { + // As we're fetching from Github, a third-party, we want + // to avoid any unnecessary stress on their services, so + // we use quite slow stale/retry times + staleTime: 1000 * 60 * 10, + retryDelay: 1000 * 60, + }); + return data; +} + +export function useRemoteLeatherMessages(): HiroMessage[] { + const config = useRemoteConfig(); + return get(config, 'messages.global', []); +} + +export function useActiveFiatProviders() { + const config = useRemoteConfig(); + if (!config?.activeFiatProviders) return {} as Record; + + return Object.fromEntries( + Object.entries(config.activeFiatProviders).filter(([_, provider]) => provider.enabled) + ); +} + +export function useHasFiatProviders() { + const activeProviders = useActiveFiatProviders(); + return ( + activeProviders && + Object.keys(activeProviders).reduce((acc, key) => activeProviders[key].enabled || acc, false) + ); +} + +export function useRecoverUninscribedTaprootUtxosFeatureEnabled() { + const config = useRemoteConfig(); + return get(config, 'recoverUninscribedTaprootUtxosFeatureEnabled', false); +} + +export function useConfigFeeEstimationsMaxEnabled() { + const config = useRemoteConfig(); + if (isUndefined(config) || isUndefined(config?.feeEstimationsMinMax)) return; + return config.feeEstimationsMinMax.maxValuesEnabled; +} + +export function useConfigFeeEstimationsMaxValues() { + const config = useRemoteConfig(); + if (typeof config?.feeEstimationsMinMax === 'undefined') return; + if (!config.feeEstimationsMinMax.maxValues) return; + if (!Array.isArray(config.feeEstimationsMinMax.maxValues)) return; + return config.feeEstimationsMinMax.maxValues.map(value => createMoney(value, 'STX')); +} + +export function useConfigFeeEstimationsMinEnabled() { + const config = useRemoteConfig(); + if (isUndefined(config) || isUndefined(config?.feeEstimationsMinMax)) return; + return config.feeEstimationsMinMax.minValuesEnabled; +} + +export function useConfigFeeEstimationsMinValues() { + const config = useRemoteConfig(); + if (typeof config?.feeEstimationsMinMax === 'undefined') return; + if (!config.feeEstimationsMinMax.minValues) return; + if (!Array.isArray(config.feeEstimationsMinMax.minValues)) return; + return config.feeEstimationsMinMax.minValues.map(value => createMoney(value, 'STX')); +} + +export function useConfigNftMetadataEnabled() { + const config = useRemoteConfig(); + return config?.nftMetadataEnabled ?? true; +} + +export function useConfigOrdinalsbot() { + const config = useRemoteConfig(); + + return { + integrationEnabled: config?.ordinalsbot.integrationEnabled ?? true, + mainnetApiUrl: config?.ordinalsbot.mainnetApiUrl ?? 'https://api2.ordinalsbot.com', + signetApiUrl: config?.ordinalsbot.signetApiUrl ?? 'https://signet.ordinalsbot.com', + }; +} + +export function useConfigRunesEnabled() { + const config = useRemoteConfig(); + return get(config, 'runesEnabled', false); +} diff --git a/packages/query/src/leather-query-provider.tsx b/packages/query/src/leather-query-provider.tsx index 78a5cc526..734cacb34 100644 --- a/packages/query/src/leather-query-provider.tsx +++ b/packages/query/src/leather-query-provider.tsx @@ -4,28 +4,47 @@ import { NetworkConfiguration, NetworkModes } from '@leather-wallet/constants'; import { ChainID } from '@stacks/common'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +export interface LeatherEnvironment { + env: string; + github: { + org: string; + repo: string; + branchName?: string; + }; +} + const LeatherNetworkContext = createContext(null); -const LeatherEnvironmentContext = createContext(null); +const LeatherEnvironmentContext = createContext(null); -export function useLeatherEnv() { +export function useLeatherGithub() { const leatherEnv = useContext(LeatherEnvironmentContext); if (!leatherEnv) { throw new Error('No LeatherEnvironment set, use LeatherQueryProvider to set one'); } - return leatherEnv; + return leatherEnv.github; +} + +export function useLeatherEnv() { + const leatherEnv = useContext(LeatherEnvironmentContext); + + if (!leatherEnv || !leatherEnv.env) { + throw new Error('No LeatherEnvironment set, use LeatherQueryProvider to set one'); + } + + return leatherEnv.env; } export function useIsLeatherTestingEnv() { const leatherEnv = useContext(LeatherEnvironmentContext); - if (!leatherEnv) { + if (!leatherEnv || !leatherEnv.env) { throw new Error('No LeatherEnvironment set, use LeatherQueryProvider to set one'); } - return leatherEnv === 'testing'; + return leatherEnv.env === 'testing'; } export function useLeatherNetwork() { @@ -51,16 +70,16 @@ export function LeatherQueryProvider({ client, network, children, - env, + environment, }: { client: QueryClient; network: NetworkConfiguration; children: ReactNode; - env: string; + environment: LeatherEnvironment; }) { return ( - + {children} diff --git a/packages/query/tsconfig.json b/packages/query/tsconfig.json index 975d7c7be..c01e1f785 100644 --- a/packages/query/tsconfig.json +++ b/packages/query/tsconfig.json @@ -5,6 +5,6 @@ "types": ["vitest/globals"], "outDir": "./dist" }, - "include": ["**/*", ".*.ts"], + "include": ["**/*", ".*.ts", "config/*.json"], "exclude": ["./dist/"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1ca80ed3d..2db8d0433 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,14 +240,14 @@ importers: specifier: 1.1.3 version: 1.1.3 '@scure/bip32': - specifier: 1.3.2 - version: 1.3.2 + specifier: 1.3.3 + version: 1.3.3 '@scure/bip39': specifier: 1.2.1 version: 1.2.1 '@scure/btc-signer': - specifier: 1.1.0 - version: 1.1.0 + specifier: 1.2.1 + version: 1.2.1 '@stacks/common': specifier: 6.8.1 version: 6.8.1 @@ -405,11 +405,11 @@ importers: specifier: 1.1.3 version: 1.1.3 '@scure/bip32': - specifier: 1.3.2 - version: 1.3.2 + specifier: 1.3.3 + version: 1.3.3 '@scure/btc-signer': - specifier: 1.1.0 - version: 1.1.0 + specifier: 1.2.1 + version: 1.2.1 '@stacks/common': specifier: 6.8.1 version: 6.8.1 @@ -437,12 +437,24 @@ importers: bignumber.js: specifier: 9.1.2 version: 9.1.2 + lodash.get: + specifier: 4.4.2 + version: 4.4.2 + p-queue: + specifier: 8.0.1 + version: 8.0.1 react: specifier: '*' version: 18.2.0 + url-join: + specifier: 5.0.0 + version: 5.0.0 yup: specifier: 1.3.3 version: 1.3.3 + zod: + specifier: 3.23.6 + version: 3.23.6 devDependencies: '@leather-wallet/eslint-config': specifier: workspace:* @@ -459,6 +471,9 @@ importers: '@types/jsdom': specifier: 21.1.3 version: 21.1.3 + '@types/lodash.get': + specifier: 4.4.9 + version: 4.4.9 '@types/react': specifier: 18.2.64 version: 18.2.64 @@ -4304,21 +4319,16 @@ packages: tar-fs: 2.1.1 dev: true - /@noble/curves@1.2.0: - resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + /@noble/curves@1.3.0: + resolution: {integrity: sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==} dependencies: - '@noble/hashes': 1.3.2 + '@noble/hashes': 1.3.3 dev: false /@noble/hashes@1.1.5: resolution: {integrity: sha512-LTMZiiLc+V4v1Yi16TD6aX2gmtKszNye0pQgbaLqkvhIqP7nVsSaJsWloGQjJfJ8offaoP5GtX3yY5swbcJxxQ==} dev: false - /@noble/hashes@1.3.2: - resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} - engines: {node: '>= 16'} - dev: false - /@noble/hashes@1.3.3: resolution: {integrity: sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==} engines: {node: '>= 16'} @@ -5932,12 +5942,16 @@ packages: resolution: {integrity: sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==} dev: false - /@scure/bip32@1.3.2: - resolution: {integrity: sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==} + /@scure/base@1.1.6: + resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==} + dev: false + + /@scure/bip32@1.3.3: + resolution: {integrity: sha512-LJaN3HwRbfQK0X1xFSi0Q9amqOgzQnnDngIt+ZlsBC3Bm7/nE7K0kwshZHyaru79yIVRv/e1mQAjZyuZG6jOFQ==} dependencies: - '@noble/curves': 1.2.0 + '@noble/curves': 1.3.0 '@noble/hashes': 1.3.3 - '@scure/base': 1.1.3 + '@scure/base': 1.1.6 dev: false /@scure/bip39@1.2.1: @@ -5947,13 +5961,13 @@ packages: '@scure/base': 1.1.3 dev: false - /@scure/btc-signer@1.1.0: - resolution: {integrity: sha512-kCX7WaaTJr0VZIXDvaY0wNZfzZoZuLnPz4G0qmKXN8bnNx5M86wb1cce9XrZcfzb0jrVAbZJqNpxmE1e7Ka2hA==} + /@scure/btc-signer@1.2.1: + resolution: {integrity: sha512-/Zle18/aWhYDBuBeXGDGJTdo0/LKpQhU8ETBJeWABCQkbk0QHCFCinidTiz9hdQFfh0HtasPGq5p6EodVCfEew==} dependencies: - '@noble/curves': 1.2.0 + '@noble/curves': 1.3.0 '@noble/hashes': 1.3.3 - '@scure/base': 1.1.3 - micro-packed: 0.3.2 + '@scure/base': 1.1.6 + micro-packed: 0.5.3 dev: false /@segment/loosely-validate-event@2.0.0: @@ -7927,6 +7941,12 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/lodash.get@4.4.9: + resolution: {integrity: sha512-J5dvW98sxmGnamqf+/aLP87PYXyrha9xIgc2ZlHl6OHMFR2Ejdxep50QfU0abO1+CH6+ugx+8wEUN1toImAinA==} + dependencies: + '@types/lodash': 4.17.0 + dev: true + /@types/lodash.merge@4.6.9: resolution: {integrity: sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==} dependencies: @@ -11541,6 +11561,10 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + /eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + dev: false + /events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -13987,6 +14011,10 @@ packages: /lodash.debounce@4.0.8: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + /lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + dev: false + /lodash.isfunction@3.0.9: resolution: {integrity: sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==} dev: true @@ -14742,10 +14770,10 @@ packages: - supports-color - utf-8-validate - /micro-packed@0.3.2: - resolution: {integrity: sha512-D1Bq0/lVOzdxhnX5vylCxZpdw5LylH7Vd81py0DfRsKUP36XYpwvy8ZIsECVo3UfnoROn8pdKqkOzL7Cd82sGA==} + /micro-packed@0.5.3: + resolution: {integrity: sha512-zWRoH+qUb/ZMp9gVZhexvRGCENDM5HEQF4sflqpdilUHWK2/zKR7/MT8GBctnTwbhNJwy1iuk5q6+TYP7/twYA==} dependencies: - '@scure/base': 1.1.3 + '@scure/base': 1.1.6 dev: false /microdiff@1.4.0: @@ -15426,6 +15454,19 @@ packages: dependencies: aggregate-error: 3.1.0 + /p-queue@8.0.1: + resolution: {integrity: sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA==} + engines: {node: '>=18'} + dependencies: + eventemitter3: 5.0.1 + p-timeout: 6.1.2 + dev: false + + /p-timeout@6.1.2: + resolution: {integrity: sha512-UbD77BuZ9Bc9aABo74gfXhNvzC9Tx7SxtHSh1fxvx3jTLLYvmVhiQZZrJzqqU0jKbN32kb5VOKiLEQI/3bIjgQ==} + engines: {node: '>=14.16'} + dev: false + /p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -18753,6 +18794,11 @@ packages: /url-join@4.0.0: resolution: {integrity: sha512-EGXjXJZhIHiQMK2pQukuFcL303nskqIRzWvPvV5O8miOfwoUb9G+a/Cld60kUyeaybEI94wvVClT10DtfeAExA==} + /url-join@5.0.0: + resolution: {integrity: sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + /url-parse@1.5.10: resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} dependencies: @@ -19515,6 +19561,10 @@ packages: type-fest: 2.19.0 dev: false + /zod@3.23.6: + resolution: {integrity: sha512-RTHJlZhsRbuA8Hmp/iNL7jnfc4nZishjsanDAfEY1QpDQZCahUp3xDzl+zfweE9BklxMUcgBgS1b7Lvie/ZVwA==} + dev: false + /zustand@4.5.2(patch_hash=g3bvddhn726lqugkadc5hylsxa)(@types/react@18.2.64)(immer@10.1.1)(react@18.2.0): resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==} engines: {node: '>=12.7.0'}