Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement double checking for spendable utxos #4893

Merged
merged 1 commit into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
alter-eggo marked this conversation as resolved.
Show resolved Hide resolved
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
Loading