Skip to content

Commit

Permalink
feat: check utxo ids for inscriptions, ref #4920
Browse files Browse the repository at this point in the history
  • Loading branch information
alter-eggo committed Feb 14, 2024
1 parent 81fea0d commit 3a85a2c
Show file tree
Hide file tree
Showing 7 changed files with 192 additions and 4 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -157,7 +161,7 @@ export function BtcSendFormConfirmation() {
</Stack>

<InfoCardFooter>
<Button aria-busy={isBroadcasting} onClick={initiateTransaction} width="100%">
<Button aria-busy={isLoading || isBroadcasting} onClick={initiateTransaction} width="100%">
Confirm and send transaction
</Button>
</InfoCardFooter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const sendCryptoAssetFormRoutes = (
</Route>
<Route path="/send/btc/disabled" element={<SendBtcDisabled />} />
<Route path="/send/btc/error" element={<BroadcastError />} />

<Route path="/send/btc/confirm" element={<BtcSendFormConfirmation />} />
<Route path={RouteUrls.SendBtcChooseFee} element={<BtcChooseFee />}>
{ledgerBitcoinTxSigningRoutes}
Expand Down
22 changes: 22 additions & 0 deletions src/app/query/bitcoin/bitcoin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Expand Down Expand Up @@ -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);
}
}
28 changes: 28 additions & 0 deletions src/app/query/bitcoin/ordinals/inscriptions.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useBitcoinClient } from '@app/store/common/api-clients.hooks';

interface BroadcastCallbackArgs {
tx: string;
checkForInscribedUtxos?(): Promise<boolean>;
delayTime?: number;
onSuccess?(txid: string): void;
onError?(error: Error): void;
Expand All @@ -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
Expand Down
111 changes: 111 additions & 0 deletions src/app/query/bitcoin/transaction/use-check-utxos.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}

0 comments on commit 3a85a2c

Please sign in to comment.