Skip to content

Commit 86dd00d

Browse files
committed
feat: check utxo ids for inscriptions, ref #4920
1 parent 3230c49 commit 86dd00d

File tree

14 files changed

+408
-7
lines changed

14 files changed

+408
-7
lines changed

src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ export function useRpcSignPsbt() {
5454

5555
await broadcastTx({
5656
tx,
57+
// skip utxos check for psbt txs
58+
skipSpendableCheckUtxoIds: 'all',
5759
async onSuccess(txid) {
5860
await refetch();
5961

src/app/pages/send/broadcast-error/components/broadcast-error.layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ReactNode } from 'react';
22

33
import BroadcastError from '@assets/images/unhappy-face-ui.png';
4+
import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
45
import { Box, Flex, FlexProps, styled } from 'leather-styles/jsx';
56

67
interface BroadcastErrorProps extends FlexProps {
@@ -23,7 +24,12 @@ export function BroadcastErrorLayout(props: BroadcastErrorProps) {
2324
<Box mt="space.05">
2425
<img src={BroadcastError} alt="Unhappy user interface cloud" width="106px" />
2526
</Box>
26-
<styled.span mx="space.05" mt="space.05" textStyle="heading.05">
27+
<styled.span
28+
data-testid={SharedComponentsSelectors.BroadcastErrorTitle}
29+
mx="space.05"
30+
mt="space.05"
31+
textStyle="heading.05"
32+
>
2733
{title}
2834
</styled.span>
2935
<styled.span color="ink.text-subdued" mt="space.04" textAlign="center" textStyle="body.02">

src/app/pages/send/ordinal-inscription/send-inscription-review.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export function SendInscriptionReview() {
4242

4343
async function sendInscription() {
4444
await broadcastTx({
45+
skipSpendableCheckUtxoIds: [inscription.tx_id],
4546
tx: bytesToHex(signedTx),
4647
async onSuccess(txid: string) {
4748
void analytics.track('broadcast_ordinal_transaction');
@@ -58,8 +59,12 @@ export function SendInscriptionReview() {
5859
},
5960
});
6061
},
61-
onError() {
62-
navigate(`/${RouteUrls.SendOrdinalInscription}/${RouteUrls.SendOrdinalInscriptionError}`);
62+
onError(e) {
63+
navigate(`/${RouteUrls.SendOrdinalInscription}/${RouteUrls.SendOrdinalInscriptionError}`, {
64+
state: {
65+
error: e,
66+
},
67+
});
6368
},
6469
});
6570
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
33
import { hexToBytes } from '@noble/hashes/utils';
44
import * as btc from '@scure/btc-signer';
55
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
6+
import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
67
import { Stack } from 'leather-styles/jsx';
78
import get from 'lodash.get';
89

@@ -157,7 +158,12 @@ export function BtcSendFormConfirmation() {
157158
</Stack>
158159

159160
<InfoCardFooter>
160-
<Button aria-busy={isBroadcasting} onClick={initiateTransaction} width="100%">
161+
<Button
162+
data-testid={SharedComponentsSelectors.InfoCardButton}
163+
aria-busy={isBroadcasting}
164+
onClick={initiateTransaction}
165+
width="100%"
166+
>
161167
Confirm and send transaction
162168
</Button>
163169
</InfoCardFooter>

src/app/pages/send/send-crypto-asset-form/hooks/use-send-form-navigate.ts

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

44
import { bytesToHex } from '@stacks/common';
55
import { StacksTransaction } from '@stacks/transactions';
6+
import { AxiosError } from 'axios';
67

78
import { BitcoinSendFormValues } from '@shared/models/form.model';
89
import { RouteUrls } from '@shared/route-urls';
@@ -95,7 +96,14 @@ export function useSendFormNavigate() {
9596
});
9697
},
9798
toErrorPage(error: unknown) {
98-
return navigate('../error', { relative: 'path', replace: true, state: { error } });
99+
// without this processing, navigate does not work
100+
const processedError = error instanceof AxiosError ? new Error(error.message) : error;
101+
102+
return navigate('../error', {
103+
relative: 'path',
104+
replace: true,
105+
state: { error: processedError },
106+
});
99107
},
100108
}),
101109
[navigate]

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const sendCryptoAssetFormRoutes = (
6464
</Route>
6565
<Route path="/send/btc/disabled" element={<SendBtcDisabled />} />
6666
<Route path="/send/btc/error" element={<BroadcastError />} />
67+
6768
<Route path="/send/btc/confirm" element={<BtcSendFormConfirmation />} />
6869
<Route path={RouteUrls.SendBtcChooseFee} element={<BtcChooseFee />}>
6970
{ledgerBitcoinTxSigningRoutes}

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,67 @@ export interface UtxoWithDerivationPath extends UtxoResponseItem {
2020
derivationPath: string;
2121
}
2222

23+
interface BestinslotInscription {
24+
inscription_name: string | null;
25+
inscription_id: string;
26+
inscription_number: number;
27+
metadata: any | null;
28+
wallet: string;
29+
mime_type: string;
30+
media_length: number;
31+
genesis_ts: number;
32+
genesis_height: number;
33+
genesis_fee: number;
34+
output_value: number;
35+
satpoint: string;
36+
collection_name: string | null;
37+
collection_slug: string | null;
38+
last_transfer_block_height: number;
39+
content_url: string;
40+
bis_url: string;
41+
byte_size: number;
42+
}
43+
44+
export interface BestinslotInscriptionByIdResponse {
45+
data: BestinslotInscription;
46+
block_height: number;
47+
}
48+
49+
export interface BestinslotInscriptionsByTxIdResponse {
50+
data: { inscription_id: string }[];
51+
blockHeight: number;
52+
}
53+
54+
class BestinslotInscriptionsApi {
55+
private defaultOptions = {
56+
headers: {
57+
'x-api-key': `${process.env.BESTINSLOT_API_KEY}`,
58+
},
59+
};
60+
constructor(public configuration: Configuration) {}
61+
62+
async getInscriptionsByTransactionId(id: string) {
63+
const resp = await axios.get<BestinslotInscriptionsByTxIdResponse>(
64+
`https://api.bestinslot.xyz/v3/inscription/in_transaction?tx_id=${id}`,
65+
{
66+
...this.defaultOptions,
67+
}
68+
);
69+
70+
return resp.data;
71+
}
72+
73+
async getInscriptionById(id: string) {
74+
const resp = await axios.get<BestinslotInscriptionByIdResponse>(
75+
`https://api.bestinslot.xyz/v3/inscription/single_info_id?inscription_id=${id}`,
76+
{
77+
...this.defaultOptions,
78+
}
79+
);
80+
return resp.data;
81+
}
82+
}
83+
2384
class AddressApi {
2485
constructor(public configuration: Configuration) {}
2586

@@ -130,11 +191,13 @@ export class BitcoinClient {
130191
addressApi: AddressApi;
131192
feeEstimatesApi: FeeEstimatesApi;
132193
transactionsApi: TransactionsApi;
194+
bestinslotInscriptionsApi: BestinslotInscriptionsApi;
133195

134196
constructor(basePath: string) {
135197
this.configuration = new Configuration(basePath);
136198
this.addressApi = new AddressApi(this.configuration);
137199
this.feeEstimatesApi = new FeeEstimatesApi(this.configuration);
138200
this.transactionsApi = new TransactionsApi(this.configuration);
201+
this.bestinslotInscriptionsApi = new BestinslotInscriptionsApi(this.configuration);
139202
}
140203
}

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,3 +209,31 @@ export function useInscriptionsByAddressQuery(address: string) {
209209

210210
return query;
211211
}
212+
213+
// In lieu of reliable API, we scrape HTML from the Ordinals.com explorer and
214+
// parses the HTML
215+
// Example:
216+
// https://ordinals.com/output/758bd2703dd9f0a2df31c2898aecf6caba05a906498c9bc076947f9fc4d8f081:0
217+
async function getOrdinalsComTxOutputHtmlPage(id: string, index: number) {
218+
const resp = await axios.get(`https://ordinals-explorer.generative.xyz/output/${id}:${index}`);
219+
return new DOMParser().parseFromString(resp.data, 'text/html');
220+
}
221+
222+
export async function getNumberOfInscriptionOnUtxoUsingOrdinalsCom(id: string, index: number) {
223+
const utxoPage = await getOrdinalsComTxOutputHtmlPage(id, index);
224+
225+
// First content on page is inscrption section header and thumbnail of
226+
// inscrptions in utxo
227+
const firstSectionHeader = utxoPage.querySelector('dl > dt:first-child');
228+
if (!firstSectionHeader)
229+
throw new Error('If no element matching this selector is found, something is wrong');
230+
231+
const firstHeaderText = firstSectionHeader.textContent;
232+
const thumbnailCount = utxoPage.querySelectorAll('dl > dt:first-child + dd.thumbnails a').length;
233+
234+
// Were HTML to page to change, thumbnailCount alone would dangerously return
235+
// zero 0, hence additional check that inscrption header is also missing
236+
if (thumbnailCount === 0 && firstHeaderText !== 'inscriptions') return 0;
237+
238+
return thumbnailCount;
239+
}

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import { useCallback, useState } from 'react';
22

3+
import * as btc from '@scure/btc-signer';
4+
5+
import { decodeBitcoinTx } from '@shared/crypto/bitcoin/bitcoin.utils';
36
import { isError } from '@shared/utils';
47

58
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
69
import { delay } from '@app/common/utils';
710
import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
811

12+
import { filterOutIntentionalUtxoSpend, useCheckInscribedUtxos } from './use-check-utxos';
13+
914
interface BroadcastCallbackArgs {
1015
tx: string;
16+
skipSpendableCheckUtxoIds?: string[] | 'all';
1117
delayTime?: number;
1218
onSuccess?(txid: string): void;
1319
onError?(error: Error): void;
@@ -18,10 +24,32 @@ export function useBitcoinBroadcastTransaction() {
1824
const client = useBitcoinClient();
1925
const [isBroadcasting, setIsBroadcasting] = useState(false);
2026
const analytics = useAnalytics();
27+
const { checkIfUtxosListIncludesInscribed } = useCheckInscribedUtxos();
2128

2229
const broadcastTx = useCallback(
23-
async ({ tx, onSuccess, onError, onFinally, delayTime = 700 }: BroadcastCallbackArgs) => {
30+
async ({
31+
tx,
32+
onSuccess,
33+
onError,
34+
onFinally,
35+
skipSpendableCheckUtxoIds = [],
36+
delayTime = 700,
37+
}: BroadcastCallbackArgs) => {
2438
try {
39+
if (skipSpendableCheckUtxoIds !== 'all') {
40+
// Filter out intentional spend inscription txid from the check list
41+
const utxos: btc.TransactionInput[] = filterOutIntentionalUtxoSpend({
42+
inputs: decodeBitcoinTx(tx).inputs,
43+
intentionalSpendUtxoIds: skipSpendableCheckUtxoIds,
44+
});
45+
46+
const hasInscribedUtxos = await checkIfUtxosListIncludesInscribed(utxos);
47+
48+
if (hasInscribedUtxos) {
49+
return;
50+
}
51+
}
52+
2553
setIsBroadcasting(true);
2654
const resp = await client.transactionsApi.broadcastTransaction(tx);
2755
// simulate slower broadcast time to allow mempool refresh
@@ -43,7 +71,7 @@ export function useBitcoinBroadcastTransaction() {
4371
return;
4472
}
4573
},
46-
[analytics, client.transactionsApi]
74+
[analytics, checkIfUtxosListIncludesInscribed, client]
4775
);
4876

4977
return { broadcastTx, isBroadcasting };

0 commit comments

Comments
 (0)