From 1e02992a7175ac9bbad2b18d2a467c6207ce7cd2 Mon Sep 17 00:00:00 2001 From: alter-eggo Date: Thu, 18 Apr 2024 17:41:31 +0400 Subject: [PATCH] feat: filter out runes utxos, closes #5207 --- .../bitcoin/address/utxos-by-address.hooks.ts | 56 ++++++++++++++++- .../bitcoin/address/utxos-by-address.spec.tsx | 28 ++++++++- .../bitcoin/balance/btc-balance.hooks.ts | 4 ++ src/app/query/bitcoin/bitcoin-client.ts | 62 +++++++++++++++++++ .../runes/runes-outputs-by-address.query.ts | 29 +++++++++ src/app/query/bitcoin/runes/runes.hooks.ts | 15 +++++ .../use-bitcoin-broadcast-transaction.ts | 4 +- .../bitcoin/transaction/use-check-utxos.ts | 2 +- src/shared/constants.ts | 2 +- tests/mocks/mock-runes.ts | 11 ++++ tests/mocks/mock-utxos.ts | 25 ++++++++ 11 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 src/app/query/bitcoin/runes/runes-outputs-by-address.query.ts create mode 100644 tests/mocks/mock-runes.ts diff --git a/src/app/query/bitcoin/address/utxos-by-address.hooks.ts b/src/app/query/bitcoin/address/utxos-by-address.hooks.ts index c5a33cd08e1..40567d97db6 100644 --- a/src/app/query/bitcoin/address/utxos-by-address.hooks.ts +++ b/src/app/query/bitcoin/address/utxos-by-address.hooks.ts @@ -4,8 +4,13 @@ import { InscriptionResponseItem } from '@shared/models/inscription.model'; import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; -import { UtxoResponseItem, UtxoWithDerivationPath } from '../bitcoin-client'; +import { + type RunesOutputsByAddress, + UtxoResponseItem, + UtxoWithDerivationPath, +} 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'; @@ -21,9 +26,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, }; /** @@ -31,7 +47,7 @@ const defaultArgs = { * we set `filterInscriptionUtxos` and `filterPendingTxsUtxos` to true */ export function useCurrentNativeSegwitUtxos(args = defaultArgs) { - const { filterInscriptionUtxos, filterPendingTxsUtxos } = args; + const { filterInscriptionUtxos, filterPendingTxsUtxos, filterRunesUtxos } = args; const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner(); const address = nativeSegwitSigner.address; @@ -40,6 +56,7 @@ export function useCurrentNativeSegwitUtxos(args = defaultArgs) { address, filterInscriptionUtxos, filterPendingTxsUtxos, + filterRunesUtxos, }); } @@ -47,6 +64,7 @@ interface UseFilterUtxosByAddressArgs { address: string; filterInscriptionUtxos: boolean; filterPendingTxsUtxos: boolean; + filterRunesUtxos: boolean; } type filterUtxoFunctionType = (utxos: UtxoResponseItem[]) => UtxoResponseItem[]; @@ -55,10 +73,12 @@ export function useNativeSegwitUtxosByAddress({ address, filterInscriptionUtxos, filterPendingTxsUtxos, + filterRunesUtxos, }: UseFilterUtxosByAddressArgs) { const { filterOutInscriptions, isInitialLoadingInscriptions } = useFilterInscriptionsByAddress(address); const { filterOutPendingTxsUtxos, isInitialLoading } = useFilterPendingUtxosByAddress(address); + const { filterOutRunesUtxos, isInitialLoadingRunesData } = useFilterRuneUtxosByAddress(address); const utxosQuery = useGetUtxosByAddressQuery(address, { select(utxos) { @@ -71,6 +91,10 @@ export function useNativeSegwitUtxosByAddress({ filters.push(filterOutInscriptions); } + if (filterRunesUtxos) { + filters.push(filterOutRunesUtxos); + } + return filters.reduce( (filteredUtxos: UtxoResponseItem[], filterFunc: filterUtxoFunctionType) => filterFunc(filteredUtxos), @@ -82,7 +106,10 @@ export function useNativeSegwitUtxosByAddress({ return { ...utxosQuery, isInitialLoading: - utxosQuery.isInitialLoading || isInitialLoading || isInitialLoadingInscriptions, + utxosQuery.isInitialLoading || + isInitialLoading || + isInitialLoadingInscriptions || + isInitialLoadingRunesData, }; } @@ -113,6 +140,29 @@ function useFilterInscriptionsByAddress(address: string) { }; } +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 = [], isInitialLoading } = useBitcoinPendingTransactionsInputs(address); diff --git a/src/app/query/bitcoin/address/utxos-by-address.spec.tsx b/src/app/query/bitcoin/address/utxos-by-address.spec.tsx index 170f005ea2d..a457da2aaca 100644 --- a/src/app/query/bitcoin/address/utxos-by-address.spec.tsx +++ b/src/app/query/bitcoin/address/utxos-by-address.spec.tsx @@ -1,7 +1,8 @@ import { mockInscriptionsList } from '@tests/mocks/mock-inscriptions'; -import { mockUtxos } from '@tests/mocks/mock-utxos'; +import { mockRunesOutputsByAddressList } from '@tests/mocks/mock-runes'; +import { mockUtxos, mockUtxosListWithRunes } from '@tests/mocks/mock-utxos'; -import { filterUtxosWithInscriptions } from './utxos-by-address.hooks'; +import { filterUtxosWithInscriptions, filterUtxosWithRunes } from './utxos-by-address.hooks'; describe(filterUtxosWithInscriptions, () => { test('that it filters out utxos with inscriptions so they are not spent', () => { @@ -9,3 +10,26 @@ describe(filterUtxosWithInscriptions, () => { expect(filteredUtxos).toEqual([]); }); }); + +describe(filterUtxosWithRunes, () => { + test('that it filters out utxos with runes so they are not spent', () => { + const filteredUtxos = filterUtxosWithRunes( + mockRunesOutputsByAddressList, + mockUtxosListWithRunes + ); + + expect(filteredUtxos).toEqual([ + { + txid: '66ff7d54e345170e3a76819dc90140971fdae054c9b7eea2089ba5a9720f6e44', + vout: 1, + status: { + confirmed: true, + block_height: 2585955, + block_hash: '00000000000000181cae54c3c19d6ed02511a2f6302a666c3d78bcf1777bb029', + block_time: 1712829917, + }, + value: 546, + }, + ]); + }); +}); diff --git a/src/app/query/bitcoin/balance/btc-balance.hooks.ts b/src/app/query/bitcoin/balance/btc-balance.hooks.ts index 94596c6e059..c5deba04ffb 100644 --- a/src/app/query/bitcoin/balance/btc-balance.hooks.ts +++ b/src/app/query/bitcoin/balance/btc-balance.hooks.ts @@ -8,8 +8,11 @@ import { isUndefined } from '@shared/utils'; import { sumNumbers } from '@app/common/math/helpers'; import { useNativeSegwitUtxosByAddress } from '../address/utxos-by-address.hooks'; +import { useRunesEnabled } from '../runes/runes.hooks'; export function useGetBitcoinBalanceByAddress(address: string) { + const runesEnabled = useRunesEnabled(); + const { data: utxos, isInitialLoading, @@ -18,6 +21,7 @@ export function useGetBitcoinBalanceByAddress(address: string) { address, filterInscriptionUtxos: true, filterPendingTxsUtxos: true, + filterRunesUtxos: runesEnabled, }); const balance = useMemo(() => { diff --git a/src/app/query/bitcoin/bitcoin-client.ts b/src/app/query/bitcoin/bitcoin-client.ts index 0b9ffaa42d5..6ba5937d9b4 100644 --- a/src/app/query/bitcoin/bitcoin-client.ts +++ b/src/app/query/bitcoin/bitcoin-client.ts @@ -150,6 +150,30 @@ export interface RuneToken extends RuneBalance, RuneTickerInfo { balance: Money; } +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[]; +} + class BestinslotApi { url = BESTINSLOT_API_BASE_URL_MAINNET; testnetUrl = BESTINSLOT_API_BASE_URL_TESTNET; @@ -220,6 +244,44 @@ class BestinslotApi { ); return resp.data.data; } + + async getRunesBatchOutputsInfo(outputs: string[], network: BitcoinNetworkModes) { + const baseUrl = network === 'mainnet' ? this.url : this.testnetUrl; + + const resp = await axios.post( + `${baseUrl}/runes/batch_output_info`, + { queries: outputs }, + { ...this.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 getRunesOutputsByAddress({ + address, + network = 'mainnet', + sortBy = 'output', + order = 'asc', + offset = 0, + count = 100, + }: RunesOutputsByAddressArgs) { + const baseUrl = network === 'mainnet' ? this.url : this.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}`, + { ...this.defaultOptions } + ); + return resp.data.data; + } } class HiroApi { diff --git a/src/app/query/bitcoin/runes/runes-outputs-by-address.query.ts b/src/app/query/bitcoin/runes/runes-outputs-by-address.query.ts new file mode 100644 index 00000000000..9d7fe7928d4 --- /dev/null +++ b/src/app/query/bitcoin/runes/runes-outputs-by-address.query.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { AppUseQueryConfig } from '@app/query/query-config'; +import { useBitcoinClient } from '@app/store/common/api-clients.hooks'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; + +import type { RunesOutputsByAddress } from '../bitcoin-client'; +import { useRunesEnabled } from './runes.hooks'; + +export function useGetRunesOutputsByAddressQuery( + address: string, + options?: AppUseQueryConfig +) { + const client = useBitcoinClient(); + const runesEnabled = useRunesEnabled(); + const network = useCurrentNetwork(); + + return useQuery({ + queryKey: ['runes-outputs-by-address', address], + queryFn: () => + client.BestinslotApi.getRunesOutputsByAddress({ + address, + network: network.chain.bitcoin.bitcoinNetwork, + }), + staleTime: 1000 * 60, + enabled: !!address && runesEnabled, + ...options, + }); +} diff --git a/src/app/query/bitcoin/runes/runes.hooks.ts b/src/app/query/bitcoin/runes/runes.hooks.ts index 3262162f43a..e734a57ef31 100644 --- a/src/app/query/bitcoin/runes/runes.hooks.ts +++ b/src/app/query/bitcoin/runes/runes.hooks.ts @@ -2,7 +2,11 @@ import { logger } from '@shared/logger'; import { createMoney } from '@shared/models/money.model'; import { isDefined } from '@shared/utils'; +import { useConfigRunesEnabled } from '@app/query/common/remote-config/remote-config.query'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; + 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'; @@ -18,6 +22,13 @@ function makeRuneToken(runeBalance: RuneBalance, tickerInfo: RuneTickerInfo): Ru }; } +export function useRunesEnabled() { + const runesEnabled = useConfigRunesEnabled(); + const network = useCurrentNetwork(); + + return runesEnabled || network.chain.bitcoin.bitcoinNetwork === 'testnet'; +} + export function useRuneTokens(addresses: string[]) { const runesBalances = useGetRunesWalletBalancesByAddressesQuery(addresses) .flatMap(query => query.data) @@ -36,3 +47,7 @@ export function useRuneTokens(addresses: string[]) { return makeRuneToken(r, tickerInfo); }); } + +export function useRunesOutputsByAddress(address: string) { + return useGetRunesOutputsByAddressQuery(address); +} diff --git a/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts b/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts index 3441e8a297b..46b73f18ef6 100644 --- a/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts +++ b/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts @@ -8,7 +8,7 @@ import { delay, isError } from '@shared/utils'; import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; import { useBitcoinClient } from '@app/store/common/api-clients.hooks'; -import { filterOutIntentionalUtxoSpend, useCheckInscribedUtxos } from './use-check-utxos'; +import { filterOutIntentionalUtxoSpend, useCheckUnspendableUtxos } from './use-check-utxos'; interface BroadcastCallbackArgs { tx: string; @@ -23,7 +23,7 @@ export function useBitcoinBroadcastTransaction() { const client = useBitcoinClient(); const [isBroadcasting, setIsBroadcasting] = useState(false); const analytics = useAnalytics(); - const { checkIfUtxosListIncludesInscribed } = useCheckInscribedUtxos(); + const { checkIfUtxosListIncludesInscribed } = useCheckUnspendableUtxos(); const broadcastTx = useCallback( async ({ diff --git a/src/app/query/bitcoin/transaction/use-check-utxos.ts b/src/app/query/bitcoin/transaction/use-check-utxos.ts index 8ecbda64b4b..1d83fe71346 100644 --- a/src/app/query/bitcoin/transaction/use-check-utxos.ts +++ b/src/app/query/bitcoin/transaction/use-check-utxos.ts @@ -78,7 +78,7 @@ async function checkInscribedUtxosByBestinslot({ return hasInscribedUtxos; } -export function useCheckInscribedUtxos(blockTxAction?: () => void) { +export function useCheckUnspendableUtxos(blockTxAction?: () => void) { const client = useBitcoinClient(); const analytics = useAnalytics(); const [isLoading, setIsLoading] = useState(false); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a6819aca2b7..9b5b770fcb8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -76,7 +76,7 @@ export interface NetworkConfiguration { } export const BESTINSLOT_API_BASE_URL_MAINNET = 'https://leatherapi.bestinslot.xyz/v3'; -export const BESTINSLOT_API_BASE_URL_TESTNET = 'https://testnet.api.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'; diff --git a/tests/mocks/mock-runes.ts b/tests/mocks/mock-runes.ts new file mode 100644 index 00000000000..f8ae1a45f64 --- /dev/null +++ b/tests/mocks/mock-runes.ts @@ -0,0 +1,11 @@ +export const mockRunesOutputsByAddressList = [ + { + pkscript: '00148027825ee06ad337f9716df8137a1b651163c5b0', + wallet_addr: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', + output: '3298edc745bdc2168e949382fd42956a7bbe43ab885a49f1212b097ac8243650:1', + rune_ids: ['2585883:3795'], + balances: [100000000], + rune_names: ['BESTINSLOTXYZ'], + spaced_rune_names: ['BESTINSLOT•XYZ'], + }, +]; diff --git a/tests/mocks/mock-utxos.ts b/tests/mocks/mock-utxos.ts index 7f661345db1..119f2273d90 100644 --- a/tests/mocks/mock-utxos.ts +++ b/tests/mocks/mock-utxos.ts @@ -11,3 +11,28 @@ export const mockUtxos = [ value: 546, }, ]; + +export const mockUtxosListWithRunes = [ + { + txid: '66ff7d54e345170e3a76819dc90140971fdae054c9b7eea2089ba5a9720f6e44', + vout: 1, + status: { + confirmed: true, + block_height: 2585955, + block_hash: '00000000000000181cae54c3c19d6ed02511a2f6302a666c3d78bcf1777bb029', + block_time: 1712829917, + }, + value: 546, + }, + { + txid: '3298edc745bdc2168e949382fd42956a7bbe43ab885a49f1212b097ac8243650', + vout: 1, + status: { + confirmed: true, + block_height: 2586064, + block_hash: '0000000000000019390bbd88e463230fa4bcc0e8313081c7a4e25fe0b3024712', + block_time: 1712920121, + }, + value: 546, + }, +];