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..a1a00da17ae 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
@@ -54,6 +54,8 @@ export function useRpcSignPsbt() {
await broadcastTx({
tx,
+ // skip utxos check for psbt txs
+ skipSpendableCheckUtxoIds: 'all',
async onSuccess(txid) {
await refetch();
diff --git a/src/app/pages/send/broadcast-error/components/broadcast-error.layout.tsx b/src/app/pages/send/broadcast-error/components/broadcast-error.layout.tsx
index 6be54385fc5..bc76f414895 100644
--- a/src/app/pages/send/broadcast-error/components/broadcast-error.layout.tsx
+++ b/src/app/pages/send/broadcast-error/components/broadcast-error.layout.tsx
@@ -1,6 +1,7 @@
import { ReactNode } from 'react';
import BroadcastError from '@assets/images/unhappy-face-ui.png';
+import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
import { Box, Flex, FlexProps, styled } from 'leather-styles/jsx';
interface BroadcastErrorProps extends FlexProps {
@@ -23,7 +24,12 @@ export function BroadcastErrorLayout(props: BroadcastErrorProps) {
-
+
{title}
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..c4a0d5ab348 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({
+ skipSpendableCheckUtxoIds: [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/form/btc/btc-send-form-confirmation.tsx b/src/app/pages/send/send-crypto-asset-form/form/btc/btc-send-form-confirmation.tsx
index 5511df43143..edce751584e 100644
--- a/src/app/pages/send/send-crypto-asset-form/form/btc/btc-send-form-confirmation.tsx
+++ b/src/app/pages/send/send-crypto-asset-form/form/btc/btc-send-form-confirmation.tsx
@@ -3,6 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { hexToBytes } from '@noble/hashes/utils';
import * as btc from '@scure/btc-signer';
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
+import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
import { Stack } from 'leather-styles/jsx';
import get from 'lodash.get';
@@ -157,7 +158,12 @@ export function BtcSendFormConfirmation() {
-
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 b0f4bf0f1cf..1655ca92b15 100644
--- a/src/app/query/bitcoin/bitcoin-client.ts
+++ b/src/app/query/bitcoin/bitcoin-client.ts
@@ -20,6 +20,67 @@ export interface UtxoWithDerivationPath extends UtxoResponseItem {
derivationPath: string;
}
+interface BestinslotInscription {
+ inscription_name: string | null;
+ inscription_id: string;
+ inscription_number: number;
+ metadata: any | null;
+ wallet: string;
+ mime_type: string;
+ media_length: number;
+ genesis_ts: number;
+ genesis_height: number;
+ genesis_fee: number;
+ output_value: number;
+ satpoint: string;
+ collection_name: string | null;
+ collection_slug: string | null;
+ last_transfer_block_height: number;
+ content_url: string;
+ bis_url: string;
+ byte_size: number;
+}
+
+export interface BestinslotInscriptionByIdResponse {
+ data: BestinslotInscription;
+ block_height: number;
+}
+
+export interface BestinslotInscriptionsByTxIdResponse {
+ data: { inscription_id: string }[];
+ blockHeight: number;
+}
+
+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(
+ `https://api.bestinslot.xyz/v3/inscription/in_transaction?tx_id=${id}`,
+ {
+ ...this.defaultOptions,
+ }
+ );
+
+ return resp.data;
+ }
+
+ async getInscriptionById(id: string) {
+ const resp = await axios.get(
+ `https://api.bestinslot.xyz/v3/inscription/single_info_id?inscription_id=${id}`,
+ {
+ ...this.defaultOptions,
+ }
+ );
+ return resp.data;
+ }
+}
+
class AddressApi {
constructor(public configuration: Configuration) {}
@@ -130,11 +191,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..4496e947ddf 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;
+ skipSpendableCheckUtxoIds?: string[] | 'all';
delayTime?: number;
onSuccess?(txid: string): void;
onError?(error: Error): void;
@@ -18,10 +24,32 @@ 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,
+ skipSpendableCheckUtxoIds = [],
+ delayTime = 700,
+ }: BroadcastCallbackArgs) => {
try {
+ if (skipSpendableCheckUtxoIds !== 'all') {
+ // Filter out intentional spend inscription txid from the check list
+ const utxos: btc.TransactionInput[] = filterOutIntentionalUtxoSpend({
+ inputs: decodeBitcoinTx(tx).inputs,
+ intentionalSpendUtxoIds: skipSpendableCheckUtxoIds,
+ });
+
+ 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 +71,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..50721d88235
--- /dev/null
+++ b/src/app/query/bitcoin/transaction/use-check-utxos.ts
@@ -0,0 +1,174 @@
+import { useCallback, useState } from 'react';
+
+import * as btc from '@scure/btc-signer';
+import { bytesToHex } from '@stacks/common';
+
+import { IS_TEST_ENV } from '@shared/environment';
+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 type {
+ BestinslotInscriptionByIdResponse,
+ BestinslotInscriptionsByTxIdResponse,
+} from '../bitcoin-client';
+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;
+ });
+ });
+}
+
+interface CheckInscribedUtxosByBestinslotArgs {
+ inputs: btc.TransactionInput[];
+ txids: string[];
+ getInscriptionsByTransactionId(id: string): Promise;
+ getInscriptionById(id: string): Promise;
+}
+
+async function checkInscribedUtxosByBestinslot({
+ inputs,
+ txids,
+ getInscriptionsByTransactionId,
+ getInscriptionById,
+}: CheckInscribedUtxosByBestinslotArgs): Promise {
+ /**
+ * @description Get the list of inscriptions moving on a transaction
+ * @see https://docs.bestinslot.xyz/reference/api-reference/ordinals-and-brc-20-and-bitmap-v3-api-mainnet+testnet/inscriptions
+ */
+ const inscriptionIdsList = await Promise.all(txids.map(id => getInscriptionsByTransactionId(id)));
+
+ const inscriptionIds = inscriptionIdsList.flatMap(inscription =>
+ inscription.data.map(data => data.inscription_id)
+ );
+
+ const inscriptionsList = await Promise.all(inscriptionIds.map(id => getInscriptionById(id)));
+
+ const hasInscribedUtxos = inscriptionsList.some(resp => {
+ return inputs.some(input => {
+ if (!input.txid) throw new Error('Transaction ID is missing in the input');
+ const idWithIndex = `${bytesToHex(input.txid)}:${input.index}`;
+ return resp.data.satpoint.includes(idWithIndex);
+ });
+ });
+
+ return hasInscribedUtxos;
+}
+
+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 && !IS_TEST_ENV) {
+ return false;
+ }
+
+ if (txids.length === 0) {
+ throw new Error('Utxos list cannot be empty');
+ }
+
+ 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) {
+ void analytics.track('utxos_includes_inscribed_one', {
+ txids,
+ });
+ preventTransaction();
+ return true;
+ }
+
+ // if there are no inscribed utxos in the transaction => allow the transaction
+ return false;
+ } catch (e) {
+ if (e instanceof PreventTransactionError) {
+ throw e;
+ }
+
+ void analytics.track('error_checking_utxos_from_ordinalscom', {
+ txids,
+ });
+
+ const hasInscribedUtxo = await checkInscribedUtxosByBestinslot({
+ inputs,
+ txids,
+ getInscriptionsByTransactionId:
+ client.bestinslotInscriptionsApi.getInscriptionsByTransactionId,
+ getInscriptionById: client.bestinslotInscriptionsApi.getInscriptionById,
+ });
+
+ if (hasInscribedUtxo) {
+ void analytics.track('utxos_includes_inscribed_one', {
+ txids,
+ });
+ preventTransaction();
+ return true;
+ }
+
+ // if there are no inscribed utxos in the transaction => allow the transaction
+ return false;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [analytics, client, isTestnet, preventTransaction]
+ );
+
+ return {
+ checkIfUtxosListIncludesInscribed,
+ isLoading,
+ };
+}
diff --git a/tests/mocks/mock-ordinalscom-api.ts b/tests/mocks/mock-ordinalscom-api.ts
new file mode 100644
index 00000000000..1703e8c39bf
--- /dev/null
+++ b/tests/mocks/mock-ordinalscom-api.ts
@@ -0,0 +1,46 @@
+export const mockOrdinalsComApiHtmlResponse = `
+
+
+
+
+
+
+
+
+
+ Output b4c94b7270d8b97c5dc9ecc73176ee7d93e96135b60dfef1b601d661bfd7884b:0
+
+
+
+
+
+
+
+
+ Output b4c94b7270d8b97c5dc9ecc73176ee7d93e96135b60dfef1b601d661bfd7884b:0
+
+ - inscriptions
+ -
+
+
+ - value
- 1461
+ - script pubkey
- OP_0 OP_PUSHBYTES_20 20d6ea31e3b6c8b43d1c8d52fd24c1226caa9bd2
+ - address
- bc1qyrtw5v0rkmytg0gu34f06fxpyfk24x7jevtvx3
+ - transaction
- b4c94b7270d8b97c5dc9ecc73176ee7d93e96135b60dfef1b601d661bfd7884b
+
+
+
+
+`;
diff --git a/tests/page-object-models/send.page.ts b/tests/page-object-models/send.page.ts
index 8e1a631853f..18030bb1bac 100644
--- a/tests/page-object-models/send.page.ts
+++ b/tests/page-object-models/send.page.ts
@@ -25,6 +25,8 @@ export class SendPage {
readonly memoRow: Locator;
readonly feesListItem: Locator;
readonly feeToBePaid: Locator;
+ readonly infoCardButton: Locator;
+ readonly broadcastErrorTitle: Locator;
constructor(page: Page) {
this.page = page;
@@ -58,6 +60,8 @@ export class SendPage {
this.sendMaxButton = page.getByTestId(SendCryptoAssetSelectors.SendMaxBtn);
this.feesListItem = page.getByTestId(SharedComponentsSelectors.FeesListItem);
this.feeToBePaid = page.getByTestId(SharedComponentsSelectors.FeeToBePaidLabel);
+ this.infoCardButton = page.getByTestId(SharedComponentsSelectors.InfoCardButton);
+ this.broadcastErrorTitle = page.getByTestId(SharedComponentsSelectors.BroadcastErrorTitle);
}
async selectBtcAndGoToSendForm() {
@@ -92,4 +96,8 @@ export class SendPage {
await this.goBack();
await this.selectStxAndGoToSendForm();
}
+
+ async clickInfoCardButton() {
+ await this.infoCardButton.click();
+ }
}
diff --git a/tests/selectors/shared-component.selectors.ts b/tests/selectors/shared-component.selectors.ts
index c86460c1ba0..1090a4c75f1 100644
--- a/tests/selectors/shared-component.selectors.ts
+++ b/tests/selectors/shared-component.selectors.ts
@@ -6,6 +6,7 @@ export enum SharedComponentsSelectors {
// InfoCard
InfoCardAssetValue = 'info-card-asset-value',
InfoCardRowValue = 'info-card-row-value',
+ InfoCardButton = 'info-card-button',
// Fees
FeeRow = 'fee-row',
@@ -20,4 +21,7 @@ export enum SharedComponentsSelectors {
// Modal Header
ModalHeaderBackBtn = 'modal-header-back-button',
+
+ // Error
+ BroadcastErrorTitle = 'broadcast-error-title',
}
diff --git a/tests/specs/send/send-btc.spec.ts b/tests/specs/send/send-btc.spec.ts
index 54b3915fdc8..15bba8f9a5e 100644
--- a/tests/specs/send/send-btc.spec.ts
+++ b/tests/specs/send/send-btc.spec.ts
@@ -1,4 +1,5 @@
import { TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS } from '@tests/mocks/constants';
+import { mockOrdinalsComApiHtmlResponse } from '@tests/mocks/mock-ordinalscom-api';
import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
import { getDisplayerAddress } from '@tests/utils';
@@ -82,5 +83,26 @@ test.describe('send btc', () => {
test.expect(fee).toContain(confirmationFee);
});
+
+ test('that prevents transaction if it contains inscribed utxo', async ({ sendPage }) => {
+ await sendPage.page.route('**/ordinals-explorer.generative.xyz/**', async route => {
+ return route.fulfill({
+ status: 200,
+ contentType: 'text/html',
+ body: mockOrdinalsComApiHtmlResponse,
+ });
+ });
+ await sendPage.amountInput.fill('0.00006');
+ await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS);
+
+ await sendPage.previewSendTxButton.click();
+ await sendPage.feesListItem.filter({ hasText: BtcFeeType.High }).click();
+
+ await sendPage.clickInfoCardButton();
+
+ const isErrorPageVisible = await sendPage.broadcastErrorTitle.isVisible();
+
+ test.expect(isErrorPageVisible).toBeTruthy();
+ });
});
});