From 614e81ee71c29f1c3c22b622f7eb6a01d474b246 Mon Sep 17 00:00:00 2001 From: alter-eggo Date: Fri, 9 Feb 2024 16:57:16 +0400 Subject: [PATCH] feat: check utxo ids for inscriptions --- .../form/btc/btc-send-form-confirmation.tsx | 16 ++- .../send-crypto-asset-form.routes.tsx | 11 +- src/app/query/bitcoin/bitcoin-client.ts | 71 ++++++++++ .../bitcoin/ordinals/inscriptions.query.ts | 42 ++++++ .../use-bitcoin-broadcast-transaction.ts | 16 ++- .../bitcoin/transaction/use-check-utxos.ts | 129 ++++++++++++++++++ 6 files changed, 280 insertions(+), 5 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..7afe7004ea9 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 @@ -2,6 +2,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { hexToBytes } from '@noble/hashes/utils'; import * as btc from '@scure/btc-signer'; +import { bytesToHex } from '@stacks/common'; import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors'; import { Stack } from 'leather-styles/jsx'; import get from 'lodash.get'; @@ -28,6 +29,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'; @@ -46,7 +48,11 @@ function useBtcSendFormConfirmationState() { }; } -export function BtcSendFormConfirmation() { +interface BtcSendFormConfirmationProps { + address: string; +} + +export function BtcSendFormConfirmation({ address }: BtcSendFormConfirmationProps) { const navigate = useNavigate(); const { tx, recipient, fee, arrivesIn, feeRowValue } = useBtcSendFormConfirmationState(); @@ -74,9 +80,13 @@ export function BtcSendFormConfirmation() { ); const sendingValue = formatMoneyPadded(createMoneyFromDecimal(Number(transferAmount), symbol)); const summaryFee = formatMoneyPadded(createMoney(Number(fee), symbol)); - + const { checkIfUtxosListIncludesInscribed, isLoading } = useCheckInscribedUtxos({ + txids: decodedTx.inputs.map(input => bytesToHex(input.txid)), + address, + }); async function initiateTransaction() { await broadcastTx({ + checkForInscribedUtxos: checkIfUtxosListIncludesInscribed, tx: transaction.hex, async onSuccess(txid) { void analytics.track('broadcast_transaction', { @@ -157,7 +167,7 @@ export function BtcSendFormConfirmation() { - 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..acbf8534a2f 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 @@ -3,6 +3,7 @@ import { Route } from 'react-router-dom'; import { RouteUrls } from '@shared/route-urls'; +import { BitcoinNativeSegwitAccountLoader } from '@app/components/account/bitcoin-account-loader'; import { BroadcastErrorDrawer } from '@app/components/broadcast-error-drawer/broadcast-error-drawer'; import { SendBtcDisabled } from '@app/components/crypto-assets/choose-crypto-asset/send-btc-disabled'; import { FullPageWithHeaderLoadingSpinner } from '@app/components/loading-spinner'; @@ -64,7 +65,15 @@ export const sendCryptoAssetFormRoutes = ( } /> } /> - } /> + + + {signer => } + + } + /> }> {ledgerBitcoinTxSigningRoutes} diff --git a/src/app/query/bitcoin/bitcoin-client.ts b/src/app/query/bitcoin/bitcoin-client.ts index b33ec1242cb..ec1d5fa12b2 100644 --- a/src/app/query/bitcoin/bitcoin-client.ts +++ b/src/app/query/bitcoin/bitcoin-client.ts @@ -16,10 +16,77 @@ export interface UtxoResponseItem { value: number; } +export interface OrdiscanInscription { + inscription_id: string; + inscription_number: number; + content_type: string; + owner_address: string; + owner_output: string; + timestamp: string; + content_url: string; +} + 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; + } +} + +/** + * @see https://ordiscan.com/docs/api#get-list-of-inscriptions + */ +export const MAX_ORDISCAN_INSCRIPTIONS_PER_REQUEST = 100; + +class OrdiscanApi { + private defaultOptions = { + headers: { + Authorization: `Bearer ${process.env.ORDISCAN_API_KEY}`, + }, + }; + constructor(public configuration: Configuration) {} + + /** + * @description Retrieve a list of inscriptions based on different filters. The max number of inscriptions returned per request is 100. + * @see https://ordiscan.com/docs/api#get-list-of-inscriptions + */ + async getInscriptionsByAddress({ + address, + fromInscriptionNumber, + sort = 'inscription_number_asc', + }: { + address: string; + fromInscriptionNumber: number; + sort?: 'inscription_number_asc' | 'inscription_number_desc'; + }) { + const resp = await axios.get>( + `https://ordiscan.com/v1/inscriptions?address=${address}&from=${fromInscriptionNumber}&sort=${sort}`, + { + ...this.defaultOptions, + } + ); + + return resp.data; + } +} + class AddressApi { constructor(public configuration: Configuration) {} @@ -129,11 +196,15 @@ export class BitcoinClient { addressApi: AddressApi; feeEstimatesApi: FeeEstimatesApi; transactionsApi: TransactionsApi; + bestinslotInscriptionsApi: BestinslotInscriptionsApi; + ordiscanApi: OrdiscanApi; 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); + this.ordiscanApi = new OrdiscanApi(this.configuration); } } diff --git a/src/app/query/bitcoin/ordinals/inscriptions.query.ts b/src/app/query/bitcoin/ordinals/inscriptions.query.ts index 16b05dd3edf..366ab829c72 100644 --- a/src/app/query/bitcoin/ordinals/inscriptions.query.ts +++ b/src/app/query/bitcoin/ordinals/inscriptions.query.ts @@ -12,8 +12,11 @@ import { createNumArrayOfRange } from '@app/common/utils'; import { QueryPrefixes } from '@app/query/query-prefixes'; import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; import { useCurrentTaprootAccount } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks'; +import { useBitcoinClient } from '@app/store/common/api-clients.hooks'; import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; +import { MAX_ORDISCAN_INSCRIPTIONS_PER_REQUEST } from '../bitcoin-client'; + const stopSearchAfterNumberAddressesWithoutOrdinals = 20; const addressesSimultaneousFetchLimit = 5; @@ -209,3 +212,42 @@ export function useInscriptionsByAddressQuery(address: string) { return query; } + +export function useOrdiscanInscriptionsByAddressQuery({ + address, + enabled = false, +}: { + address: string; + enabled?: boolean; +}) { + const network = useCurrentNetwork(); + const client = useBitcoinClient(); + const query = useInfiniteQuery({ + queryKey: [QueryPrefixes.InscriptionsByAddress, address, network.id, 'ordiscan'], + async queryFn({ pageParam: inscription_number = 0 }) { + return client.ordiscanApi.getInscriptionsByAddress({ + address, + fromInscriptionNumber: inscription_number, + }); + }, + getNextPageParam(prevInscriptionsQuery) { + if (prevInscriptionsQuery.data.length < MAX_ORDISCAN_INSCRIPTIONS_PER_REQUEST) + return undefined; + return prevInscriptionsQuery.data[prevInscriptionsQuery.data.length - 1].inscription_number; + }, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + staleTime: 3 * 60 * 1000, + enabled, + }); + + // Auto-trigger next request + useEffect(() => { + if (enabled) { + void query.fetchNextPage(); + } + }, [query, query.data, enabled]); + + return query; +} 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..fab9cf14bf6 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..4e2ec42d0fe --- /dev/null +++ b/src/app/query/bitcoin/transaction/use-check-utxos.ts @@ -0,0 +1,129 @@ +import { useCallback, useState } from 'react'; + +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 { useOrdiscanInscriptionsByAddressQuery } from '../ordinals/inscriptions.query'; + +class PreventTransactionError extends Error { + constructor(message: string) { + super(message); + this.name = 'PreventTransactionError'; + } +} + +interface UseCheckInscribedUtxosArgs { + txids: string[]; + address: string; + blockTxAction?(): void; +} + +export function useCheckInscribedUtxos({ + txids, + address, + blockTxAction, +}: UseCheckInscribedUtxosArgs) { + const client = useBitcoinClient(); + const analytics = useAnalytics(); + const [isLoading, setIsLoading] = useState(false); + const { isTestnet } = useCurrentNetworkState(); + const { + data: ordInscriptionsList, + refetch: refetchOrdInscriptionsList, + isError: isOrdRequestError, + } = useOrdiscanInscriptionsByAddressQuery({ + address, + }); + + 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); + 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, + }); + + // fallback to ordiscan, refetch is used here as request is disabled by default + await refetchOrdInscriptionsList(); + + const hasInscribedUtxo = ordInscriptionsList?.pages.some(page => { + return page.data.some(v => { + return txids.includes(v.owner_output); + }); + }); + + // if there are inscribed utxos in the transaction, and no error => prevent the transaction + if (hasInscribedUtxo && !isOrdRequestError) { + preventTransaction(); + return true; + } + + // if there is an error fetching inscriptions from ordiscan => throw an error + if (isOrdRequestError) { + void analytics.track('error_checking_utxos_from_ordiscan', { + txids, + }); + + throw new Error( + 'Error trying to check transaction for inscribed utxos. Please try again later or contact support.' + ); + } + + return true; + } finally { + setIsLoading(false); + } + }, [ + client.bestinslotInscriptionsApi, + txids, + isTestnet, + analytics, + preventTransaction, + refetchOrdInscriptionsList, + ordInscriptionsList, + isOrdRequestError, + ]); + + return { + checkIfUtxosListIncludesInscribed, + isLoading, + }; +}