From 3a85a2c8f533b11164429a3894d65a2a8d1a1dfa Mon Sep 17 00:00:00 2001 From: alter-eggo Date: Wed, 14 Feb 2024 15:13:59 +0400 Subject: [PATCH] feat: check utxo ids for inscriptions, ref #4920 --- .../form/btc/btc-send-form-confirmation.tsx | 8 +- .../hooks/use-send-form-navigate.ts | 10 +- .../send-crypto-asset-form.routes.tsx | 1 + src/app/query/bitcoin/bitcoin-client.ts | 22 ++++ .../bitcoin/ordinals/inscriptions.query.ts | 28 +++++ .../use-bitcoin-broadcast-transaction.ts | 16 ++- .../bitcoin/transaction/use-check-utxos.ts | 111 ++++++++++++++++++ 7 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 src/app/query/bitcoin/transaction/use-check-utxos.ts 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..59dab9106d0 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 @@ -28,6 +28,7 @@ import { import { ModalHeader } from '@app/components/modal-header'; import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; import { useBitcoinBroadcastTransaction } from '@app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction'; +import { useCheckInscribedUtxos } from '@app/query/bitcoin/transaction/use-check-utxos'; import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks'; import { Button } from '@app/ui/components/button/button'; @@ -74,9 +75,12 @@ export function BtcSendFormConfirmation() { ); const sendingValue = formatMoneyPadded(createMoneyFromDecimal(Number(transferAmount), symbol)); const summaryFee = formatMoneyPadded(createMoney(Number(fee), symbol)); - + const { checkIfUtxosListIncludesInscribed, isLoading } = useCheckInscribedUtxos({ + inputs: decodedTx.inputs, + }); async function initiateTransaction() { await broadcastTx({ + checkForInscribedUtxos: checkIfUtxosListIncludesInscribed, tx: transaction.hex, async onSuccess(txid) { void analytics.track('broadcast_transaction', { @@ -157,7 +161,7 @@ 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 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..ff98361ece8 100644 --- a/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts +++ b/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts @@ -8,6 +8,7 @@ import { useBitcoinClient } from '@app/store/common/api-clients.hooks'; interface BroadcastCallbackArgs { tx: string; + checkForInscribedUtxos?(): Promise; delayTime?: number; onSuccess?(txid: string): void; onError?(error: Error): void; @@ -20,8 +21,21 @@ export function useBitcoinBroadcastTransaction() { const analytics = useAnalytics(); const broadcastTx = useCallback( - async ({ tx, onSuccess, onError, onFinally, delayTime = 700 }: BroadcastCallbackArgs) => { + async ({ + tx, + onSuccess, + onError, + onFinally, + delayTime = 700, + checkForInscribedUtxos, + }: BroadcastCallbackArgs) => { try { + // add explicit check in broadcastTx to ensure that utxos are checked before broadcasting + const hasInscribedUtxos = await checkForInscribedUtxos?.(); + if (hasInscribedUtxos) { + return; + } + setIsBroadcasting(true); const resp = await client.transactionsApi.broadcastTransaction(tx); // simulate slower broadcast time to allow mempool refresh 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..da669311126 --- /dev/null +++ b/src/app/query/bitcoin/transaction/use-check-utxos.ts @@ -0,0 +1,111 @@ +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 UseCheckInscribedUtxosArgs { + inputs: btc.TransactionInput[]; + blockTxAction?(): void; +} + +export function useCheckInscribedUtxos({ inputs, blockTxAction }: UseCheckInscribedUtxosArgs) { + 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 () => { + 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); + } + }, [client.bestinslotInscriptionsApi, inputs, preventTransaction, isTestnet, analytics]); + + return { + checkIfUtxosListIncludesInscribed, + isLoading, + }; +}