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) { Unhappy user interface cloud - + {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(); + }); }); });