feat: send inscription choose fee, closes #3544

This commit is contained in:
fbwoolf
2023-05-08 19:52:49 -05:00
committed by Fara Woolf
parent 42b75d502a
commit 45c090fa3c
32 changed files with 402 additions and 276 deletions

View File

@@ -1,17 +1,25 @@
import { Stack, Text } from '@stacks/ui';
import { BtcFeeType } from '@shared/models/fees/bitcoin-fees.model';
import { LoadingSpinner } from '../loading-spinner';
import { FeesCard } from './components/fees-card';
import { useBitcoinFeesList } from './use-bitcoin-fees-list';
export interface FeesListItem {
label: BtcFeeType;
value: number;
btcValue: string;
time: string;
fiatValue: string;
feeRate: number;
}
interface BitcoinFeesListProps {
amount: number;
feesList: FeesListItem[];
isLoading: boolean;
onChooseFee(feeRate: number, feeValue: number, time: string): Promise<void> | void;
recipient: string;
}
export function BitcoinFeesList({ amount, onChooseFee, recipient }: BitcoinFeesListProps) {
const { feesList, isLoading } = useBitcoinFeesList({ amount, recipient });
export function BitcoinFeesList({ feesList, isLoading, onChooseFee }: BitcoinFeesListProps) {
if (isLoading) return <LoadingSpinner />;
if (!feesList.length) {

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { BtcFeeType, btcTxTimeMap } from '@shared/models/fees/bitcoin-fees.model';
import { createMoney } from '@shared/models/money.model';
import { baseCurrencyAmountInQuote } from '@app/common/money/calculate-money';
@@ -7,11 +8,12 @@ import { formatMoneyPadded, i18nFormatCurrency } from '@app/common/money/format-
import { btcToSat } from '@app/common/money/unit-conversion';
import { determineUtxosForSpend } from '@app/common/transactions/bitcoin/coinselect/local-coin-selection';
import { useGetUtxosByAddressQuery } from '@app/query/bitcoin/address/utxos-by-address.query';
import { BtcFeeType, btcTxTimeMap } from '@app/query/bitcoin/bitcoin-client';
import { useAverageBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useAverageBitcoinFeeRates } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks';
import { useCurrentBtcNativeSegwitAccountAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { FeesListItem } from './bitcoin-fees-list';
interface UseBitcoinFeesListArgs {
amount: number;
recipient: string;
@@ -21,9 +23,9 @@ export function useBitcoinFeesList({ amount, recipient }: UseBitcoinFeesListArgs
const { data: utxos } = useGetUtxosByAddressQuery(currentAccountBtcAddress);
const btcMarketData = useCryptoCurrencyMarketData('BTC');
const { avgApiFeeRates: feeRate, isLoading } = useAverageBitcoinFeeRate();
const { avgApiFeeRates: feeRate, isLoading } = useAverageBitcoinFeeRates();
const feesList = useMemo(() => {
const feesList: FeesListItem[] = useMemo(() => {
function getFiatFeeValue(fee: number) {
return `~ ${i18nFormatCurrency(
baseCurrencyAmountInQuote(createMoney(Math.ceil(fee), 'BTC'), btcMarketData)
@@ -40,7 +42,7 @@ export function useBitcoinFeesList({ amount, recipient }: UseBitcoinFeesListArgs
feeRate: feeRate.fastestFee.toNumber(),
});
const { fee: standartFeeValue } = determineUtxosForSpend({
const { fee: standardFeeValue } = determineUtxosForSpend({
utxos,
recipient,
amount: satAmount,
@@ -65,17 +67,17 @@ export function useBitcoinFeesList({ amount, recipient }: UseBitcoinFeesListArgs
},
{
label: BtcFeeType.Standard,
value: standartFeeValue,
btcValue: formatMoneyPadded(createMoney(standartFeeValue, 'BTC')),
value: standardFeeValue,
btcValue: formatMoneyPadded(createMoney(standardFeeValue, 'BTC')),
time: btcTxTimeMap.halfHourFee,
fiatValue: getFiatFeeValue(standartFeeValue),
fiatValue: getFiatFeeValue(standardFeeValue),
feeRate: feeRate.halfHourFee.toNumber(),
},
{
label: BtcFeeType.Low,
value: lowFeeValue,
btcValue: formatMoneyPadded(createMoney(lowFeeValue, 'BTC')),
time: btcTxTimeMap.economyFee,
time: btcTxTimeMap.hourFee,
fiatValue: getFiatFeeValue(lowFeeValue),
feeRate: feeRate.hourFee.toNumber(),
},

View File

@@ -1,4 +1,3 @@
import { BitcoinFeeEstimate } from '@shared/models/fees/bitcoin-fees.model';
import { FeeTypes } from '@shared/models/fees/fees.model';
import { StacksFeeEstimate } from '@shared/models/fees/stacks-fees.model';
@@ -7,7 +6,7 @@ import { FeeEstimateSelectLayout } from './fee-estimate-select.layout';
interface FeeEstimateSelectProps {
isVisible: boolean;
estimate: BitcoinFeeEstimate[] | StacksFeeEstimate[];
estimate: StacksFeeEstimate[];
onSelectItem(index: number): void;
onSetIsSelectVisible(value: boolean): void;
selectedItem: number;

View File

@@ -7,7 +7,7 @@ import { Money, createMoney } from '@shared/models/money.model';
import { sumNumbers } from '@app/common/math/helpers';
import { BtcSizeFeeEstimator } from '@app/common/transactions/bitcoin/fees/btc-size-fee-estimator';
import { useCurrentTaprootAccountUninscribedUtxos } from '@app/query/bitcoin/balance/bitcoin-balances.query';
import { useBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useAverageBitcoinFeeRates } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { getNumberOfInscriptionOnUtxo } from '@app/query/bitcoin/ordinals/ordinals-aware-utxo.query';
import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain';
import { useCurrentAccountTaprootSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
@@ -16,17 +16,17 @@ export function useGenerateRetrieveTaprootFundsTx() {
const networkMode = useBitcoinScureLibNetworkConfig();
const uninscribedUtxos = useCurrentTaprootAccountUninscribedUtxos();
const createSigner = useCurrentAccountTaprootSigner();
const { data: feeRate } = useBitcoinFeeRate();
const { avgApiFeeRates: feeRates } = useAverageBitcoinFeeRates();
const fee = useMemo(() => {
if (!feeRate) return createMoney(0, 'BTC');
if (!feeRates) return createMoney(0, 'BTC');
const txSizer = new BtcSizeFeeEstimator();
const { txVBytes } = txSizer.calcTxSize({
input_count: uninscribedUtxos.length,
p2wpkh_output_count: 1,
});
return createMoney(Math.ceil(txVBytes * feeRate.hourFee), 'BTC');
}, [feeRate, uninscribedUtxos.length]);
return createMoney(Math.ceil(txVBytes * feeRates.hourFee.toNumber()), 'BTC');
}, [feeRates, uninscribedUtxos.length]);
const generateRetrieveTaprootFundsTx = useCallback(
async ({ recipient, fee }: { recipient: string; fee: Money }) => {

View File

@@ -10,6 +10,7 @@ import { noop } from '@shared/utils';
import { useGenerateSignedBitcoinTx } from '@app/common/transactions/bitcoin/use-generate-bitcoin-tx';
import { useWalletType } from '@app/common/use-wallet-type';
import { BitcoinFeesList } from '@app/components/bitcoin-fees-list/bitcoin-fees-list';
import { useBitcoinFeesList } from '@app/components/bitcoin-fees-list/use-bitcoin-fees-list';
function useRpcSendTransferFeeState() {
const location = useLocation();
@@ -24,6 +25,10 @@ export function RpcSendTransferChooseFee() {
const navigate = useNavigate();
const { whenWallet } = useWalletType();
const generateTx = useGenerateSignedBitcoinTx();
const { feesList, isLoading } = useBitcoinFeesList({
amount: Number(amount),
recipient: address,
});
async function previewTransfer(feeRate: number, feeValue: number, time: string) {
const resp = generateTx(
@@ -50,6 +55,6 @@ export function RpcSendTransferChooseFee() {
}
return (
<BitcoinFeesList amount={Number(amount)} onChooseFee={previewTransfer} recipient={address} />
<BitcoinFeesList feesList={feesList} isLoading={isLoading} onChooseFee={previewTransfer} />
);
}

View File

@@ -0,0 +1,11 @@
import { Outlet } from 'react-router-dom';
import { SendInscriptionLoader } from './send-inscription-loader';
export function SendInscriptionContainer() {
return (
<SendInscriptionLoader>
{({ feeRates, inscription, utxo }) => <Outlet context={{ feeRates, inscription, utxo }} />}
</SendInscriptionLoader>
);
}

View File

@@ -0,0 +1,40 @@
import { Navigate, useLocation, useOutletContext } from 'react-router-dom';
import get from 'lodash.get';
import { AverageBitcoinFeeRates } from '@shared/models/fees/bitcoin-fees.model';
import { SupportedInscription } from '@shared/models/inscription.model';
import { RouteUrls } from '@shared/route-urls';
import { useAverageBitcoinFeeRates } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { TaprootUtxo } from '@app/query/bitcoin/ordinals/use-taproot-address-utxos.query';
import { useSendInscriptionRouteState } from '../hooks/use-send-inscription-route-state';
import { createUtxoFromInscription } from './create-utxo-from-inscription';
interface InscriptionSendState {
feeRates: AverageBitcoinFeeRates;
inscription: SupportedInscription;
utxo: TaprootUtxo;
}
export function useInscriptionSendState() {
const location = useLocation();
const context = useOutletContext<InscriptionSendState>();
return { ...context, recipient: get(location.state, 'recipient', '') as string };
}
interface SendInscriptionLoaderProps {
children(data: InscriptionSendState): JSX.Element;
}
export function SendInscriptionLoader({ children }: SendInscriptionLoaderProps) {
const { inscription } = useSendInscriptionRouteState();
const { avgApiFeeRates: feeRates } = useAverageBitcoinFeeRates();
if (!feeRates) return null;
if (!inscription) return <Navigate to={RouteUrls.Home} />;
const utxo = createUtxoFromInscription(inscription);
return children({ inscription, feeRates, utxo });
}

View File

@@ -4,38 +4,36 @@ import { logger } from '@shared/logger';
import { OrdinalSendFormValues } from '@shared/models/form.model';
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/address.hooks';
import { useBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { TaprootUtxo } from '@app/query/bitcoin/ordinals/use-taproot-address-utxos.query';
import { useBitcoinScureLibNetworkConfig } from '@app/store/accounts/blockchain/bitcoin/bitcoin-keychain';
import { useCurrentAccountNativeSegwitSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { useCurrentAccountTaprootSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks';
import { selectInscriptionTransferCoins } from './select-inscription-coins';
import { selectInscriptionTransferCoins } from '../coinselect/select-inscription-coins';
export function useGenerateSignedOrdinalTx(trInput: TaprootUtxo) {
const createTapRootSigner = useCurrentAccountTaprootSigner();
const createNativeSegwitSigner = useCurrentAccountNativeSegwitSigner();
const networkMode = useBitcoinScureLibNetworkConfig();
const { data: feeRate } = useBitcoinFeeRate();
const { data: nativeSegwitUtxos } = useCurrentNativeSegwitUtxos();
function coverFeeFromAdditionalUtxos(values: OrdinalSendFormValues) {
const trSigner = createTapRootSigner?.(trInput.addressIndex);
const nativeSegwitSigner = createNativeSegwitSigner?.(0);
if (!trSigner || !nativeSegwitSigner || !nativeSegwitUtxos || !feeRate) return;
if (!trSigner || !nativeSegwitSigner || !nativeSegwitUtxos || !values.feeRate) return;
const result = selectInscriptionTransferCoins({
recipient: values.recipient,
inscriptionInput: trInput,
nativeSegwitUtxos,
changeAddress: nativeSegwitSigner.payment.address!,
feeRate: feeRate.halfHourFee,
feeRate: values.feeRate,
});
if (!result.success) return null;
const { inputs, outputs, txFee } = result;
const { inputs, outputs } = result;
try {
const tx = new btc.Transaction();
@@ -76,7 +74,7 @@ export function useGenerateSignedOrdinalTx(trInput: TaprootUtxo) {
}
tx.finalize();
return { hex: tx.hex, fee: txFee };
return { hex: tx.hex };
} catch (e) {
logger.error('Unable to sign transaction');
return null;

View File

@@ -0,0 +1,101 @@
import { useMemo } from 'react';
import { BtcFeeType, btcTxTimeMap } from '@shared/models/fees/bitcoin-fees.model';
import { createMoney } from '@shared/models/money.model';
import { baseCurrencyAmountInQuote } from '@app/common/money/calculate-money';
import { formatMoneyPadded, i18nFormatCurrency } from '@app/common/money/format-money';
import { FeesListItem } from '@app/components/bitcoin-fees-list/bitcoin-fees-list';
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/address.hooks';
import { useAverageBitcoinFeeRates } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { TaprootUtxo } from '@app/query/bitcoin/ordinals/use-taproot-address-utxos.query';
import { useCryptoCurrencyMarketData } from '@app/query/common/market-data/market-data.hooks';
import { useCurrentAccountNativeSegwitSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
import { selectInscriptionTransferCoins } from '../coinselect/select-inscription-coins';
interface UseSendInscriptionFeesListArgs {
recipient: string;
utxo: TaprootUtxo;
}
export function useSendInscriptionFeesList({ recipient, utxo }: UseSendInscriptionFeesListArgs) {
const createNativeSegwitSigner = useCurrentAccountNativeSegwitSigner();
const { data: nativeSegwitUtxos } = useCurrentNativeSegwitUtxos();
const btcMarketData = useCryptoCurrencyMarketData('BTC');
const { avgApiFeeRates: feeRates, isLoading } = useAverageBitcoinFeeRates();
const feesList: FeesListItem[] = useMemo(() => {
function getFiatFeeValue(fee: number) {
return `~ ${i18nFormatCurrency(
baseCurrencyAmountInQuote(createMoney(Math.ceil(fee), 'BTC'), btcMarketData)
)}`;
}
const nativeSegwitSigner = createNativeSegwitSigner?.(0);
if (!feeRates || !nativeSegwitUtxos || !nativeSegwitSigner) return [];
const highFeeResult = selectInscriptionTransferCoins({
recipient,
inscriptionInput: utxo,
nativeSegwitUtxos,
changeAddress: nativeSegwitSigner.payment.address!,
feeRate: feeRates.fastestFee.toNumber(),
});
const standardFeeResult = selectInscriptionTransferCoins({
recipient,
inscriptionInput: utxo,
nativeSegwitUtxos,
changeAddress: nativeSegwitSigner.payment.address!,
feeRate: feeRates.halfHourFee.toNumber(),
});
const lowFeeResult = selectInscriptionTransferCoins({
recipient,
inscriptionInput: utxo,
nativeSegwitUtxos,
changeAddress: nativeSegwitSigner.payment.address!,
feeRate: feeRates.hourFee.toNumber(),
});
if (!highFeeResult.success || !standardFeeResult.success || !lowFeeResult.success) return [];
const { txFee: highFeeValue } = highFeeResult;
const { txFee: standardFeeValue } = standardFeeResult;
const { txFee: lowFeeValue } = lowFeeResult;
return [
{
label: BtcFeeType.High,
value: highFeeValue,
btcValue: formatMoneyPadded(createMoney(highFeeValue, 'BTC')),
time: btcTxTimeMap.fastestFee,
fiatValue: getFiatFeeValue(highFeeValue),
feeRate: feeRates.fastestFee.toNumber(),
},
{
label: BtcFeeType.Standard,
value: standardFeeValue,
btcValue: formatMoneyPadded(createMoney(standardFeeValue, 'BTC')),
time: btcTxTimeMap.halfHourFee,
fiatValue: getFiatFeeValue(standardFeeValue),
feeRate: feeRates.halfHourFee.toNumber(),
},
{
label: BtcFeeType.Low,
value: lowFeeValue,
btcValue: formatMoneyPadded(createMoney(lowFeeValue, 'BTC')),
time: btcTxTimeMap.hourFee,
fiatValue: getFiatFeeValue(lowFeeValue),
feeRate: feeRates.hourFee.toNumber(),
},
];
}, [createNativeSegwitSigner, feeRates, nativeSegwitUtxos, recipient, utxo, btcMarketData]);
return {
feesList,
isLoading,
};
}

View File

@@ -0,0 +1,101 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import * as yup from 'yup';
import { logger } from '@shared/logger';
import { OrdinalSendFormValues } from '@shared/models/form.model';
import { RouteUrls } from '@shared/route-urls';
import { noop } from '@shared/utils';
import { FormErrorMessages } from '@app/common/error-messages';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { useWalletType } from '@app/common/use-wallet-type';
import {
btcAddressNetworkValidator,
btcAddressValidator,
btcTaprootAddressValidator,
} from '@app/common/validation/forms/address-validators';
import { getNumberOfInscriptionOnUtxo } from '@app/query/bitcoin/ordinals/ordinals-aware-utxo.query';
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
import { useInscriptionSendState } from '../components/send-inscription-loader';
import { recipeintFieldName } from '../send-inscription-form';
import { useGenerateSignedOrdinalTx } from './use-generate-ordinal-tx';
export function useSendInscriptionForm() {
const [currentError, setShowError] = useState<null | string>(null);
const analytics = useAnalytics();
const navigate = useNavigate();
const { whenWallet } = useWalletType();
const { inscription, utxo } = useInscriptionSendState();
const currentNetwork = useCurrentNetwork();
const { coverFeeFromAdditionalUtxos } = useGenerateSignedOrdinalTx(utxo);
return {
currentError,
async chooseTransactionFee(values: OrdinalSendFormValues) {
// Check tx with fastest fee for errors before routing and
// generating the final transaction with the chosen fee to send
const resp = coverFeeFromAdditionalUtxos(values);
if (!resp) {
setShowError(
'Insufficient funds to cover fee. Deposit some BTC to your Native Segwit address.'
);
return;
}
if (Number(inscription.offset) !== 0) {
setShowError('Sending inscriptions at non-zero offsets is unsupported');
return;
}
try {
const numInscriptionsOnUtxo = await getNumberOfInscriptionOnUtxo(utxo.txid, utxo.vout);
if (numInscriptionsOnUtxo > 1) {
setShowError('Sending inscription from utxo with multiple inscriptions is unsupported');
return;
}
} catch (error) {
void analytics.track('ordinals_dot_com_unavailable', { error });
setShowError('Unable to establish if utxo has multiple inscriptions');
return;
}
whenWallet({
software: () =>
navigate(RouteUrls.SendOrdinalInscriptionChooseFee, {
state: { inscription, recipient: values.recipient, utxo },
}),
ledger: noop,
})();
},
async reviewTransaction(feeValue: number, time: string, values: OrdinalSendFormValues) {
// Generate the final tx with the chosen fee to send
const resp = coverFeeFromAdditionalUtxos(values);
if (!resp) {
logger.error('Failed to generate transaction for send');
return;
}
const { hex } = resp;
return navigate(RouteUrls.SendOrdinalInscriptionReview, {
state: { fee: feeValue, inscription, utxo, recipient: values.recipient, time, tx: hex },
});
},
validationSchema: yup.object({
[recipeintFieldName]: yup
.string()
.required(FormErrorMessages.AddressRequired)
.concat(btcAddressValidator())
.concat(btcAddressNetworkValidator(currentNetwork.chain.bitcoin.network))
.concat(btcTaprootAddressValidator()),
}),
};
}

View File

@@ -6,10 +6,10 @@ import { SupportedInscription } from '@shared/models/inscription.model';
import { TaprootUtxo } from '@app/query/bitcoin/ordinals/use-taproot-address-utxos.query';
export function useSendOrdinalInscriptionRouteState() {
export function useSendInscriptionRouteState() {
const location = useLocation();
return {
inscription: get(location, 'state.inscription', null) as SupportedInscription | null,
utxo: get(location, 'state.utxo', null) as TaprootUtxo | null,
inscription: get(location.state, 'inscription', null) as SupportedInscription | null,
utxo: get(location.state, 'utxo', null) as TaprootUtxo | null,
};
}

View File

@@ -1,35 +1,42 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
// import get from 'lodash.get';
// import { RouteUrls } from '@shared/route-urls';
import { BitcoinFeesList } from '@app/components/bitcoin-fees-list/bitcoin-fees-list';
import { BitcoinFeesListLayout } from '@app/components/bitcoin-fees-list/components/bitcoin-fees-list.layout';
import { BaseDrawer } from '@app/components/drawer/base-drawer';
import { LoadingSpinner } from '@app/components/loading-spinner';
// import { BitcoinChooseFee } from '../send-crypto-asset-form/family/bitcoin/components/bitcoin-choose-fee';
// import { useInscriptionSendState } from './send-inscription-container';
// function useSendInscriptionChooseFeeState() {
// const location = useLocation();
// return {
// tx: get(location.state, 'tx') as string,
// recipient: get(location.state, 'recipient', '') as string,
// };
// }
import { useInscriptionSendState } from './components/send-inscription-loader';
import { useSendInscriptionFeesList } from './hooks/use-send-inscription-fees-list';
import { useSendInscriptionForm } from './hooks/use-send-inscription-form';
export function SendInscriptionChooseFee() {
const [isLoadingReview, setIsLoadingReview] = useState(false);
const navigate = useNavigate();
// const { tx, recipient } = useSendInscriptionChooseFeeState();
// const { inscription, utxo } = useInscriptionSendState();
const { recipient, utxo } = useInscriptionSendState();
const { feesList, isLoading } = useSendInscriptionFeesList({ recipient, utxo });
const { reviewTransaction } = useSendInscriptionForm();
// function previewTransaction(feeRate: number, feeValue: number, time: string) {
// feeRate;
// navigate(RouteUrls.SendOrdinalInscriptionReview, {
// state: { fee: feeValue, inscription, utxo, recipient, tx, arrivesIn: time },
// });
// }
async function previewTransaction(feeRate: number, feeValue: number, time: string) {
try {
setIsLoadingReview(true);
await reviewTransaction(feeValue, time, { feeRate, recipient });
} finally {
setIsLoadingReview(false);
}
}
if (isLoadingReview) return <LoadingSpinner />;
return (
<BaseDrawer title="Choose fee" isShowing enableGoBack onClose={() => navigate(-1)}>
{/* <BitcoinChooseFee onChooseFee={previewTransaction} recipient={recipient} amount={}/>; */}
<BitcoinFeesListLayout>
<BitcoinFeesList
feesList={feesList}
isLoading={isLoading}
onChooseFee={previewTransaction}
/>
</BitcoinFeesListLayout>
</BaseDrawer>
);
}

View File

@@ -1,41 +0,0 @@
import { Navigate, Outlet, useLocation, useOutletContext } from 'react-router-dom';
import get from 'lodash.get';
import { SupportedInscription } from '@shared/models/inscription.model';
import { RouteUrls } from '@shared/route-urls';
import { FeeEstimateMempoolSpaceApi } from '@app/query/bitcoin/bitcoin-client';
import { useBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useSendOrdinalInscriptionRouteState } from './use-send-ordinal-inscription-route-state';
interface InscriptionSendState {
fees: FeeEstimateMempoolSpaceApi;
inscription: SupportedInscription;
}
export function useInscriptionSendState() {
const location = useLocation();
const context = useOutletContext<InscriptionSendState>();
return { ...context, recipient: get(location, 'state.recipient', '') as string };
}
interface SendInscriptionLoaderProps {
children(data: InscriptionSendState): JSX.Element;
}
function SendInscriptionLoader({ children }: SendInscriptionLoaderProps) {
const { inscription } = useSendOrdinalInscriptionRouteState();
const { data: fees } = useBitcoinFeeRate();
if (!fees) return null;
if (!inscription) return <Navigate to={RouteUrls.Home} />;
return children({ inscription, fees });
}
export function SendInscription() {
return (
<SendInscriptionLoader>
{({ fees, inscription }) => <Outlet context={{ fees, inscription }} />}
</SendInscriptionLoader>
);
}

View File

@@ -1,84 +1,37 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Box, Button, Flex } from '@stacks/ui';
import { Form, Formik } from 'formik';
import { OrdinalSendFormValues } from '@shared/models/form.model';
import { RouteUrls } from '@shared/route-urls';
import { useAnalytics } from '@app/common/hooks/analytics/use-analytics';
import { BaseDrawer } from '@app/components/drawer/base-drawer';
import { ErrorLabel } from '@app/components/error-label';
import { OrdinalIcon } from '@app/components/icons/ordinal-icon';
import { InscriptionPreview } from '@app/components/inscription-preview-card/components/inscription-preview';
import { getNumberOfInscriptionOnUtxo } from '@app/query/bitcoin/ordinals/ordinals-aware-utxo.query';
import { InscriptionPreviewCard } from '@app/components/inscription-preview-card/inscription-preview-card';
import { InscriptionPreviewCard } from '../../../components/inscription-preview-card/inscription-preview-card';
import { RecipientField } from '../send-crypto-asset-form/components/recipient-field';
import { CollectibleAsset } from './components/collectible-asset';
import { useInscriptionSendState } from './send-inscription-container';
import { useGenerateSignedOrdinalTx } from './use-generate-ordinal-tx';
import { useOrdinalInscriptionFormValidationSchema } from './use-ordinal-inscription-form-validation-schema';
import { createUtxoFromInscription } from './utils';
import { useInscriptionSendState } from './components/send-inscription-loader';
import { useSendInscriptionForm } from './hooks/use-send-inscription-form';
export const recipeintFieldName = 'recipient';
export function SendInscriptionForm() {
const [currentError, setShowError] = useState<null | string>(null);
const navigate = useNavigate();
const { inscription, recipient } = useInscriptionSendState();
const validationSchema = useOrdinalInscriptionFormValidationSchema();
const [isLoading, setIsLoading] = useState(false);
const analytics = useAnalytics();
const utxo = createUtxoFromInscription(inscription);
const { coverFeeFromAdditionalUtxos } = useGenerateSignedOrdinalTx(utxo);
async function reviewTransaction(values: OrdinalSendFormValues) {
const resp = coverFeeFromAdditionalUtxos(values);
if (!resp) {
setShowError(
'Insufficient funds to cover fee. Deposit some BTC to your Native Segwit address.'
);
return;
}
if (Number(inscription.offset) !== 0) {
setShowError('Sending inscriptions at non-zero offsets is unsupported');
return;
}
try {
const numInscriptionsOnUtxo = await getNumberOfInscriptionOnUtxo(utxo.txid, utxo.vout);
if (numInscriptionsOnUtxo > 1) {
setShowError('Sending inscription from utxo with multiple inscriptions is unsupported');
return;
}
} catch (error) {
void analytics.track('ordinals_dot_com_unavailable', { error });
setShowError('Unable to establish if utxo has multiple inscriptions.');
return;
}
const { hex } = resp;
return navigate(RouteUrls.SendOrdinalInscriptionReview, {
state: { fee: resp.fee, inscription, recipient: values.recipient, tx: hex },
});
}
const { feeRates, inscription, recipient } = useInscriptionSendState();
const { chooseTransactionFee, currentError, validationSchema } = useSendInscriptionForm();
return (
<Formik
validationSchema={validationSchema}
initialValues={{ [recipeintFieldName]: recipient, inscription }}
onSubmit={async values => {
try {
setIsLoading(true);
await reviewTransaction(values);
} finally {
setIsLoading(false);
}
initialValues={{
[recipeintFieldName]: recipient,
inscription,
feeRate: feeRates.fastestFee.toNumber(),
}}
onSubmit={chooseTransactionFee}
>
<Form>
<BaseDrawer title="Send" enableGoBack isShowing onClose={() => navigate(RouteUrls.Home)}>
@@ -104,13 +57,12 @@ export function SendInscriptionForm() {
</ErrorLabel>
)}
<Button
borderRadius="10px"
height="48px"
mb="extra-loose"
mt="tight"
type="submit"
borderRadius="10px"
height="48px"
width="100%"
isLoading={isLoading}
>
Continue
</Button>

View File

@@ -14,15 +14,15 @@ import { InfoCard, InfoCardRow, InfoCardSeparator } from '@app/components/info-c
import { InscriptionPreview } from '@app/components/inscription-preview-card/components/inscription-preview';
import { PrimaryButton } from '@app/components/primary-button';
import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/address.hooks';
import { btcTxTimeMap } from '@app/query/bitcoin/bitcoin-client';
import { InscriptionPreviewCard } from '../../../components/inscription-preview-card/inscription-preview-card';
import { useBitcoinBroadcastTransaction } from '../../../query/bitcoin/transaction/use-bitcoin-broadcast-transaction';
import { useInscriptionSendState } from './send-inscription-container';
import { useInscriptionSendState } from './components/send-inscription-loader';
function useSendInscriptionReviewState() {
const location = useLocation();
return {
arrivesIn: get(location.state, 'time') as string,
signedTx: get(location.state, 'tx') as string,
recipient: get(location.state, 'recipient', '') as string,
fee: get(location.state, 'fee') as number,
@@ -33,13 +33,12 @@ export function SendInscriptionReview() {
const analytics = useAnalytics();
const navigate = useNavigate();
const { signedTx, recipient, fee } = useSendInscriptionReviewState();
const { arrivesIn, signedTx, recipient, fee } = useSendInscriptionReviewState();
const { inscription } = useInscriptionSendState();
const { refetch } = useCurrentNativeSegwitUtxos();
const { broadcastTx, isBroadcasting } = useBitcoinBroadcastTransaction();
const arrivesIn = btcTxTimeMap.hourFee;
const summaryFee = formatMoney(createMoney(Number(fee), 'BTC'));
async function sendInscription() {

View File

@@ -47,15 +47,16 @@ export function SendInscriptionSummary() {
const { handleOpenTxLink } = useExplorerLink();
const analytics = useAnalytics();
const onClickLink = () => {
function onClickLink() {
void analytics.track('view_transaction_confirmation', { symbol: 'BTC' });
handleOpenTxLink(txLink);
};
}
const onClickCopy = () => {
function onClickCopy() {
onCopy();
toast.success('ID copied!');
};
}
return (
<BaseDrawer title="Sent" isShowing onClose={() => navigate(RouteUrls.Home)}>
<Box px="extra-loose" mt="extra-loose">

View File

@@ -1,23 +0,0 @@
import * as yup from 'yup';
import { FormErrorMessages } from '@app/common/error-messages';
import {
btcAddressNetworkValidator,
btcAddressValidator,
btcTaprootAddressValidator,
} from '@app/common/validation/forms/address-validators';
import { useCurrentNetwork } from '@app/store/networks/networks.selectors';
import { recipeintFieldName } from './send-inscription-form';
export function useOrdinalInscriptionFormValidationSchema() {
const currentNetwork = useCurrentNetwork();
return yup.object({
[recipeintFieldName]: yup
.string()
.required(FormErrorMessages.AddressRequired)
.concat(btcAddressValidator())
.concat(btcAddressNetworkValidator(currentNetwork.chain.bitcoin.network))
.concat(btcTaprootAddressValidator()),
});
}

View File

@@ -9,18 +9,18 @@ import { satToBtc } from '@app/common/money/unit-conversion';
import { BtcSizeFeeEstimator } from '@app/common/transactions/bitcoin/fees/btc-size-fee-estimator';
import { useGetUtxosByAddressQuery } from '@app/query/bitcoin/address/utxos-by-address.query';
import { useCurrentNativeSegwitAddressBalance } from '@app/query/bitcoin/balance/bitcoin-balances.query';
import { useBitcoinFeeRate } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useAverageBitcoinFeeRates } from '@app/query/bitcoin/fees/fee-estimates.hooks';
import { useCurrentBtcNativeSegwitAccountAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks';
export function useCalculateMaxBitcoinSpend() {
const currentAccountBtcAddress = useCurrentBtcNativeSegwitAccountAddressIndexZero();
const balance = useCurrentNativeSegwitAddressBalance();
const { data: utxos } = useGetUtxosByAddressQuery(currentAccountBtcAddress);
const { data: feeRate } = useBitcoinFeeRate();
const { avgApiFeeRates: feeRates } = useAverageBitcoinFeeRates();
return useCallback(
(address = '') => {
if (!utxos || !feeRate)
if (!utxos || !feeRates)
return {
spendAllFee: 0,
amount: createMoney(0, 'BTC'),
@@ -35,7 +35,7 @@ export function useCalculateMaxBitcoinSpend() {
input_count: utxos.length,
[`${addressTypeWithFallback}_output_count`]: 2,
});
const fee = Math.ceil(size.txVBytes * feeRate.fastestFee);
const fee = Math.ceil(size.txVBytes * feeRates.fastestFee.toNumber());
const spendableAmount = BigNumber.max(0, balance.amount.minus(fee));
@@ -45,6 +45,6 @@ export function useCalculateMaxBitcoinSpend() {
spendableBitcoin: satToBtc(spendableAmount),
};
},
[balance.amount, feeRate, utxos]
[balance.amount, feeRates, utxos]
);
}

View File

@@ -13,6 +13,7 @@ import { useGenerateSignedBitcoinTx } from '@app/common/transactions/bitcoin/use
import { useWalletType } from '@app/common/use-wallet-type';
import { BitcoinFeesList } from '@app/components/bitcoin-fees-list/bitcoin-fees-list';
import { BitcoinFeesListLayout } from '@app/components/bitcoin-fees-list/components/bitcoin-fees-list.layout';
import { useBitcoinFeesList } from '@app/components/bitcoin-fees-list/use-bitcoin-fees-list';
import { ModalHeader } from '@app/components/modal-header';
import { useSendFormNavigate } from '../../hooks/use-send-form-navigate';
@@ -29,6 +30,10 @@ export function BtcChooseFee() {
const { whenWallet } = useWalletType();
const sendFormNavigate = useSendFormNavigate();
const generateTx = useGenerateSignedBitcoinTx();
const { feesList, isLoading } = useBitcoinFeesList({
amount: Number(txValues.amount),
recipient: txValues.recipient,
});
async function previewTransaction(feeRate: number, feeValue: number, time: string) {
const resp = generateTx(
@@ -59,11 +64,7 @@ export function BtcChooseFee() {
return (
<BitcoinFeesListLayout>
<BitcoinFeesList
amount={Number(txValues.amount)}
onChooseFee={previewTransaction}
recipient={txValues.recipient}
/>
<BitcoinFeesList feesList={feesList} isLoading={isLoading} onChooseFee={previewTransaction} />
</BitcoinFeesListLayout>
);
}

View File

@@ -39,14 +39,15 @@ export function BtcSentSummary() {
const { handleOpenTxLink } = useExplorerLink();
const analytics = useAnalytics();
const onClickLink = () => {
function onClickLink() {
void analytics.track('view_transaction_confirmation', { symbol: 'BTC' });
handleOpenTxLink(txLink);
};
const onClickCopy = () => {
}
function onClickCopy() {
onCopy();
toast.success('ID copied!');
};
}
useRouteHeader(<ModalHeader hideActions defaultClose title="Sent" />);

View File

@@ -39,14 +39,15 @@ export function StxSentSummary() {
const { handleOpenTxLink } = useExplorerLink();
const analytics = useAnalytics();
const onClickLink = () => {
function onClickLink() {
void analytics.track('view_transaction_confirmation', { symbol: 'STX' });
handleOpenTxLink(txLink);
};
const onClickCopy = () => {
}
function onClickCopy() {
onCopy();
toast.success('ID copied!');
};
}
useRouteHeader(<ModalHeader hideActions defaultClose title="Sent" />);

View File

@@ -42,13 +42,14 @@ interface FeeEstimateEarnApiResponse {
halfHourFee: number;
hourFee: number;
}
export interface FeeEstimateMempoolSpaceApi {
interface FeeEstimateMempoolSpaceApiResponse {
fastestFee: number;
halfHourFee: number;
hourFee: number;
economyFee: number;
minimumFee: number;
}
class FeeEstimatesApi {
constructor(public configuration: Configuration) {}
@@ -59,7 +60,7 @@ class FeeEstimatesApi {
});
}
async getFeeEstimatesFromMempoolSpaceApi(): Promise<FeeEstimateMempoolSpaceApi> {
async getFeeEstimatesFromMempoolSpaceApi(): Promise<FeeEstimateMempoolSpaceApiResponse> {
return fetchData({
errorMsg: 'No fee estimates fetched',
url: ` https://mempool.space/api/v1/fees/recommended`,
@@ -85,20 +86,6 @@ class TransactionsApi {
}
}
export const btcTxTimeMap: Record<keyof FeeEstimateMempoolSpaceApi, string> = {
fastestFee: '~10 20min',
halfHourFee: '~30 min',
economyFee: '~1 hour+',
hourFee: '~1 hour+',
minimumFee: '~1 hour+',
};
export enum BtcFeeType {
High = 'High',
Standard = 'Standard',
Low = 'Low',
}
export class BitcoinClient {
configuration: Configuration;
addressApi: AddressApi;

View File

@@ -1,24 +1,16 @@
import BigNumber from 'bignumber.js';
import { logger } from '@shared/logger';
import { AverageBitcoinFeeRates } from '@shared/models/fees/bitcoin-fees.model';
import { calculateMeanAverage } from '@app/common/math/calculate-averages';
import {
useGetBitcoinAllFeeEstimatesQuery,
useGetBitcoinMempoolApiFeeEstimatesQuery,
} from './fee-estimates.query';
import { useGetAllBitcoinFeeEstimatesQuery } from './fee-estimates.query';
export function useBitcoinFeeRate() {
return useGetBitcoinMempoolApiFeeEstimatesQuery({
onError: err => logger.error('Error getting bitcoin fee estimates', { err }),
});
}
export function useAverageBitcoinFeeRate() {
const { data: avgApiFeeRates, isLoading } = useGetBitcoinAllFeeEstimatesQuery({
export function useAverageBitcoinFeeRates() {
const { data: avgApiFeeRates, isLoading } = useGetAllBitcoinFeeEstimatesQuery({
onError: err => logger.error('Error getting all apis bitcoin fee estimates', { err }),
select: resp => {
select: (resp): AverageBitcoinFeeRates | null => {
if (resp[0].status === 'rejected' && resp[1].status === 'rejected') {
return null;
}

View File

@@ -5,34 +5,6 @@ import { useBitcoinClient } from '@app/store/common/api-clients.hooks';
import { BitcoinClient } from '../bitcoin-client';
// Mempool api
function fetchMempoolApiBitcoinFeeEstimates(client: BitcoinClient) {
return async () => {
return client.feeEstimatesApi.getFeeEstimatesFromMempoolSpaceApi();
};
}
type FetchMempoolApiBitcoinFeeEstimatesResp = Awaited<
ReturnType<ReturnType<typeof fetchMempoolApiBitcoinFeeEstimates>>
>;
// https://github.com/bitcoinbook/bitcoinbook/blob/develop/ch06.asciidoc#transaction-fees
// Possible alt api if needed: https://bitcoinfees.earn.com/api
export function useGetBitcoinMempoolApiFeeEstimatesQuery<
T extends unknown = FetchMempoolApiBitcoinFeeEstimatesResp
>(options?: AppUseQueryConfig<FetchMempoolApiBitcoinFeeEstimatesResp, T>) {
const client = useBitcoinClient();
return useQuery({
queryKey: ['mempool-api-bitcoin-fee-estimates'],
queryFn: fetchMempoolApiBitcoinFeeEstimates(client),
staleTime: 1000 * 60,
refetchOnWindowFocus: false,
refetchOnMount: false,
...options,
});
}
// Earn api
function fetchAllBitcoinFeeEstimates(client: BitcoinClient) {
return async () => {
return Promise.allSettled([
@@ -46,7 +18,7 @@ type FetchAllBitcoinFeeEstimatesResp = Awaited<
ReturnType<ReturnType<typeof fetchAllBitcoinFeeEstimates>>
>;
export function useGetBitcoinAllFeeEstimatesQuery<
export function useGetAllBitcoinFeeEstimatesQuery<
T extends unknown = FetchAllBitcoinFeeEstimatesResp
>(options?: AppUseQueryConfig<FetchAllBitcoinFeeEstimatesResp, T>) {
const client = useBitcoinClient();

View File

@@ -39,8 +39,8 @@ import { RpcGetAddresses } from '@app/pages/rpc-get-addresses/rpc-get-addresses'
import { rpcSendTransferRoutes } from '@app/pages/rpc-send-transfer/rpc-send-transfer.routes';
import { SelectNetwork } from '@app/pages/select-network/select-network';
import { BroadcastError } from '@app/pages/send/broadcast-error/broadcast-error';
import { SendInscriptionContainer } from '@app/pages/send/ordinal-inscription/components/send-inscription-container';
import { SendInscriptionChooseFee } from '@app/pages/send/ordinal-inscription/send-inscription-choose-fee';
import { SendInscription } from '@app/pages/send/ordinal-inscription/send-inscription-container';
import { SendInscriptionForm } from '@app/pages/send/ordinal-inscription/send-inscription-form';
import { SendInscriptionReview } from '@app/pages/send/ordinal-inscription/send-inscription-review';
import { SendInscriptionSummary } from '@app/pages/send/ordinal-inscription/sent-inscription-summary';
@@ -118,16 +118,16 @@ function useAppRoutes() {
<Route path={RouteUrls.ReceiveStx} element={<ReceiveStxModal />} />
<Route path={RouteUrls.ReceiveBtc} element={<ReceiveBtcModal />} />
<Route path={RouteUrls.SendOrdinalInscription} element={<SendInscription />}>
<Route path={RouteUrls.SendOrdinalInscription} element={<SendInscriptionContainer />}>
<Route index element={<SendInscriptionForm />} />
<Route
path={RouteUrls.SendOrdinalInscriptionReview}
element={<SendInscriptionReview />}
/>
<Route
path={RouteUrls.SendOrdinalInscriptionChooseFee}
element={<SendInscriptionChooseFee />}
/>
<Route
path={RouteUrls.SendOrdinalInscriptionReview}
element={<SendInscriptionReview />}
/>
<Route
path={RouteUrls.SendOrdinalInscriptionSent}
element={<SendInscriptionSummary />}

View File

@@ -1,7 +1,19 @@
// Source: https://github.com/Blockstream/esplora/blob/master/API.md#fee-estimates
import { Money } from '../money.model';
import BigNumber from 'bignumber.js';
export interface BitcoinFeeEstimate {
feeRate: number;
fee: Money;
export interface AverageBitcoinFeeRates {
fastestFee: BigNumber;
halfHourFee: BigNumber;
hourFee: BigNumber;
}
export const btcTxTimeMap: Record<keyof AverageBitcoinFeeRates, string> = {
fastestFee: '~10 20min',
halfHourFee: '~30 min',
hourFee: '~1 hour+',
};
export enum BtcFeeType {
High = 'High',
Standard = 'Standard',
Low = 'Low',
}

View File

@@ -1,5 +1,4 @@
import { Blockchains } from '../blockchain.model';
import { BitcoinFeeEstimate } from './bitcoin-fees.model';
import { StacksFeeEstimate } from './stacks-fees.model';
export enum FeeTypes {
@@ -19,6 +18,6 @@ export enum FeeCalculationTypes {
export interface Fees {
blockchain: Blockchains;
estimates: BitcoinFeeEstimate[] | StacksFeeEstimate[];
estimates: StacksFeeEstimate[];
calculation: FeeCalculationTypes;
}

View File

@@ -10,6 +10,7 @@ export interface BitcoinSendFormValues {
}
export interface OrdinalSendFormValues {
feeRate: number;
recipient: string;
}

View File

@@ -3,7 +3,7 @@ import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors';
import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors';
import { getDisplayerAddress } from '@tests/utils';
import { BtcFeeType } from '@app/query/bitcoin/bitcoin-client';
import { BtcFeeType } from '@shared/models/fees/bitcoin-fees.model';
import { test } from '../../fixtures/fixtures';