Skip to content

Commit 614e81e

Browse files
committed
feat: check utxo ids for inscriptions
1 parent f0f488a commit 614e81e

File tree

6 files changed

+280
-5
lines changed

6 files changed

+280
-5
lines changed

src/app/pages/send/send-crypto-asset-form/form/btc/btc-send-form-confirmation.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
22

33
import { hexToBytes } from '@noble/hashes/utils';
44
import * as btc from '@scure/btc-signer';
5+
import { bytesToHex } from '@stacks/common';
56
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
67
import { Stack } from 'leather-styles/jsx';
78
import get from 'lodash.get';
@@ -28,6 +29,7 @@ import {
2829
import { ModalHeader } from '@app/components/modal-header';
2930
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks';
3031
import { useBitcoinBroadcastTransaction } from '@app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction';
32+
import { useCheckInscribedUtxos } from '@app/query/bitcoin/transaction/use-check-utxos';
3133
import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks';
3234
import { Button } from '@app/ui/components/button/button';
3335

@@ -46,7 +48,11 @@ function useBtcSendFormConfirmationState() {
4648
};
4749
}
4850

49-
export function BtcSendFormConfirmation() {
51+
interface BtcSendFormConfirmationProps {
52+
address: string;
53+
}
54+
55+
export function BtcSendFormConfirmation({ address }: BtcSendFormConfirmationProps) {
5056
const navigate = useNavigate();
5157
const { tx, recipient, fee, arrivesIn, feeRowValue } = useBtcSendFormConfirmationState();
5258

@@ -74,9 +80,13 @@ export function BtcSendFormConfirmation() {
7480
);
7581
const sendingValue = formatMoneyPadded(createMoneyFromDecimal(Number(transferAmount), symbol));
7682
const summaryFee = formatMoneyPadded(createMoney(Number(fee), symbol));
77-
83+
const { checkIfUtxosListIncludesInscribed, isLoading } = useCheckInscribedUtxos({
84+
txids: decodedTx.inputs.map(input => bytesToHex(input.txid)),
85+
address,
86+
});
7887
async function initiateTransaction() {
7988
await broadcastTx({
89+
checkForInscribedUtxos: checkIfUtxosListIncludesInscribed,
8090
tx: transaction.hex,
8191
async onSuccess(txid) {
8292
void analytics.track('broadcast_transaction', {
@@ -157,7 +167,7 @@ export function BtcSendFormConfirmation() {
157167
</Stack>
158168

159169
<InfoCardFooter>
160-
<Button aria-busy={isBroadcasting} onClick={initiateTransaction} width="100%">
170+
<Button aria-busy={isLoading || isBroadcasting} onClick={initiateTransaction} width="100%">
161171
Confirm and send transaction
162172
</Button>
163173
</InfoCardFooter>

src/app/pages/send/send-crypto-asset-form/send-crypto-asset-form.routes.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Route } from 'react-router-dom';
33

44
import { RouteUrls } from '@shared/route-urls';
55

6+
import { BitcoinNativeSegwitAccountLoader } from '@app/components/account/bitcoin-account-loader';
67
import { BroadcastErrorDrawer } from '@app/components/broadcast-error-drawer/broadcast-error-drawer';
78
import { SendBtcDisabled } from '@app/components/crypto-assets/choose-crypto-asset/send-btc-disabled';
89
import { FullPageWithHeaderLoadingSpinner } from '@app/components/loading-spinner';
@@ -64,7 +65,15 @@ export const sendCryptoAssetFormRoutes = (
6465
</Route>
6566
<Route path="/send/btc/disabled" element={<SendBtcDisabled />} />
6667
<Route path="/send/btc/error" element={<BroadcastError />} />
67-
<Route path="/send/btc/confirm" element={<BtcSendFormConfirmation />} />
68+
69+
<Route
70+
path="/send/btc/confirm"
71+
element={
72+
<BitcoinNativeSegwitAccountLoader current>
73+
{signer => <BtcSendFormConfirmation address={signer.address} />}
74+
</BitcoinNativeSegwitAccountLoader>
75+
}
76+
/>
6877
<Route path={RouteUrls.SendBtcChooseFee} element={<BtcChooseFee />}>
6978
{ledgerBitcoinTxSigningRoutes}
7079
</Route>

src/app/query/bitcoin/bitcoin-client.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,77 @@ export interface UtxoResponseItem {
1616
value: number;
1717
}
1818

19+
export interface OrdiscanInscription {
20+
inscription_id: string;
21+
inscription_number: number;
22+
content_type: string;
23+
owner_address: string;
24+
owner_output: string;
25+
timestamp: string;
26+
content_url: string;
27+
}
28+
1929
export interface UtxoWithDerivationPath extends UtxoResponseItem {
2030
derivationPath: string;
2131
}
2232

33+
class BestinslotInscriptionsApi {
34+
private defaultOptions = {
35+
headers: {
36+
'x-api-key': `${process.env.BESTINSLOT_API_KEY}`,
37+
},
38+
};
39+
constructor(public configuration: Configuration) {}
40+
41+
async getInscriptionsByTransactionId(id: string) {
42+
const resp = await axios.get<{ data: { inscription_id: string }[]; blockHeight: number }>(
43+
`https://api.bestinslot.xyz/v3/inscription/in_transaction?tx_id=${id}`,
44+
{
45+
...this.defaultOptions,
46+
}
47+
);
48+
49+
return resp.data;
50+
}
51+
}
52+
53+
/**
54+
* @see https://ordiscan.com/docs/api#get-list-of-inscriptions
55+
*/
56+
export const MAX_ORDISCAN_INSCRIPTIONS_PER_REQUEST = 100;
57+
58+
class OrdiscanApi {
59+
private defaultOptions = {
60+
headers: {
61+
Authorization: `Bearer ${process.env.ORDISCAN_API_KEY}`,
62+
},
63+
};
64+
constructor(public configuration: Configuration) {}
65+
66+
/**
67+
* @description Retrieve a list of inscriptions based on different filters. The max number of inscriptions returned per request is 100.
68+
* @see https://ordiscan.com/docs/api#get-list-of-inscriptions
69+
*/
70+
async getInscriptionsByAddress({
71+
address,
72+
fromInscriptionNumber,
73+
sort = 'inscription_number_asc',
74+
}: {
75+
address: string;
76+
fromInscriptionNumber: number;
77+
sort?: 'inscription_number_asc' | 'inscription_number_desc';
78+
}) {
79+
const resp = await axios.get<Record<'data', OrdiscanInscription[]>>(
80+
`https://ordiscan.com/v1/inscriptions?address=${address}&from=${fromInscriptionNumber}&sort=${sort}`,
81+
{
82+
...this.defaultOptions,
83+
}
84+
);
85+
86+
return resp.data;
87+
}
88+
}
89+
2390
class AddressApi {
2491
constructor(public configuration: Configuration) {}
2592

@@ -129,11 +196,15 @@ export class BitcoinClient {
129196
addressApi: AddressApi;
130197
feeEstimatesApi: FeeEstimatesApi;
131198
transactionsApi: TransactionsApi;
199+
bestinslotInscriptionsApi: BestinslotInscriptionsApi;
200+
ordiscanApi: OrdiscanApi;
132201

133202
constructor(basePath: string) {
134203
this.configuration = new Configuration(basePath);
135204
this.addressApi = new AddressApi(this.configuration);
136205
this.feeEstimatesApi = new FeeEstimatesApi(this.configuration);
137206
this.transactionsApi = new TransactionsApi(this.configuration);
207+
this.bestinslotInscriptionsApi = new BestinslotInscriptionsApi(this.configuration);
208+
this.ordiscanApi = new OrdiscanApi(this.configuration);
138209
}
139210
}

src/app/query/bitcoin/ordinals/inscriptions.query.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import { createNumArrayOfRange } from '@app/common/utils';
1212
import { QueryPrefixes } from '@app/query/query-prefixes';
1313
import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
1414
import { useCurrentTaprootAccount } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
15+
import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
1516
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
1617

18+
import { MAX_ORDISCAN_INSCRIPTIONS_PER_REQUEST } from '../bitcoin-client';
19+
1720
const stopSearchAfterNumberAddressesWithoutOrdinals = 20;
1821
const addressesSimultaneousFetchLimit = 5;
1922

@@ -209,3 +212,42 @@ export function useInscriptionsByAddressQuery(address: string) {
209212

210213
return query;
211214
}
215+
216+
export function useOrdiscanInscriptionsByAddressQuery({
217+
address,
218+
enabled = false,
219+
}: {
220+
address: string;
221+
enabled?: boolean;
222+
}) {
223+
const network = useCurrentNetwork();
224+
const client = useBitcoinClient();
225+
const query = useInfiniteQuery({
226+
queryKey: [QueryPrefixes.InscriptionsByAddress, address, network.id, 'ordiscan'],
227+
async queryFn({ pageParam: inscription_number = 0 }) {
228+
return client.ordiscanApi.getInscriptionsByAddress({
229+
address,
230+
fromInscriptionNumber: inscription_number,
231+
});
232+
},
233+
getNextPageParam(prevInscriptionsQuery) {
234+
if (prevInscriptionsQuery.data.length < MAX_ORDISCAN_INSCRIPTIONS_PER_REQUEST)
235+
return undefined;
236+
return prevInscriptionsQuery.data[prevInscriptionsQuery.data.length - 1].inscription_number;
237+
},
238+
refetchOnMount: false,
239+
refetchOnReconnect: false,
240+
refetchOnWindowFocus: false,
241+
staleTime: 3 * 60 * 1000,
242+
enabled,
243+
});
244+
245+
// Auto-trigger next request
246+
useEffect(() => {
247+
if (enabled) {
248+
void query.fetchNextPage();
249+
}
250+
}, [query, query.data, enabled]);
251+
252+
return query;
253+
}

src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
88

99
interface BroadcastCallbackArgs {
1010
tx: string;
11+
checkForInscribedUtxos(): Promise<boolean>;
1112
delayTime?: number;
1213
onSuccess?(txid: string): void;
1314
onError?(error: Error): void;
@@ -20,8 +21,21 @@ export function useBitcoinBroadcastTransaction() {
2021
const analytics = useAnalytics();
2122

2223
const broadcastTx = useCallback(
23-
async ({ tx, onSuccess, onError, onFinally, delayTime = 700 }: BroadcastCallbackArgs) => {
24+
async ({
25+
tx,
26+
onSuccess,
27+
onError,
28+
onFinally,
29+
delayTime = 700,
30+
checkForInscribedUtxos,
31+
}: BroadcastCallbackArgs) => {
2432
try {
33+
// add explicit check in broadcastTx to ensure that utxos are checked before broadcasting
34+
const hasInscribedUtxos = await checkForInscribedUtxos();
35+
if (hasInscribedUtxos) {
36+
return;
37+
}
38+
2539
setIsBroadcasting(true);
2640
const resp = await client.transactionsApi.broadcastTransaction(tx);
2741
// simulate slower broadcast time to allow mempool refresh
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { useCallback, useState } from 'react';
2+
3+
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
4+
import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
5+
import { useCurrentNetworkState } from '@app/store/networks/networks.hooks';
6+
7+
import { useOrdiscanInscriptionsByAddressQuery } from '../ordinals/inscriptions.query';
8+
9+
class PreventTransactionError extends Error {
10+
constructor(message: string) {
11+
super(message);
12+
this.name = 'PreventTransactionError';
13+
}
14+
}
15+
16+
interface UseCheckInscribedUtxosArgs {
17+
txids: string[];
18+
address: string;
19+
blockTxAction?(): void;
20+
}
21+
22+
export function useCheckInscribedUtxos({
23+
txids,
24+
address,
25+
blockTxAction,
26+
}: UseCheckInscribedUtxosArgs) {
27+
const client = useBitcoinClient();
28+
const analytics = useAnalytics();
29+
const [isLoading, setIsLoading] = useState(false);
30+
const { isTestnet } = useCurrentNetworkState();
31+
const {
32+
data: ordInscriptionsList,
33+
refetch: refetchOrdInscriptionsList,
34+
isError: isOrdRequestError,
35+
} = useOrdiscanInscriptionsByAddressQuery({
36+
address,
37+
});
38+
39+
const preventTransaction = useCallback(() => {
40+
if (blockTxAction) return blockTxAction();
41+
throw new PreventTransactionError(
42+
'Transaction is prevented due to inscribed utxos in the transaction. Please contact support for more information.'
43+
);
44+
}, [blockTxAction]);
45+
46+
const checkIfUtxosListIncludesInscribed = useCallback(async () => {
47+
setIsLoading(true);
48+
try {
49+
// no need to check for inscriptions on testnet
50+
if (isTestnet) {
51+
return false;
52+
}
53+
54+
if (txids.length === 0) {
55+
throw new Error('Utxos list cannot be empty');
56+
}
57+
58+
const responses = await Promise.all(
59+
txids.map(id => client.bestinslotInscriptionsApi.getInscriptionsByTransactionId(id))
60+
);
61+
62+
const hasInscribedUtxo = responses.some(resp => {
63+
return resp.data.length > 0;
64+
});
65+
66+
if (hasInscribedUtxo) {
67+
void analytics.track('utxos_includes_inscribed_one', {
68+
txids,
69+
});
70+
preventTransaction();
71+
return true;
72+
}
73+
74+
return false;
75+
} catch (e) {
76+
if (e instanceof PreventTransactionError) {
77+
throw e;
78+
}
79+
80+
void analytics.track('error_checking_utxos_from_bestinslot', {
81+
txids,
82+
});
83+
84+
// fallback to ordiscan, refetch is used here as request is disabled by default
85+
await refetchOrdInscriptionsList();
86+
87+
const hasInscribedUtxo = ordInscriptionsList?.pages.some(page => {
88+
return page.data.some(v => {
89+
return txids.includes(v.owner_output);
90+
});
91+
});
92+
93+
// if there are inscribed utxos in the transaction, and no error => prevent the transaction
94+
if (hasInscribedUtxo && !isOrdRequestError) {
95+
preventTransaction();
96+
return true;
97+
}
98+
99+
// if there is an error fetching inscriptions from ordiscan => throw an error
100+
if (isOrdRequestError) {
101+
void analytics.track('error_checking_utxos_from_ordiscan', {
102+
txids,
103+
});
104+
105+
throw new Error(
106+
'Error trying to check transaction for inscribed utxos. Please try again later or contact support.'
107+
);
108+
}
109+
110+
return true;
111+
} finally {
112+
setIsLoading(false);
113+
}
114+
}, [
115+
client.bestinslotInscriptionsApi,
116+
txids,
117+
isTestnet,
118+
analytics,
119+
preventTransaction,
120+
refetchOrdInscriptionsList,
121+
ordInscriptionsList,
122+
isOrdRequestError,
123+
]);
124+
125+
return {
126+
checkIfUtxosListIncludesInscribed,
127+
isLoading,
128+
};
129+
}

0 commit comments

Comments
 (0)