Skip to content

Commit

Permalink
feat: filter out runes utxos, closes #5207
Browse files Browse the repository at this point in the history
  • Loading branch information
alter-eggo committed Apr 18, 2024
1 parent b4dcf87 commit 1459215
Show file tree
Hide file tree
Showing 11 changed files with 229 additions and 8 deletions.
55 changes: 53 additions & 2 deletions src/app/query/bitcoin/address/utxos-by-address.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -21,17 +26,28 @@ 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,
};

/**
* Warning: ⚠️ To avoid spending inscriptions, when using UTXOs
* 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;
Expand All @@ -40,13 +56,15 @@ export function useCurrentNativeSegwitUtxos(args = defaultArgs) {
address,
filterInscriptionUtxos,
filterPendingTxsUtxos,
filterRunesUtxos,
});
}

interface UseFilterUtxosByAddressArgs {
address: string;
filterInscriptionUtxos: boolean;
filterPendingTxsUtxos: boolean;
filterRunesUtxos: boolean;
}

type filterUtxoFunctionType = (utxos: UtxoResponseItem[]) => UtxoResponseItem[];
Expand All @@ -55,10 +73,12 @@ export function useNativeSegwitUtxosByAddress({
address,
filterInscriptionUtxos,
filterPendingTxsUtxos,
filterRunesUtxos,
}: UseFilterUtxosByAddressArgs) {
const { filterOutInscriptions, isInitialLoadingInscriptions } =
useFilterInscriptionsByAddress(address);
const { filterOutPendingTxsUtxos, isInitialLoading } = useFilterPendingUtxosByAddress(address);
const { filterOutRunesUtxos } = useFilterRuneUtxosByAddress(address);

const utxosQuery = useGetUtxosByAddressQuery(address, {
select(utxos) {
Expand All @@ -71,6 +91,10 @@ export function useNativeSegwitUtxosByAddress({
filters.push(filterOutInscriptions);
}

if (filterRunesUtxos) {
filters.push(filterOutRunesUtxos);
}

return filters.reduce(
(filteredUtxos: UtxoResponseItem[], filterFunc: filterUtxoFunctionType) =>
filterFunc(filteredUtxos),
Expand Down Expand Up @@ -113,6 +137,33 @@ function useFilterInscriptionsByAddress(address: string) {
};
}

function useFilterRuneUtxosByAddress(address: string) {
const { data, isLoading } = useRunesOutputsByAddress(address);
const runesEnabled = useRunesEnabled();

const filterOutRunesUtxos = useCallback(
(utxos: UtxoResponseItem[]) => {
// If Runes are not enabled, return all utxos
if (!runesEnabled) {
return utxos;
}

// To-DO If there are no runes response, filter out all utxos ?
if (!data) {
return [];
}

return filterUtxosWithRunes(data, utxos);
},
[data, runesEnabled]
);

return {
filterOutRunesUtxos,
isLoading,
};
}

function useFilterPendingUtxosByAddress(address: string) {
const { data: pendingInputs = [], isInitialLoading } =
useBitcoinPendingTransactionsInputs(address);
Expand Down
28 changes: 26 additions & 2 deletions src/app/query/bitcoin/address/utxos-by-address.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,35 @@
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', () => {
const filteredUtxos = filterUtxosWithInscriptions(mockInscriptionsList, mockUtxos);
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,
},
]);
});
});
4 changes: 4 additions & 0 deletions src/app/query/bitcoin/balance/btc-balance.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -18,6 +21,7 @@ export function useGetBitcoinBalanceByAddress(address: string) {
address,
filterInscriptionUtxos: true,
filterPendingTxsUtxos: true,
filterRunesUtxos: runesEnabled,
});

const balance = useMemo(() => {
Expand Down
62 changes: 62 additions & 0 deletions src/app/query/bitcoin/bitcoin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<RunesOutputsByAddressResponse>(
`${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<RunesOutputsByAddressResponse>(
`${baseUrl}/runes/wallet_valid_outputs?${queryParams}`,
{ ...this.defaultOptions }
);
return resp.data.data;
}
}

class HiroApi {
Expand Down
29 changes: 29 additions & 0 deletions src/app/query/bitcoin/runes/runes-outputs-by-address.query.ts
Original file line number Diff line number Diff line change
@@ -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<T extends unknown = RunesOutputsByAddress[]>(
address: string,
options?: AppUseQueryConfig<RunesOutputsByAddress[], T>
) {
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,
});
}
15 changes: 15 additions & 0 deletions src/app/query/bitcoin/runes/runes.hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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)
Expand All @@ -36,3 +47,7 @@ export function useRuneTokens(addresses: string[]) {
return makeRuneToken(r, tickerInfo);
});
}

export function useRunesOutputsByAddress(address: string) {
return useGetRunesOutputsByAddressQuery(address);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ({
Expand Down
2 changes: 1 addition & 1 deletion src/app/query/bitcoin/transaction/use-check-utxos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
11 changes: 11 additions & 0 deletions tests/mocks/mock-runes.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
];
25 changes: 25 additions & 0 deletions tests/mocks/mock-utxos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
];

0 comments on commit 1459215

Please sign in to comment.