From d5a30ddf883bec6c424ebd0fef59c55e02b92822 Mon Sep 17 00:00:00 2001 From: kyranjamie Date: Tue, 17 Dec 2024 23:12:28 +0100 Subject: [PATCH] feat: granular utxo protection feature --- .../hooks/balance/use-total-balance.tsx | 42 +++- .../common/hooks/use-hover-with-children.ts | 32 +++ src/app/components/account-total-balance.tsx | 4 +- src/app/debug.ts | 6 + .../bitcoin/high-sat-value-utxo.tsx | 23 ++ .../components/bitcoin/inscription-text.tsx | 2 +- .../components/bitcoin/inscription.tsx | 208 ++++++++++++------ .../components/bitcoin/ordinals.tsx | 2 +- .../components/collectible-item.layout.tsx | 5 +- .../use-inscribed-spendable-utxos.ts | 49 +++++ src/app/pages/home/home.tsx | 14 +- .../form/btc/btc-send-form-confirmation.tsx | 4 + .../bitcoin/address/utxos-by-address.hooks.ts | 16 +- .../btc-balance-native-segwit.hooks.ts | 65 +++--- .../inscriptions/inscriptions.query.ts | 32 ++- .../blockchain/bitcoin/bitcoin.hooks.ts | 19 +- src/app/store/settings/settings.selectors.ts | 35 ++- src/app/store/settings/settings.slice.ts | 18 ++ .../account/account.card.stories.tsx | 12 +- .../ui/components/account/account.card.tsx | 40 +++- tests/page-object-models/onboarding.page.ts | 1 + tests/specs/ledger/ledger.spec.ts | 5 +- 22 files changed, 489 insertions(+), 145 deletions(-) create mode 100644 src/app/common/hooks/use-hover-with-children.ts create mode 100644 src/app/features/collectibles/components/bitcoin/high-sat-value-utxo.tsx create mode 100644 src/app/features/discarded-inscriptions/use-inscribed-spendable-utxos.ts diff --git a/src/app/common/hooks/balance/use-total-balance.tsx b/src/app/common/hooks/balance/use-total-balance.tsx index 7129f0846a7..b94743e6b4a 100644 --- a/src/app/common/hooks/balance/use-total-balance.tsx +++ b/src/app/common/hooks/balance/use-total-balance.tsx @@ -10,11 +10,11 @@ import { useBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance import { useSip10ManagedTokensBalance } from './use-sip10-balance'; -interface UseTotalBalanceArgs { +interface UseAvailableBalanceArgs { btcAddress: string; stxAddress: string; } -export function useTotalBalance({ btcAddress, stxAddress }: UseTotalBalanceArgs) { +export function useAvailableBalance({ btcAddress, stxAddress }: UseAvailableBalanceArgs) { // get market data const btcMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); const stxMarketData = useCryptoCurrencyMarketDataMeanAverage('STX'); @@ -46,12 +46,24 @@ export function useTotalBalance({ btcAddress, stxAddress }: UseTotalBalanceArgs) return useMemo(() => { // calculate total balance const stxUsdAmount = baseCurrencyAmountInQuote(stxBalance, stxMarketData); - const btcUsdAmount = baseCurrencyAmountInQuote(btcBalance.availableBalance, btcMarketData); + + const availableBtcUsdAmount = baseCurrencyAmountInQuote( + btcBalance.availableBalance, + btcMarketData + ); + + const totalBtcUsdAmount = baseCurrencyAmountInQuote(btcBalance.totalBalance, btcMarketData); const totalBalance = { ...stxUsdAmount, - amount: stxUsdAmount.amount.plus(btcUsdAmount.amount).plus(sip10BalanceUsd.amount), + amount: stxUsdAmount.amount.plus(totalBtcUsdAmount.amount).plus(sip10BalanceUsd.amount), + }; + + const availableBalance = { + ...stxUsdAmount, + amount: stxUsdAmount.amount.plus(availableBtcUsdAmount.amount).plus(sip10BalanceUsd.amount), }; + return { isFetching: isFetchingStxBalance || isFetchingBtcBalance, isLoading: isLoadingStxBalance || isLoadingBtcBalance, @@ -59,6 +71,11 @@ export function useTotalBalance({ btcAddress, stxAddress }: UseTotalBalanceArgs) (isPendingStxBalance && Boolean(stxAddress)) || (isPendingBtcBalance && Boolean(btcAddress)), totalBalance, + availableBalance, + availableUsdBalance: i18nFormatCurrency( + availableBalance, + availableBalance.amount.isGreaterThanOrEqualTo(100_000) ? 0 : 2 + ), totalUsdBalance: i18nFormatCurrency( totalBalance, totalBalance.amount.isGreaterThanOrEqualTo(100_000) ? 0 : 2 @@ -67,20 +84,21 @@ export function useTotalBalance({ btcAddress, stxAddress }: UseTotalBalanceArgs) isLoadingAdditionalDataStxBalance || isLoadingAdditionalDataBtcBalance, }; }, [ + stxBalance, + stxMarketData, btcBalance.availableBalance, + btcBalance.totalBalance, btcMarketData, - isFetchingBtcBalance, + sip10BalanceUsd.amount, isFetchingStxBalance, - isLoadingBtcBalance, + isFetchingBtcBalance, isLoadingStxBalance, - isPendingBtcBalance, + isLoadingBtcBalance, isPendingStxBalance, - stxBalance, - stxMarketData, - isLoadingAdditionalDataBtcBalance, - isLoadingAdditionalDataStxBalance, stxAddress, + isPendingBtcBalance, btcAddress, - sip10BalanceUsd, + isLoadingAdditionalDataStxBalance, + isLoadingAdditionalDataBtcBalance, ]); } diff --git a/src/app/common/hooks/use-hover-with-children.ts b/src/app/common/hooks/use-hover-with-children.ts new file mode 100644 index 00000000000..1ef522dd73d --- /dev/null +++ b/src/app/common/hooks/use-hover-with-children.ts @@ -0,0 +1,32 @@ +import { useCallback, useState } from 'react'; + +interface HoverBind { + onMouseEnter(event: React.MouseEvent): void; + onMouseLeave(event: React.MouseEvent): void; +} + +export const useHoverWithChildren = (): [boolean, HoverBind] => { + const [isHovered, setIsHovered] = useState(false); + + const handleMouseEnter = useCallback(() => { + setIsHovered(true); + }, []); + + const handleMouseLeave = useCallback((event: React.MouseEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement; + + // If the related target is a child of the current element, don't trigger mouseleave + if (event.currentTarget.contains(relatedTarget)) { + return; + } + + setIsHovered(false); + }, []); + + const bind: HoverBind = { + onMouseEnter: handleMouseEnter, + onMouseLeave: handleMouseLeave, + }; + + return [isHovered, bind]; +}; diff --git a/src/app/components/account-total-balance.tsx b/src/app/components/account-total-balance.tsx index e7e4657251d..63d4e064bf7 100644 --- a/src/app/components/account-total-balance.tsx +++ b/src/app/components/account-total-balance.tsx @@ -4,7 +4,7 @@ import { styled } from 'leather-styles/jsx'; import { SkeletonLoader, shimmerStyles } from '@leather.io/ui'; -import { useTotalBalance } from '@app/common/hooks/balance/use-total-balance'; +import { useAvailableBalance } from '@app/common/hooks/balance/use-total-balance'; import { PrivateText } from '@app/components/privacy/private-text'; interface AccountTotalBalanceProps { @@ -13,7 +13,7 @@ interface AccountTotalBalanceProps { } export const AccountTotalBalance = memo(({ btcAddress, stxAddress }: AccountTotalBalanceProps) => { - const { totalUsdBalance, isFetching, isLoading, isLoadingAdditionalData } = useTotalBalance({ + const { totalUsdBalance, isFetching, isLoading, isLoadingAdditionalData } = useAvailableBalance({ btcAddress, stxAddress, }); diff --git a/src/app/debug.ts b/src/app/debug.ts index d49cc104cef..d0650a24b28 100644 --- a/src/app/debug.ts +++ b/src/app/debug.ts @@ -57,6 +57,12 @@ const debug = { chrome.storage.local.clear(); chrome.storage.session.clear(); }, + bypassInscriptionChecks() { + store.dispatch(settingsSlice.actions.dangerouslyChosenToBypassAllInscriptionChecks()); + }, + resetInscriptionState() { + store.dispatch(settingsSlice.actions.resetInscriptionState()); + }, }; export function setDebugOnGlobal() { diff --git a/src/app/features/collectibles/components/bitcoin/high-sat-value-utxo.tsx b/src/app/features/collectibles/components/bitcoin/high-sat-value-utxo.tsx new file mode 100644 index 00000000000..b600e2cedbb --- /dev/null +++ b/src/app/features/collectibles/components/bitcoin/high-sat-value-utxo.tsx @@ -0,0 +1,23 @@ +import { Box, Circle } from 'leather-styles/jsx'; + +import type { Inscription } from '@leather.io/models'; + +import { BasicTooltip } from '@app/ui/components/tooltip/basic-tooltip'; + +const featureBuilt = false; + +interface HighSatValueUtxoProps { + inscription: Inscription; +} + +export function HighSatValueUtxoWarning({ inscription }: HighSatValueUtxoProps) { + if (Number(inscription.value) < 5_000) return null; + if (!featureBuilt) return null; + return ( + + + + + + ); +} diff --git a/src/app/features/collectibles/components/bitcoin/inscription-text.tsx b/src/app/features/collectibles/components/bitcoin/inscription-text.tsx index 9a58cfe8488..f064399ad19 100644 --- a/src/app/features/collectibles/components/bitcoin/inscription-text.tsx +++ b/src/app/features/collectibles/components/bitcoin/inscription-text.tsx @@ -10,7 +10,7 @@ import { CollectibleText } from '../_collectible-types/collectible-text'; interface InscriptionTextProps { contentSrc: string; inscriptionNumber: number; - onClickCallToAction(): void; + onClickCallToAction?(): void; onClickSend(): void; } export function InscriptionText({ diff --git a/src/app/features/collectibles/components/bitcoin/inscription.tsx b/src/app/features/collectibles/components/bitcoin/inscription.tsx index bdb1a54d40a..7ecf231532d 100644 --- a/src/app/features/collectibles/components/bitcoin/inscription.tsx +++ b/src/app/features/collectibles/components/bitcoin/inscription.tsx @@ -1,17 +1,33 @@ +import { useCallback, useMemo } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { Box } from 'leather-styles/jsx'; + import { type Inscription } from '@leather.io/models'; -import { OrdinalAvatarIcon } from '@leather.io/ui'; +import { + DropdownMenu, + EllipsisVIcon, + ExternalLinkIcon, + Flag, + IconButton, + LockIcon, + OrdinalAvatarIcon, + TrashIcon, + UnlockIcon, +} from '@leather.io/ui'; import { ORD_IO_URL } from '@shared/constants'; import { RouteUrls } from '@shared/route-urls'; +import { useHoverWithChildren } from '@app/common/hooks/use-hover-with-children'; import { openInNewTab } from '@app/common/utils/open-in-new-tab'; +import { useDiscardedInscriptions } from '@app/store/settings/settings.selectors'; import { CollectibleAudio } from '../_collectible-types/collectible-audio'; import { CollectibleIframe } from '../_collectible-types/collectible-iframe'; import { CollectibleImage } from '../_collectible-types/collectible-image'; import { CollectibleOther } from '../_collectible-types/collectible-other'; +import { HighSatValueUtxoWarning } from './high-sat-value-utxo'; import { InscriptionText } from './inscription-text'; interface InscriptionProps { @@ -25,74 +41,136 @@ function openInscriptionUrl(num: number) { export function Inscription({ inscription }: InscriptionProps) { const navigate = useNavigate(); const location = useLocation(); + const [isHovered, bind] = useHoverWithChildren(); + const { hasInscriptionBeenDiscarded, discardInscription, recoverInscription } = + useDiscardedInscriptions(); - function openSendInscriptionModal() { + const openSendInscriptionModal = useCallback(() => { navigate(RouteUrls.SendOrdinalInscription, { state: { inscription, backgroundLocation: location }, }); - } + }, [navigate, inscription, location]); + + const content = useMemo(() => { + const sharedProps = { onClickSend: () => openSendInscriptionModal() }; + switch (inscription.mimeType) { + case 'audio': + return ( + } + key={inscription.title} + subtitle="Ordinal inscription" + title={`# ${inscription.number}`} + {...sharedProps} + /> + ); + case 'html': + case 'svg': + case 'video': + case 'gltf': + return ( + } + key={inscription.title} + src={inscription.src} + subtitle="Ordinal inscription" + title={`# ${inscription.number}`} + {...sharedProps} + /> + ); + case 'image': + return ( + } + key={inscription.title} + src={inscription.src} + subtitle="Ordinal inscription" + title={`# ${inscription.number}`} + {...sharedProps} + /> + ); + case 'text': + return ( + + ); + case 'other': + return ( + + + + ); + default: + return null; + } + }, [ + inscription.mimeType, + inscription.number, + inscription.src, + inscription.title, + openSendInscriptionModal, + ]); + + return ( + + {content} + {isHovered && ( + + + + } + /> + + + openInscriptionUrl(inscription.number)}> + }>Open original + + {hasInscriptionBeenDiscarded(inscription) ? ( + recoverInscription(inscription)}> + }>Protect + + ) : ( + discardInscription(inscription)}> + }>Unprotect + + )} + + + + )} + + - switch (inscription.mimeType) { - case 'audio': - return ( - } - key={inscription.title} - onClickCallToAction={() => openInscriptionUrl(inscription.number)} - onClickSend={() => openSendInscriptionModal()} - subtitle="Ordinal inscription" - title={`# ${inscription.number}`} - /> - ); - case 'html': - case 'svg': - case 'video': - case 'gltf': - return ( - } - key={inscription.title} - onClickCallToAction={() => openInscriptionUrl(inscription.number)} - onClickSend={() => openSendInscriptionModal()} - src={inscription.src} - subtitle="Ordinal inscription" - title={`# ${inscription.number}`} - /> - ); - case 'image': - return ( - } - key={inscription.title} - onClickCallToAction={() => openInscriptionUrl(inscription.number)} - onClickSend={() => openSendInscriptionModal()} - src={inscription.src} - subtitle="Ordinal inscription" - title={`# ${inscription.number}`} - /> - ); - case 'text': - return ( - openInscriptionUrl(inscription.number)} - onClickSend={() => openSendInscriptionModal()} - /> - ); - case 'other': - return ( - openInscriptionUrl(inscription.number)} - onClickSend={() => openSendInscriptionModal()} - subtitle="Ordinal inscription" - title={`# ${inscription.number}`} + {hasInscriptionBeenDiscarded(inscription) && ( + - - - ); - default: - return null; - } + }> + Unprotected + + + )} + + ); } diff --git a/src/app/features/collectibles/components/bitcoin/ordinals.tsx b/src/app/features/collectibles/components/bitcoin/ordinals.tsx index dd176f16233..bbb0db4a91b 100644 --- a/src/app/features/collectibles/components/bitcoin/ordinals.tsx +++ b/src/app/features/collectibles/components/bitcoin/ordinals.tsx @@ -18,8 +18,8 @@ export function Ordinals() { void analytics.track('view_collectibles', { ordinals_count: inscriptionsLength, }); - void analytics.identify({ ordinals_count: inscriptionsLength }); } + void analytics.identify({ ordinals_count: inscriptionsLength }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [results.inscriptions?.length]); diff --git a/src/app/features/collectibles/components/collectible-item.layout.tsx b/src/app/features/collectibles/components/collectible-item.layout.tsx index 92b29c9a0d0..243865ecc07 100644 --- a/src/app/features/collectibles/components/collectible-item.layout.tsx +++ b/src/app/features/collectibles/components/collectible-item.layout.tsx @@ -4,7 +4,8 @@ import { useInView } from 'react-intersection-observer'; import { SendCryptoAssetSelectors } from '@tests/selectors/send.selectors'; import { Box, Stack, styled } from 'leather-styles/jsx'; import { token } from 'leather-styles/tokens'; -import { useHover } from 'use-events'; + +import { useHoverWithChildren } from '@app/common/hooks/use-hover-with-children'; import { CollectibleHover } from './collectible-hover'; @@ -30,7 +31,7 @@ export function CollectibleItemLayout({ title, ...rest }: CollectibleItemLayoutProps) { - const [isHovered, bind] = useHover(); + const [isHovered, bind] = useHoverWithChildren(); const { ref, inView } = useInView({ triggerOnce: true }); diff --git a/src/app/features/discarded-inscriptions/use-inscribed-spendable-utxos.ts b/src/app/features/discarded-inscriptions/use-inscribed-spendable-utxos.ts new file mode 100644 index 00000000000..5b35177d5e5 --- /dev/null +++ b/src/app/features/discarded-inscriptions/use-inscribed-spendable-utxos.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; + +import { useNativeSegwitUtxosByAddress } from '@leather.io/query'; + +import { useCurrentNativeSegwitInscriptions } from '@app/query/bitcoin/ordinals/inscriptions/inscriptions.query'; +import { useCurrentAccountNativeSegwitIndexZeroSignerNullable } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { useDiscardedInscriptions } from '@app/store/settings/settings.selectors'; + +export function useInscribedSpendableUtxos() { + const { hasInscriptionBeenDiscarded } = useDiscardedInscriptions(); + + const { data: nativeSegwitInscriptions } = useCurrentNativeSegwitInscriptions(); + + const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSignerNullable(); + const address = nativeSegwitSigner?.address; + + // Utxos but don't filter the inscribed ones + const { data: nativeSegwitUtxos } = useNativeSegwitUtxosByAddress({ + address: address ?? '', + filterInscriptionUtxos: false, + filterPendingTxsUtxos: true, + filterRunesUtxos: true, + }); + + return useMemo(() => { + if (!nativeSegwitUtxos || !nativeSegwitInscriptions) return []; + + // Preformatting utxos so that inscriptions are declared as an object + // property aids the following filter logic + const utxosFormatted = nativeSegwitUtxos.map(utxo => ({ + ...utxo, + inscriptions: nativeSegwitInscriptions.filter( + inscription => inscription.txid === utxo.txid && Number(inscription.output) === utxo.vout + ), + })); + + const utxosThatCanBeSpentBecauseAllUtxosInsideWereDiscarded = utxosFormatted + // If there are no inscriptions they're not being filtered and we don't care about them + .filter(utxo => utxo.inscriptions.length > 0) + // For a given utxo with inscriptions, check that all inscriptions in it + // have been discarded. This check ensures we don't spend a utxo if only + // one of potentially many have been discarded + .filter(utxo => + utxo.inscriptions.every(inscription => hasInscriptionBeenDiscarded(inscription)) + ); + + return utxosThatCanBeSpentBecauseAllUtxosInsideWereDiscarded; + }, [nativeSegwitUtxos, nativeSegwitInscriptions, hasInscriptionBeenDiscarded]); +} diff --git a/src/app/pages/home/home.tsx b/src/app/pages/home/home.tsx index 303ad935f1a..d9645944361 100644 --- a/src/app/pages/home/home.tsx +++ b/src/app/pages/home/home.tsx @@ -7,7 +7,7 @@ import { RouteUrls } from '@shared/route-urls'; import { useAccountDisplayName } from '@app/common/hooks/account/use-account-names'; import { useOnboardingState } from '@app/common/hooks/auth/use-onboarding-state'; -import { useTotalBalance } from '@app/common/hooks/balance/use-total-balance'; +import { useAvailableBalance } from '@app/common/hooks/balance/use-total-balance'; import { useOnMount } from '@app/common/hooks/use-on-mount'; import { useSwitchAccountSheet } from '@app/common/switch-account/use-switch-account-sheet-context'; import { whenPageMode } from '@app/common/utils'; @@ -45,10 +45,11 @@ export function Home() { }); const btcAddress = useCurrentAccountNativeSegwitAddressIndexZero(); - const { totalUsdBalance, isPending, isLoadingAdditionalData } = useTotalBalance({ - btcAddress, - stxAddress: account?.address || '', - }); + const { totalUsdBalance, availableUsdBalance, isPending, isLoadingAdditionalData } = + useAvailableBalance({ + btcAddress, + stxAddress: account?.address || '', + }); useOnMount(() => { if (decodedAuthRequest) navigate(RouteUrls.ChooseAccount); @@ -69,7 +70,8 @@ export function Home() { toggleSwitchAccount()} isFetchingBnsName={isFetchingBnsName} isLoadingBalance={isPending} diff --git a/src/app/pages/send/send-crypto-asset-form/form/btc/btc-send-form-confirmation.tsx b/src/app/pages/send/send-crypto-asset-form/form/btc/btc-send-form-confirmation.tsx index 073e6d8e6ce..8c0910c1029 100644 --- a/src/app/pages/send/send-crypto-asset-form/form/btc/btc-send-form-confirmation.tsx +++ b/src/app/pages/send/send-crypto-asset-form/form/btc/btc-send-form-confirmation.tsx @@ -36,6 +36,7 @@ import { } from '@app/components/info-card/info-card'; import { Card, Content, Page } from '@app/components/layout'; import { PageHeader } from '@app/features/container/headers/page.header'; +import { useInscribedSpendableUtxos } from '@app/features/discarded-inscriptions/use-inscribed-spendable-utxos'; import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; import { useSendFormNavigate } from '../../hooks/use-send-form-navigate'; @@ -81,8 +82,11 @@ export function BtcSendFormConfirmation() { const sendingValue = formatMoneyPadded(createMoneyFromDecimal(Number(transferAmount), symbol)); const summaryFee = formatMoneyPadded(createMoney(Number(fee), symbol)); + const utxosOfSpendableInscriptions = useInscribedSpendableUtxos(); + async function initiateTransaction() { await broadcastTx({ + skipSpendableCheckUtxoIds: utxosOfSpendableInscriptions.map(utxo => utxo.txid), tx: transaction.hex, async onSuccess(txid) { void analytics.track('broadcast_transaction', { diff --git a/src/app/query/bitcoin/address/utxos-by-address.hooks.ts b/src/app/query/bitcoin/address/utxos-by-address.hooks.ts index 9d1891d1c9b..5fec8310e0c 100644 --- a/src/app/query/bitcoin/address/utxos-by-address.hooks.ts +++ b/src/app/query/bitcoin/address/utxos-by-address.hooks.ts @@ -1,5 +1,6 @@ import { useNativeSegwitUtxosByAddress } from '@leather.io/query'; +import { useInscribedSpendableUtxos } from '@app/features/discarded-inscriptions/use-inscribed-spendable-utxos'; import { useCurrentAccountNativeSegwitIndexZeroSignerNullable } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; const defaultArgs = { @@ -12,16 +13,13 @@ const defaultArgs = { * Warning: ⚠️ To avoid spending inscriptions, when using UTXOs * we set `filterInscriptionUtxos` and `filterPendingTxsUtxos` to true */ -export function useCurrentNativeSegwitUtxos(args = defaultArgs) { - const { filterInscriptionUtxos, filterPendingTxsUtxos, filterRunesUtxos } = args; - +export function useCurrentNativeSegwitUtxos() { const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSignerNullable(); const address = nativeSegwitSigner?.address ?? ''; + const spendableUtxos = useInscribedSpendableUtxos(); + + const query = useNativeSegwitUtxosByAddress({ address, ...defaultArgs }); - return useNativeSegwitUtxosByAddress({ - address, - filterInscriptionUtxos, - filterPendingTxsUtxos, - filterRunesUtxos, - }); + const queryResponseData = query.data ?? []; + return { ...query, data: [...queryResponseData, ...spendableUtxos] }; } diff --git a/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts b/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts index 028f07083e8..805382a0e57 100644 --- a/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts +++ b/src/app/query/bitcoin/balance/btc-balance-native-segwit.hooks.ts @@ -2,46 +2,61 @@ import { useMemo } from 'react'; import BigNumber from 'bignumber.js'; -import type { BtcCryptoAssetBalance, Money } from '@leather.io/models'; import { useNativeSegwitUtxosByAddress, useRunesEnabled } from '@leather.io/query'; import { createMoney, isUndefined, sumNumbers } from '@leather.io/utils'; +import { useInscribedSpendableUtxos } from '@app/features/discarded-inscriptions/use-inscribed-spendable-utxos'; import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; -function createBtcCryptoAssetBalance(balance: Money): BtcCryptoAssetBalance { - return { - availableBalance: balance, - protectedBalance: createMoney(0, 'BTC'), - uneconomicalBalance: createMoney(0, 'BTC'), - pendingBalance: createMoney(0, 'BTC'), - totalBalance: balance, - inboundBalance: createMoney(0, 'BTC'), - outboundBalance: createMoney(0, 'BTC'), - }; -} +import { useFilterNativeSegwitInscriptions } from '../ordinals/inscriptions/inscriptions.query'; + +const defaultZeroValues = { + protectedBalance: createMoney(0, 'BTC'), + uneconomicalBalance: createMoney(0, 'BTC'), + inboundBalance: createMoney(0, 'BTC'), + outboundBalance: createMoney(0, 'BTC'), + pendingBalance: createMoney(0, 'BTC'), +}; export function useBtcCryptoAssetBalanceNativeSegwit(address: string) { const runesEnabled = useRunesEnabled(); - const query = useNativeSegwitUtxosByAddress({ + const spendableInscriptionUtxos = useInscribedSpendableUtxos(); + + const { filterOutInscriptions: filterOutNativeSegwitInscriptions } = + useFilterNativeSegwitInscriptions(); + + const totalUtxosQuery = useNativeSegwitUtxosByAddress({ address, - filterInscriptionUtxos: true, + filterInscriptionUtxos: false, filterPendingTxsUtxos: true, filterRunesUtxos: runesEnabled, }); const balance = useMemo(() => { - if (isUndefined(query.data)) - return createBtcCryptoAssetBalance(createMoney(new BigNumber(0), 'BTC')); - return createBtcCryptoAssetBalance( - createMoney(sumNumbers(query.data.map(utxo => utxo.value)), 'BTC') - ); - }, [query.data]); - - return { - ...query, - balance, - }; + if (isUndefined(totalUtxosQuery.data) || isUndefined(totalUtxosQuery.data)) + return { + ...defaultZeroValues, + totalBalance: createMoney(new BigNumber(0), 'BTC'), + availableBalance: createMoney(new BigNumber(0), 'BTC'), + }; + return { + ...defaultZeroValues, + totalBalance: createMoney(sumNumbers(totalUtxosQuery.data.map(utxo => utxo.value)), 'BTC'), + availableBalance: createMoney( + // Here we add back in the utxos that are spending beacuse they've been discarded + sumNumbers( + [ + ...filterOutNativeSegwitInscriptions(totalUtxosQuery.data), + ...spendableInscriptionUtxos, + ].map(utxo => utxo.value) + ), + 'BTC' + ), + }; + }, [totalUtxosQuery.data, filterOutNativeSegwitInscriptions, spendableInscriptionUtxos]); + + return { ...totalUtxosQuery, balance }; } export function useCurrentBtcCryptoAssetBalanceNativeSegwit() { diff --git a/src/app/query/bitcoin/ordinals/inscriptions/inscriptions.query.ts b/src/app/query/bitcoin/ordinals/inscriptions/inscriptions.query.ts index 0a5f74501d9..98b54822844 100644 --- a/src/app/query/bitcoin/ordinals/inscriptions/inscriptions.query.ts +++ b/src/app/query/bitcoin/ordinals/inscriptions/inscriptions.query.ts @@ -1,19 +1,25 @@ import { useCallback, useMemo } from 'react'; -import { useQueries } from '@tanstack/react-query'; +import { useQueries, useQuery } from '@tanstack/react-query'; import { + type UtxoResponseItem, combineInscriptionResults, + createBestInSlotInscription, createInscriptionByXpubQuery, createNumberOfInscriptionsFn, filterUninscribedUtxosToRecoverFromTaproot, + filterUtxosWithInscriptions, useBitcoinClient, useGetTaprootUtxosByAddressQuery, utxosToBalance, } from '@leather.io/query'; import { useCurrentAccountIndex } from '@app/store/accounts/account'; -import { useCurrentBitcoinAccountXpubs } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks'; +import { + useCurrentBitcoinAccountNativeSegwitXpub, + useCurrentBitcoinAccountXpubs, +} from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks'; import { useCurrentTaprootAccount } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks'; interface UseInscriptionArgs { @@ -37,6 +43,17 @@ export function useNumberOfInscriptionsOnUtxo() { ); } +export function useCurrentNativeSegwitInscriptions() { + const client = useBitcoinClient(); + const nativeSegwitXpub = useCurrentBitcoinAccountNativeSegwitXpub(); + return useQuery({ + ...createInscriptionByXpubQuery(client, nativeSegwitXpub), + select(data) { + return data.data.map(createBestInSlotInscription); + }, + }); +} + export function useCurrentTaprootAccountUninscribedUtxos() { const taprootAccount = useCurrentTaprootAccount(); const currentAccountIndex = useCurrentAccountIndex(); @@ -55,3 +72,14 @@ export function useCurrentTaprootAccountBalance() { const uninscribedUtxos = useCurrentTaprootAccountUninscribedUtxos(); return useMemo(() => utxosToBalance(uninscribedUtxos), [uninscribedUtxos]); } + +export function useFilterNativeSegwitInscriptions() { + const { data: inscriptions } = useCurrentNativeSegwitInscriptions(); + + const filterOutInscriptions = useCallback( + (utxos: UtxoResponseItem[]) => utxos.filter(filterUtxosWithInscriptions(inscriptions ?? [])), + [inscriptions] + ); + + return { filterOutInscriptions }; +} diff --git a/src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts b/src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts index 4362f69374e..14cf197f1ad 100644 --- a/src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts +++ b/src/app/store/accounts/blockchain/bitcoin/bitcoin.hooks.ts @@ -287,12 +287,19 @@ export function useSignBitcoinTx() { }; } -export function useCurrentBitcoinAccountXpubs() { - const taprootAccount = useCurrentTaprootAccount(); +export function useCurrentBitcoinAccountNativeSegwitXpub() { const nativeSegwitAccount = useCurrentNativeSegwitAccount(); + return `wpkh(${nativeSegwitAccount?.keychain.publicExtendedKey})`; +} + +function useCurrentBitcoinAccountTaprootXpub() { + const taprootAccount = useCurrentTaprootAccount(); + return `tr(${taprootAccount?.keychain.publicExtendedKey})`; +} + +export function useCurrentBitcoinAccountXpubs() { + const taprootXpub = useCurrentBitcoinAccountTaprootXpub(); + const nativeSegwitXpub = useCurrentBitcoinAccountNativeSegwitXpub(); - return [ - `wpkh(${nativeSegwitAccount?.keychain.publicExtendedKey})`, - `tr(${taprootAccount?.keychain.publicExtendedKey})`, - ]; + return [taprootXpub, nativeSegwitXpub]; } diff --git a/src/app/store/settings/settings.selectors.ts b/src/app/store/settings/settings.selectors.ts index cde2ec58676..c69b23aaa0c 100644 --- a/src/app/store/settings/settings.selectors.ts +++ b/src/app/store/settings/settings.selectors.ts @@ -1,9 +1,14 @@ -import { useSelector } from 'react-redux'; +import { useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { createSelector } from '@reduxjs/toolkit'; +import type { Inscription } from '@leather.io/models'; + import { RootState } from '@app/store'; +import { settingsSlice } from './settings.slice'; + const selectSettings = (state: RootState) => state.settings; const selectUserSelectedTheme = createSelector(selectSettings, state => state.userSelectedTheme); @@ -26,3 +31,31 @@ const selectIsPrivateMode = createSelector(selectSettings, state => state.isPriv export function useIsPrivateMode() { return useSelector(selectIsPrivateMode); } + +const selectDiscardedInscriptions = createSelector( + selectSettings, + state => state.discardedInscriptions +); + +type InscriptionIdentifier = Pick; + +export function useDiscardedInscriptions() { + const discardedInscriptions = useSelector(selectDiscardedInscriptions); + const dispatch = useDispatch(); + + return useMemo( + () => ({ + discardedInscriptions, + hasInscriptionBeenDiscarded({ txid, output: vout, offset }: InscriptionIdentifier) { + return discardedInscriptions.includes([txid, vout, offset].join(':')); + }, + discardInscription({ txid, output: vout, offset }: InscriptionIdentifier) { + dispatch(settingsSlice.actions.discardInscription([txid, vout, offset].join(':'))); + }, + recoverInscription({ txid, output: vout, offset }: InscriptionIdentifier) { + dispatch(settingsSlice.actions.recoverInscription([txid, vout, offset].join(':'))); + }, + }), + [discardedInscriptions, dispatch] + ); +} diff --git a/src/app/store/settings/settings.slice.ts b/src/app/store/settings/settings.slice.ts index e50bb1e44f6..8f910f3565c 100644 --- a/src/app/store/settings/settings.slice.ts +++ b/src/app/store/settings/settings.slice.ts @@ -6,11 +6,14 @@ interface InitialState { userSelectedTheme: UserSelectedTheme; dismissedMessages: string[]; isPrivateMode?: boolean; + bypassInscriptionChecks?: boolean; + discardedInscriptions: string[]; } const initialState: InitialState = { userSelectedTheme: 'system', dismissedMessages: [], + discardedInscriptions: [], }; export const settingsSlice = createSlice({ @@ -30,5 +33,20 @@ export const settingsSlice = createSlice({ togglePrivateMode(state) { state.isPrivateMode = !state.isPrivateMode; }, + dangerouslyChosenToBypassAllInscriptionChecks(state) { + state.bypassInscriptionChecks = true; + }, + discardInscription(state, action: PayloadAction) { + if (!Array.isArray(state.discardedInscriptions)) state.discardedInscriptions = []; + state.discardedInscriptions.push(action.payload); + }, + recoverInscription(state, action: PayloadAction) { + state.discardedInscriptions = state.discardedInscriptions.filter( + inscriptionId => inscriptionId !== action.payload + ); + }, + resetInscriptionState(state) { + state.discardedInscriptions = []; + }, }, }); diff --git a/src/app/ui/components/account/account.card.stories.tsx b/src/app/ui/components/account/account.card.stories.tsx index 3c9f4631f8f..b85e9e863b7 100644 --- a/src/app/ui/components/account/account.card.stories.tsx +++ b/src/app/ui/components/account/account.card.stories.tsx @@ -33,7 +33,8 @@ export function AccountCard() { return ( null} isLoadingBalance={false} isFetchingBnsName={false} @@ -52,7 +53,8 @@ export function AccountCardLoading() { return ( null} isLoadingBalance isFetchingBnsName={false} @@ -71,7 +73,8 @@ export function AccountCardBnsNameLoading() { return ( null} isLoadingBalance={false} isFetchingBnsName @@ -91,7 +94,8 @@ export function AccountCardPrivateBalance() { return ( null} isLoadingBalance={false} isFetchingBnsName={false} diff --git a/src/app/ui/components/account/account.card.tsx b/src/app/ui/components/account/account.card.tsx index 54a0bfc64c5..18ea852a890 100644 --- a/src/app/ui/components/account/account.card.tsx +++ b/src/app/ui/components/account/account.card.tsx @@ -4,27 +4,38 @@ import { SettingsSelectors } from '@tests/selectors/settings.selectors'; import { SharedComponentsSelectors } from '@tests/selectors/shared-component.selectors'; import { Box, Flex, styled } from 'leather-styles/jsx'; -import { ChevronDownIcon, Link, SkeletonLoader, shimmerStyles } from '@leather.io/ui'; +import { + ChevronDownIcon, + Flag, + InfoCircleIcon, + Link, + SkeletonLoader, + shimmerStyles, +} from '@leather.io/ui'; import { useScaleText } from '@app/common/hooks/use-scale-text'; import { AccountNameLayout } from '@app/components/account/account-name'; import { PrivateTextLayout } from '@app/components/privacy/private-text.layout'; +import { BasicTooltip } from '../tooltip/basic-tooltip'; + interface AccountCardProps { name: string; - balance: string; + availableBalance: string; + totalBalance: string; children: ReactNode; - toggleSwitchAccount(): void; isFetchingBnsName: boolean; isLoadingBalance: boolean; isLoadingAdditionalData?: boolean; isBalancePrivate?: boolean; + toggleSwitchAccount(): void; onShowBalance?(): void; } export function AccountCard({ name, - balance, + availableBalance, + totalBalance, toggleSwitchAccount, onShowBalance, children, @@ -89,9 +100,28 @@ export function AccountCard({ display="inline-block" overflow="hidden" > - {balance} + {totalBalance} + + Available balance: + + + + } + > + {availableBalance} + + + + {children} diff --git a/tests/page-object-models/onboarding.page.ts b/tests/page-object-models/onboarding.page.ts index 4aad633c9de..08c694a2338 100644 --- a/tests/page-object-models/onboarding.page.ts +++ b/tests/page-object-models/onboarding.page.ts @@ -42,6 +42,7 @@ export const testSoftwareAccountDefaultWalletState = { networks: { ids: [], entities: {}, currentNetworkId: 'mainnet' }, ordinals: {}, settings: { + discardedInscriptions: [], userSelectedTheme: 'system', dismissedMessages: [], }, diff --git a/tests/specs/ledger/ledger.spec.ts b/tests/specs/ledger/ledger.spec.ts index 1e4936de1c2..f74143234d7 100644 --- a/tests/specs/ledger/ledger.spec.ts +++ b/tests/specs/ledger/ledger.spec.ts @@ -50,10 +50,7 @@ test.describe('App with Ledger', () => { await homePage.page.getByTestId(SettingsSelectors.CurrentAccountDisplayName).click(); - await test - .expect(async () => await test.expect(requestPromise).rejects.toThrowError()) - .toPass() - .catch(); + test.expect(async () => await test.expect(requestPromise).rejects.toThrowError()); }); }