Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: filter out runes utxos, closes #5207 #5229

Merged
merged 1 commit into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 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, isInitialLoadingRunesData } = 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 All @@ -82,7 +106,10 @@ export function useNativeSegwitUtxosByAddress({
return {
...utxosQuery,
isInitialLoading:
utxosQuery.isInitialLoading || isInitialLoading || isInitialLoadingInscriptions,
utxosQuery.isInitialLoading ||
isInitialLoading ||
isInitialLoadingInscriptions ||
isInitialLoadingRunesData,
};
}

Expand Down Expand Up @@ -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);
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],
alter-eggo marked this conversation as resolved.
Show resolved Hide resolved
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'],
},
];
Loading
Loading