diff --git a/src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx b/src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx
index 222f4033027..8026db9248b 100644
--- a/src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx
+++ b/src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx
@@ -4,6 +4,7 @@ import { RpcErrorCode } from '@btckit/types';
import { hexToBytes } from '@noble/hashes/utils';
import { bytesToHex } from '@stacks/common';
+import { decodeBitcoinTx } from '@shared/crypto/bitcoin/bitcoin.utils';
import { Money } from '@shared/models/money.model';
import { RouteUrls } from '@shared/route-urls';
import { makeRpcErrorResponse, makeRpcSuccessResponse } from '@shared/rpc/rpc-methods';
@@ -54,6 +55,8 @@ export function useRpcSignPsbt() {
await broadcastTx({
tx,
+ // skip utxos check for psbt txs
+ skipBypassUtxoIdsCheckFor: decodeBitcoinTx(tx).inputs.map(input => bytesToHex(input.txid)),
async onSuccess(txid) {
await refetch();
diff --git a/src/app/pages/send/ordinal-inscription/send-inscription-review.tsx b/src/app/pages/send/ordinal-inscription/send-inscription-review.tsx
index 36ec06caeaa..7b492516b68 100644
--- a/src/app/pages/send/ordinal-inscription/send-inscription-review.tsx
+++ b/src/app/pages/send/ordinal-inscription/send-inscription-review.tsx
@@ -42,6 +42,7 @@ export function SendInscriptionReview() {
async function sendInscription() {
await broadcastTx({
+ skipBypassUtxoIdsCheckFor: [inscription.tx_id],
tx: bytesToHex(signedTx),
async onSuccess(txid: string) {
void analytics.track('broadcast_ordinal_transaction');
@@ -58,8 +59,12 @@ export function SendInscriptionReview() {
},
});
},
- onError() {
- navigate(`/${RouteUrls.SendOrdinalInscription}/${RouteUrls.SendOrdinalInscriptionError}`);
+ onError(e) {
+ navigate(`/${RouteUrls.SendOrdinalInscription}/${RouteUrls.SendOrdinalInscriptionError}`, {
+ state: {
+ error: e,
+ },
+ });
},
});
}
diff --git a/src/app/pages/send/send-crypto-asset-form/hooks/use-send-form-navigate.ts b/src/app/pages/send/send-crypto-asset-form/hooks/use-send-form-navigate.ts
index 8da2d9a6f84..df0de5edb5a 100644
--- a/src/app/pages/send/send-crypto-asset-form/hooks/use-send-form-navigate.ts
+++ b/src/app/pages/send/send-crypto-asset-form/hooks/use-send-form-navigate.ts
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { bytesToHex } from '@stacks/common';
import { StacksTransaction } from '@stacks/transactions';
+import { AxiosError } from 'axios';
import { BitcoinSendFormValues } from '@shared/models/form.model';
import { RouteUrls } from '@shared/route-urls';
@@ -95,7 +96,14 @@ export function useSendFormNavigate() {
});
},
toErrorPage(error: unknown) {
- return navigate('../error', { relative: 'path', replace: true, state: { error } });
+ // without this processing, navigate does not work
+ const processedError = error instanceof AxiosError ? new Error(error.message) : error;
+
+ return navigate('../error', {
+ relative: 'path',
+ replace: true,
+ state: { error: processedError },
+ });
},
}),
[navigate]
diff --git a/src/app/pages/send/send-crypto-asset-form/send-crypto-asset-form.routes.tsx b/src/app/pages/send/send-crypto-asset-form/send-crypto-asset-form.routes.tsx
index 02080a77214..8ad33c31f81 100644
--- a/src/app/pages/send/send-crypto-asset-form/send-crypto-asset-form.routes.tsx
+++ b/src/app/pages/send/send-crypto-asset-form/send-crypto-asset-form.routes.tsx
@@ -64,6 +64,7 @@ export const sendCryptoAssetFormRoutes = (
} />
} />
+
} />
}>
{ledgerBitcoinTxSigningRoutes}
diff --git a/src/app/query/bitcoin/bitcoin-client.ts b/src/app/query/bitcoin/bitcoin-client.ts
index b33ec1242cb..44d9081b877 100644
--- a/src/app/query/bitcoin/bitcoin-client.ts
+++ b/src/app/query/bitcoin/bitcoin-client.ts
@@ -20,6 +20,26 @@ export interface UtxoWithDerivationPath extends UtxoResponseItem {
derivationPath: string;
}
+class BestinslotInscriptionsApi {
+ private defaultOptions = {
+ headers: {
+ 'x-api-key': `${process.env.BESTINSLOT_API_KEY}`,
+ },
+ };
+ constructor(public configuration: Configuration) {}
+
+ async getInscriptionsByTransactionId(id: string) {
+ const resp = await axios.get<{ data: { inscription_id: string }[]; blockHeight: number }>(
+ `https://api.bestinslot.xyz/v3/inscription/in_transaction?tx_id=${id}`,
+ {
+ ...this.defaultOptions,
+ }
+ );
+
+ return resp.data;
+ }
+}
+
class AddressApi {
constructor(public configuration: Configuration) {}
@@ -129,11 +149,13 @@ export class BitcoinClient {
addressApi: AddressApi;
feeEstimatesApi: FeeEstimatesApi;
transactionsApi: TransactionsApi;
+ bestinslotInscriptionsApi: BestinslotInscriptionsApi;
constructor(basePath: string) {
this.configuration = new Configuration(basePath);
this.addressApi = new AddressApi(this.configuration);
this.feeEstimatesApi = new FeeEstimatesApi(this.configuration);
this.transactionsApi = new TransactionsApi(this.configuration);
+ this.bestinslotInscriptionsApi = new BestinslotInscriptionsApi(this.configuration);
}
}
diff --git a/src/app/query/bitcoin/ordinals/inscriptions.query.ts b/src/app/query/bitcoin/ordinals/inscriptions.query.ts
index 16b05dd3edf..a3a4495c986 100644
--- a/src/app/query/bitcoin/ordinals/inscriptions.query.ts
+++ b/src/app/query/bitcoin/ordinals/inscriptions.query.ts
@@ -209,3 +209,31 @@ export function useInscriptionsByAddressQuery(address: string) {
return query;
}
+
+// In lieu of reliable API, we scrape HTML from the Ordinals.com explorer and
+// parses the HTML
+// Example:
+// https://ordinals.com/output/758bd2703dd9f0a2df31c2898aecf6caba05a906498c9bc076947f9fc4d8f081:0
+async function getOrdinalsComTxOutputHtmlPage(id: string, index: number) {
+ const resp = await axios.get(`https://ordinals-explorer.generative.xyz/output/${id}:${index}`);
+ return new DOMParser().parseFromString(resp.data, 'text/html');
+}
+
+export async function getNumberOfInscriptionOnUtxoUsingOrdinalsCom(id: string, index: number) {
+ const utxoPage = await getOrdinalsComTxOutputHtmlPage(id, index);
+
+ // First content on page is inscrption section header and thumbnail of
+ // inscrptions in utxo
+ const firstSectionHeader = utxoPage.querySelector('dl > dt:first-child');
+ if (!firstSectionHeader)
+ throw new Error('If no element matching this selector is found, something is wrong');
+
+ const firstHeaderText = firstSectionHeader.textContent;
+ const thumbnailCount = utxoPage.querySelectorAll('dl > dt:first-child + dd.thumbnails a').length;
+
+ // Were HTML to page to change, thumbnailCount alone would dangerously return
+ // zero 0, hence additional check that inscrption header is also missing
+ if (thumbnailCount === 0 && firstHeaderText !== 'inscriptions') return 0;
+
+ return thumbnailCount;
+}
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 298a6643721..3ad90309e6d 100644
--- a/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts
+++ b/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts
@@ -1,13 +1,19 @@
import { useCallback, useState } from 'react';
+import * as btc from '@scure/btc-signer';
+
+import { decodeBitcoinTx } from '@shared/crypto/bitcoin/bitcoin.utils';
import { isError } from '@shared/utils';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { delay } from '@app/common/utils';
import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
+import { filterOutIntentionalUtxoSpend, useCheckInscribedUtxos } from './use-check-utxos';
+
interface BroadcastCallbackArgs {
tx: string;
+ skipBypassUtxoIdsCheckFor?: string[];
delayTime?: number;
onSuccess?(txid: string): void;
onError?(error: Error): void;
@@ -18,10 +24,29 @@ export function useBitcoinBroadcastTransaction() {
const client = useBitcoinClient();
const [isBroadcasting, setIsBroadcasting] = useState(false);
const analytics = useAnalytics();
+ const { checkIfUtxosListIncludesInscribed } = useCheckInscribedUtxos();
const broadcastTx = useCallback(
- async ({ tx, onSuccess, onError, onFinally, delayTime = 700 }: BroadcastCallbackArgs) => {
+ async ({
+ tx,
+ onSuccess,
+ onError,
+ onFinally,
+ skipBypassUtxoIdsCheckFor = [],
+ delayTime = 700,
+ }: BroadcastCallbackArgs) => {
try {
+ // Filter out intentional spend inscription txid from the check list
+ const utxos: btc.TransactionInput[] = filterOutIntentionalUtxoSpend({
+ inputs: decodeBitcoinTx(tx).inputs,
+ intentionalSpendUtxoIds: skipBypassUtxoIdsCheckFor,
+ });
+
+ const hasInscribedUtxos = await checkIfUtxosListIncludesInscribed(utxos);
+ if (hasInscribedUtxos) {
+ return;
+ }
+
setIsBroadcasting(true);
const resp = await client.transactionsApi.broadcastTransaction(tx);
// simulate slower broadcast time to allow mempool refresh
@@ -43,7 +68,7 @@ export function useBitcoinBroadcastTransaction() {
return;
}
},
- [analytics, client.transactionsApi]
+ [analytics, checkIfUtxosListIncludesInscribed, client]
);
return { broadcastTx, isBroadcasting };
diff --git a/src/app/query/bitcoin/transaction/use-check-utxos.ts b/src/app/query/bitcoin/transaction/use-check-utxos.ts
new file mode 100644
index 00000000000..9f7d5f9c483
--- /dev/null
+++ b/src/app/query/bitcoin/transaction/use-check-utxos.ts
@@ -0,0 +1,127 @@
+import { useCallback, useState } from 'react';
+
+import * as btc from '@scure/btc-signer';
+import { bytesToHex } from '@stacks/common';
+
+import { isUndefined } from '@shared/utils';
+
+import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
+import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
+import { useCurrentNetworkState } from '@app/store/networks/networks.hooks';
+
+import { getNumberOfInscriptionOnUtxoUsingOrdinalsCom } from '../ordinals/inscriptions.query';
+
+class PreventTransactionError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'PreventTransactionError';
+ }
+}
+
+interface FilterOutIntentionalInscriptionsSpendArgs {
+ inputs: btc.TransactionInput[];
+ intentionalSpendUtxoIds: string[];
+}
+export function filterOutIntentionalUtxoSpend({
+ inputs,
+ intentionalSpendUtxoIds,
+}: FilterOutIntentionalInscriptionsSpendArgs): btc.TransactionInput[] {
+ return inputs.filter(input => {
+ if (!input.txid) throw new Error('Transaction ID is missing in the input');
+ const inputTxid = bytesToHex(input.txid);
+
+ return intentionalSpendUtxoIds.every(id => {
+ return id !== inputTxid;
+ });
+ });
+}
+
+export function useCheckInscribedUtxos(blockTxAction?: () => void) {
+ const client = useBitcoinClient();
+ const analytics = useAnalytics();
+ const [isLoading, setIsLoading] = useState(false);
+ const { isTestnet } = useCurrentNetworkState();
+
+ const preventTransaction = useCallback(() => {
+ if (blockTxAction) return blockTxAction();
+ throw new PreventTransactionError(
+ 'Transaction is prevented due to inscribed utxos in the transaction. Please contact support for more information.'
+ );
+ }, [blockTxAction]);
+
+ const checkIfUtxosListIncludesInscribed = useCallback(
+ async (inputs: btc.TransactionInput[]) => {
+ setIsLoading(true);
+ const txids = inputs.map(input => {
+ if (!input.txid) throw new Error('Transaction ID is missing in the input');
+ return bytesToHex(input.txid);
+ });
+
+ try {
+ // no need to check for inscriptions on testnet
+ if (isTestnet) {
+ return false;
+ }
+
+ if (txids.length === 0) {
+ throw new Error('Utxos list cannot be empty');
+ }
+
+ const responses = await Promise.all(
+ txids.map(id => client.bestinslotInscriptionsApi.getInscriptionsByTransactionId(id))
+ );
+
+ const hasInscribedUtxo = responses.some(resp => {
+ return resp.data.length > 0;
+ });
+
+ if (hasInscribedUtxo) {
+ void analytics.track('utxos_includes_inscribed_one', {
+ txids,
+ });
+ preventTransaction();
+ return true;
+ }
+
+ return false;
+ } catch (e) {
+ if (e instanceof PreventTransactionError) {
+ throw e;
+ }
+
+ void analytics.track('error_checking_utxos_from_bestinslot', {
+ txids,
+ });
+
+ const ordinalsComResponses = await Promise.all(
+ txids.map(async (id, index) => {
+ const inscriptionIndex = inputs[index].index;
+ if (isUndefined(inscriptionIndex)) {
+ throw new Error('Inscription index is missing in the input');
+ }
+ const num = await getNumberOfInscriptionOnUtxoUsingOrdinalsCom(id, inscriptionIndex);
+ return num > 0;
+ })
+ );
+
+ const hasInscribedUtxo = ordinalsComResponses.some(resp => resp);
+
+ // if there are inscribed utxos in the transaction, and no error => prevent the transaction
+ if (hasInscribedUtxo) {
+ preventTransaction();
+ return true;
+ }
+
+ return true;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [analytics, client.bestinslotInscriptionsApi, isTestnet, preventTransaction]
+ );
+
+ return {
+ checkIfUtxosListIncludesInscribed,
+ isLoading,
+ };
+}