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 26, 2024
1 parent 3230c49 commit 86dd00d
Show file tree
Hide file tree
Showing 14 changed files with 408 additions and 7 deletions.
2 changes: 2 additions & 0 deletions src/app/pages/rpc-sign-psbt/use-rpc-sign-psbt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export function useRpcSignPsbt() {

await broadcastTx({
tx,
// skip utxos check for psbt txs
skipSpendableCheckUtxoIds: 'all',
async onSuccess(txid) {
await refetch();

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -23,7 +24,12 @@ export function BroadcastErrorLayout(props: BroadcastErrorProps) {
<Box mt="space.05">
<img src={BroadcastError} alt="Unhappy user interface cloud" width="106px" />
</Box>
<styled.span mx="space.05" mt="space.05" textStyle="heading.05">
<styled.span
data-testid={SharedComponentsSelectors.BroadcastErrorTitle}
mx="space.05"
mt="space.05"
textStyle="heading.05"
>
{title}
</styled.span>
<styled.span color="ink.text-subdued" mt="space.04" textAlign="center" textStyle="body.02">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -58,8 +59,12 @@ export function SendInscriptionReview() {
},
});
},
onError() {
navigate(`/${RouteUrls.SendOrdinalInscription}/${RouteUrls.SendOrdinalInscriptionError}`);
onError(e) {
navigate(`/${RouteUrls.SendOrdinalInscription}/${RouteUrls.SendOrdinalInscriptionError}`, {
state: {
error: e,
},
});
},
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -157,7 +158,12 @@ export function BtcSendFormConfirmation() {
</Stack>

<InfoCardFooter>
<Button aria-busy={isBroadcasting} onClick={initiateTransaction} width="100%">
<Button
data-testid={SharedComponentsSelectors.InfoCardButton}
aria-busy={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
63 changes: 63 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,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<BestinslotInscriptionsByTxIdResponse>(
`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<BestinslotInscriptionByIdResponse>(
`https://api.bestinslot.xyz/v3/inscription/single_info_id?inscription_id=${id}`,
{
...this.defaultOptions,
}
);
return resp.data;
}
}

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

Expand Down Expand Up @@ -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);
}
}
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
@@ -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;
Expand All @@ -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
Expand All @@ -43,7 +71,7 @@ export function useBitcoinBroadcastTransaction() {
return;
}
},
[analytics, client.transactionsApi]
[analytics, checkIfUtxosListIncludesInscribed, client]
);

return { broadcastTx, isBroadcasting };
Expand Down
Loading

0 comments on commit 86dd00d

Please sign in to comment.