Skip to content

Commit

Permalink
feat: check utxo ids for inscriptions
Browse files Browse the repository at this point in the history
  • Loading branch information
alter-eggo committed Feb 9, 2024
1 parent f0f488a commit 614e81e
Show file tree
Hide file tree
Showing 6 changed files with 280 additions and 5 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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();

Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -157,7 +167,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 { 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';
Expand Down Expand Up @@ -64,7 +65,15 @@ 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="/send/btc/confirm"
element={
<BitcoinNativeSegwitAccountLoader current>
{signer => <BtcSendFormConfirmation address={signer.address} />}
</BitcoinNativeSegwitAccountLoader>
}
/>
<Route path={RouteUrls.SendBtcChooseFee} element={<BtcChooseFee />}>
{ledgerBitcoinTxSigningRoutes}
</Route>
Expand Down
71 changes: 71 additions & 0 deletions src/app/query/bitcoin/bitcoin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<'data', OrdiscanInscription[]>>(
`https://ordiscan.com/v1/inscriptions?address=${address}&from=${fromInscriptionNumber}&sort=${sort}`,
{
...this.defaultOptions,
}
);

return resp.data;
}
}

class AddressApi {
constructor(public configuration: Configuration) {}

Expand Down Expand Up @@ -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);
}
}
42 changes: 42 additions & 0 deletions src/app/query/bitcoin/ordinals/inscriptions.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
}
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
129 changes: 129 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,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,
};
}

0 comments on commit 614e81e

Please sign in to comment.