diff --git a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts index 96df414b91c..342390eb336 100644 --- a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts +++ b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts @@ -135,7 +135,10 @@ export function determineUtxosForSpendAllMultipleRecipients({ }); // Fee has already been deducted from the amount with send all - const outputs = recipients.map(({ address, amount }) => ({ value: BigInt(amount), address })); + const outputs = recipients.map(({ address, amount }) => ({ + value: BigInt(amount.amount.toNumber()), + address, + })); const fee = Math.ceil(sizeInfo.txVBytes * feeRate); @@ -190,7 +193,10 @@ export function determineUtxosForSpendMultipleRecipients({ address?: string; }[] = [ // outputs[0] = the desired amount going to recipient - ...recipients.map(({ address, amount }) => ({ value: BigInt(amount), address })), + ...recipients.map(({ address, amount }) => ({ + value: BigInt(amount.amount.toNumber()), + address, + })), // outputs[recipients.length] = the remainder to be returned to a change address { value: sum - BigInt(amount) - BigInt(fee) }, ]; diff --git a/src/app/components/account/bitcoin-account-loader.tsx b/src/app/components/account/bitcoin-account-loader.tsx index e7ea98b72d3..18774dc3680 100644 --- a/src/app/components/account/bitcoin-account-loader.tsx +++ b/src/app/components/account/bitcoin-account-loader.tsx @@ -4,6 +4,8 @@ import { useConfigBitcoinEnabled } from '@app/query/common/remote-config/remote- import { useCurrentAccountIndex } from '@app/store/accounts/account'; import { Signer } from '@app/store/accounts/blockchain/bitcoin/bitcoin-signer'; import { useNativeSegwitSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; +import { useTaprootSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; interface BitcoinAccountLoaderBaseProps { children(account: Signer): React.ReactNode; @@ -29,3 +31,17 @@ export function BitcoinNativeSegwitAccountLoader({ children, ...props }: BtcAcco if (!signer || !isBitcoinEnabled) return null; return children(signer(0)); } + +export function BitcoinTaprootAccountLoader({ children, ...props }: BtcAccountLoaderProps) { + const isBitcoinEnabled = useConfigBitcoinEnabled(); + const network = useCurrentNetwork(); + + const currentAccountIndex = useCurrentAccountIndex(); + + const properIndex = 'current' in props ? currentAccountIndex : props.index; + + const signer = useTaprootSigner(properIndex, network.chain.bitcoin.bitcoinNetwork); + + if (!signer || !isBitcoinEnabled) return null; + return children(signer(0)); +} diff --git a/src/app/components/crypto-assets/bitcoin/runes-asset-list/runes-asset-item.layout.tsx b/src/app/components/crypto-assets/bitcoin/runes-asset-list/runes-asset-item.layout.tsx index 01501396b3d..130885b777d 100644 --- a/src/app/components/crypto-assets/bitcoin/runes-asset-list/runes-asset-item.layout.tsx +++ b/src/app/components/crypto-assets/bitcoin/runes-asset-list/runes-asset-item.layout.tsx @@ -18,8 +18,8 @@ export function RunesAssetItemLayout({ rune }: RunesAssetItemLayoutProps) { } - titleLeft={rune.rune_name.toUpperCase()} - captionLeft="RUNE" + titleLeft={rune.spaced_rune_name ?? rune.rune_name} + captionLeft="Runes" titleRight={ - {formattedBalance.value} + {formattedBalance.value} {rune.symbol} } diff --git a/src/app/components/crypto-assets/bitcoin/runes-asset-list/runes-asset-list.tsx b/src/app/components/crypto-assets/bitcoin/runes-asset-list/runes-asset-list.tsx index a862ad36d8d..deaebbcbe66 100644 --- a/src/app/components/crypto-assets/bitcoin/runes-asset-list/runes-asset-list.tsx +++ b/src/app/components/crypto-assets/bitcoin/runes-asset-list/runes-asset-list.tsx @@ -6,5 +6,5 @@ interface RunesAssetListProps { runes: RuneToken[]; } export function RunesAssetList({ runes }: RunesAssetListProps) { - return runes.map(rune => ); + return runes.map((rune, i) => ); } diff --git a/src/app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout.tsx b/src/app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout.tsx index 98b97327e3e..4362246859c 100644 --- a/src/app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout.tsx +++ b/src/app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout.tsx @@ -58,9 +58,7 @@ export function CryptoCurrencyAssetItemLayout({ const captionRight = ( - {rightElement ? ( - rightElement - ) : ( + {!rightElement && ( diff --git a/src/app/components/loaders/runes-loader.tsx b/src/app/components/loaders/runes-loader.tsx index 333be639ba7..18e81e21d15 100644 --- a/src/app/components/loaders/runes-loader.tsx +++ b/src/app/components/loaders/runes-loader.tsx @@ -8,8 +8,6 @@ interface RunesLoaderProps { children(runes: RuneToken[]): React.ReactNode; } export function RunesLoader({ addresses, children }: RunesLoaderProps) { - const runes = useRuneTokens(addresses) - .flatMap(query => query.data) - .filter(isDefined); - return children(runes); + const runes = useRuneTokens(addresses); + return children(runes.filter(isDefined)); } diff --git a/src/app/features/asset-list/asset-list.tsx b/src/app/features/asset-list/asset-list.tsx index 36c569e7976..4963ad8bcd9 100644 --- a/src/app/features/asset-list/asset-list.tsx +++ b/src/app/features/asset-list/asset-list.tsx @@ -5,12 +5,15 @@ import { Stack } from 'leather-styles/jsx'; import { useBtcAssetBalance } from '@app/common/hooks/balance/btc/use-btc-balance'; import { useWalletType } from '@app/common/use-wallet-type'; +import { + BitcoinNativeSegwitAccountLoader, + BitcoinTaprootAccountLoader, +} from '@app/components/account/bitcoin-account-loader'; import { BitcoinContractEntryPoint } from '@app/components/bitcoin-contract-entry-point/bitcoin-contract-entry-point'; import { CryptoCurrencyAssetItemLayout } from '@app/components/crypto-assets/crypto-currency-asset/crypto-currency-asset-item.layout'; import { CurrentStacksAccountLoader } from '@app/components/loaders/stacks-account-loader'; import { useHasBitcoinLedgerKeychain } from '@app/store/accounts/blockchain/bitcoin/bitcoin.ledger'; import { useCurrentAccountNativeSegwitAddressIndexZero } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; -import { useCurrentAccountTaprootIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/taproot-account.hooks'; import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; import { BtcAvatarIcon } from '@app/ui/components/avatar/btc-avatar-icon'; @@ -25,7 +28,6 @@ import { StacksFungibleTokenAssetList } from './components/stacks-fungible-token export function AssetsList() { const hasBitcoinLedgerKeys = useHasBitcoinLedgerKeychain(); const bitcoinAddressNativeSegwit = useCurrentAccountNativeSegwitAddressIndexZero(); - const { address: bitcoinAddressTaproot } = useCurrentAccountTaprootIndexZeroSigner(); const network = useCurrentNetwork(); const { btcAvailableAssetBalance, btcAvailableUsdBalance, isInitialLoading } = useBtcAssetBalance( @@ -76,15 +78,18 @@ export function AssetsList() { )} - {whenWallet({ - software: ( - - ), - ledger: null, - })} + + {nativeSegwitAccount => ( + + {taprootAccount => ( + + )} + + )} + diff --git a/src/app/pages/rpc-send-transfer/components/send-transfer-confirmation-details.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-confirmation-details.tsx index e0ba30938a7..3d74812fc81 100644 --- a/src/app/pages/rpc-send-transfer/components/send-transfer-confirmation-details.tsx +++ b/src/app/pages/rpc-send-transfer/components/send-transfer-confirmation-details.tsx @@ -1,18 +1,18 @@ import { HStack, Stack, styled } from 'leather-styles/jsx'; +import type { Money } from '@shared/models/money.model'; + +import { formatMoney } from '@app/common/money/format-money'; + interface SendTransferConfirmationDetailsProps { currentAddress: string; recipient: string; - time: string; - total: string; - feeRowValue: string; + amount: Money; } export function SendTransferConfirmationDetails({ currentAddress, recipient, - time, - total, - feeRowValue, + amount, }: SendTransferConfirmationDetailsProps) { return ( @@ -25,19 +25,9 @@ export function SendTransferConfirmationDetails({ {recipient} - Fee - {feeRowValue} - - - Total - {total} + Amount + {formatMoney(amount)} - {time && ( - - Estimated Time - {time} - - )} ); } diff --git a/src/app/pages/rpc-send-transfer/components/send-transfer-details.tsx b/src/app/pages/rpc-send-transfer/components/send-transfer-details.tsx index 778c6a53e73..66532aa2043 100644 --- a/src/app/pages/rpc-send-transfer/components/send-transfer-details.tsx +++ b/src/app/pages/rpc-send-transfer/components/send-transfer-details.tsx @@ -2,6 +2,7 @@ import { HStack, Stack, styled } from 'leather-styles/jsx'; import type { RpcSendTransferRecipient } from '@shared/rpc/methods/send-transfer'; +import { formatMoney } from '@app/common/money/format-money'; import { truncateMiddle } from '@app/ui/utils/truncate-middle'; interface SendTransferDetailsProps { @@ -31,7 +32,7 @@ export function SendTransferDetails({ recipients, currentAddress }: SendTransfer Amount - {amount} + {formatMoney(amount)} ))} diff --git a/src/app/pages/rpc-send-transfer/rpc-send-transfer-confirmation.tsx b/src/app/pages/rpc-send-transfer/rpc-send-transfer-confirmation.tsx index 0943694a3e9..ea506cdf7fe 100644 --- a/src/app/pages/rpc-send-transfer/rpc-send-transfer-confirmation.tsx +++ b/src/app/pages/rpc-send-transfer/rpc-send-transfer-confirmation.tsx @@ -1,20 +1,24 @@ import { useLocation, useNavigate } from 'react-router-dom'; -import { Stack } from 'leather-styles/jsx'; +import { HStack, Stack, styled } from 'leather-styles/jsx'; import get from 'lodash.get'; import { decodeBitcoinTx } from '@shared/crypto/bitcoin/bitcoin.utils'; import { logger } from '@shared/logger'; import { CryptoCurrencies } from '@shared/models/currencies.model'; -import { createMoney, createMoneyFromDecimal } from '@shared/models/money.model'; +import { createMoney } from '@shared/models/money.model'; import { RouteUrls } from '@shared/route-urls'; import type { RpcSendTransferRecipient } from '@shared/rpc/methods/send-transfer'; import { makeRpcSuccessResponse } from '@shared/rpc/rpc-methods'; import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; -import { baseCurrencyAmountInQuote } from '@app/common/money/calculate-money'; -import { formatMoney, formatMoneyPadded, i18nFormatCurrency } from '@app/common/money/format-money'; -import { satToBtc } from '@app/common/money/unit-conversion'; +import { baseCurrencyAmountInQuote, sumMoney } from '@app/common/money/calculate-money'; +import { + formatMoney, + formatMoneyPadded, + formatMoneyWithoutSymbol, + i18nFormatCurrency, +} from '@app/common/money/format-money'; import { InfoCardFooter } from '@app/components/info-card/info-card'; import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; import { useBitcoinBroadcastTransaction } from '@app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction'; @@ -50,17 +54,12 @@ export function RpcSendTransferConfirmation() { const btcMarketData = useCryptoCurrencyMarketData('BTC'); const psbt = decodeBitcoinTx(tx); - const transferAmount = satToBtc(psbt.outputs[0].amount.toString()).toString(); - const txFiatValue = i18nFormatCurrency( - baseCurrencyAmountInQuote(createMoneyFromDecimal(Number(transferAmount), symbol), btcMarketData) - ); + const transferAmount = sumMoney(recipients.map(r => r.amount)); + const txFiatValue = i18nFormatCurrency(baseCurrencyAmountInQuote(transferAmount, btcMarketData)); const txFiatValueSymbol = btcMarketData.price.symbol; - const feeInBtc = satToBtc(fee); - const summaryFee = formatMoneyPadded(createMoney(Number(fee), symbol)); - const sendingValue = formatMoney(createMoneyFromDecimal(Number(transferAmount), symbol)); - const totalSpend = formatMoney( - createMoneyFromDecimal(Number(transferAmount) + Number(feeInBtc), symbol) - ); + const feeMoney = createMoney(Number(fee), symbol); + const summaryFee = formatMoneyPadded(feeMoney); + const totalSpend = sumMoney([transferAmount, feeMoney]); function formBtcTxSummaryState(txId: string) { return { @@ -71,11 +70,11 @@ export function RpcSendTransferConfirmation() { txId, recipients, fee: summaryFee, - txValue: transferAmount, + txValue: formatMoneyWithoutSymbol(transferAmount), arrivesIn: time, - totalSpend, + totalSpend: formatMoney(totalSpend), symbol, - sendingValue, + sendingValue: formatMoney(transferAmount), txFiatValue, txFiatValueSymbol, feeRowValue, @@ -121,17 +120,31 @@ export function RpcSendTransferConfirmation() { return ( <> - + {recipients.map((recipient, index) => ( ))} + + + Fee + {feeRowValue} + + + Total amount + {formatMoney(totalSpend)} + + {time && ( + + Estimated time + {time} + + )} + diff --git a/src/app/pages/rpc-send-transfer/use-rpc-send-transfer.ts b/src/app/pages/rpc-send-transfer/use-rpc-send-transfer.ts index 3f61514925f..019baca3d04 100644 --- a/src/app/pages/rpc-send-transfer/use-rpc-send-transfer.ts +++ b/src/app/pages/rpc-send-transfer/use-rpc-send-transfer.ts @@ -39,7 +39,7 @@ export function useRpcSendTransfer() { const recipients = recipientsAddresses.map((address, index) => ({ address, - amount: amounts[index], + amount: createMoney(Number(amounts[index]), 'BTC'), })); return { 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 c5a33cd08e1..40567d97db6 100644 --- a/src/app/query/bitcoin/address/utxos-by-address.hooks.ts +++ b/src/app/query/bitcoin/address/utxos-by-address.hooks.ts @@ -4,8 +4,13 @@ import { InscriptionResponseItem } from '@shared/models/inscription.model'; import { useCurrentAccountNativeSegwitIndexZeroSigner } from '@app/store/accounts/blockchain/bitcoin/native-segwit-account.hooks'; -import { UtxoResponseItem, UtxoWithDerivationPath } from '../bitcoin-client'; +import { + type RunesOutputsByAddress, + UtxoResponseItem, + UtxoWithDerivationPath, +} from '../bitcoin-client'; import { useInscriptionsByAddressQuery } from '../ordinals/inscriptions.query'; +import { useRunesEnabled, useRunesOutputsByAddress } from '../runes/runes.hooks'; import { useBitcoinPendingTransactionsInputs } from './transactions-by-address.hooks'; import { useGetUtxosByAddressQuery } from './utxos-by-address.query'; @@ -21,9 +26,20 @@ export function filterUtxosWithInscriptions( ); } +export function filterUtxosWithRunes(runes: RunesOutputsByAddress[], utxos: UtxoResponseItem[]) { + return utxos.filter(utxo => { + const hasRuneOutput = runes.find(rune => { + return rune.output === `${utxo.txid}:${utxo.vout}`; + }); + + return !hasRuneOutput; + }); +} + const defaultArgs = { filterInscriptionUtxos: true, filterPendingTxsUtxos: true, + filterRunesUtxos: true, }; /** @@ -31,7 +47,7 @@ const defaultArgs = { * we set `filterInscriptionUtxos` and `filterPendingTxsUtxos` to true */ export function useCurrentNativeSegwitUtxos(args = defaultArgs) { - const { filterInscriptionUtxos, filterPendingTxsUtxos } = args; + const { filterInscriptionUtxos, filterPendingTxsUtxos, filterRunesUtxos } = args; const nativeSegwitSigner = useCurrentAccountNativeSegwitIndexZeroSigner(); const address = nativeSegwitSigner.address; @@ -40,6 +56,7 @@ export function useCurrentNativeSegwitUtxos(args = defaultArgs) { address, filterInscriptionUtxos, filterPendingTxsUtxos, + filterRunesUtxos, }); } @@ -47,6 +64,7 @@ interface UseFilterUtxosByAddressArgs { address: string; filterInscriptionUtxos: boolean; filterPendingTxsUtxos: boolean; + filterRunesUtxos: boolean; } type filterUtxoFunctionType = (utxos: UtxoResponseItem[]) => UtxoResponseItem[]; @@ -55,10 +73,12 @@ export function useNativeSegwitUtxosByAddress({ address, filterInscriptionUtxos, filterPendingTxsUtxos, + filterRunesUtxos, }: UseFilterUtxosByAddressArgs) { const { filterOutInscriptions, isInitialLoadingInscriptions } = useFilterInscriptionsByAddress(address); const { filterOutPendingTxsUtxos, isInitialLoading } = useFilterPendingUtxosByAddress(address); + const { filterOutRunesUtxos, isInitialLoadingRunesData } = useFilterRuneUtxosByAddress(address); const utxosQuery = useGetUtxosByAddressQuery(address, { select(utxos) { @@ -71,6 +91,10 @@ export function useNativeSegwitUtxosByAddress({ filters.push(filterOutInscriptions); } + if (filterRunesUtxos) { + filters.push(filterOutRunesUtxos); + } + return filters.reduce( (filteredUtxos: UtxoResponseItem[], filterFunc: filterUtxoFunctionType) => filterFunc(filteredUtxos), @@ -82,7 +106,10 @@ export function useNativeSegwitUtxosByAddress({ return { ...utxosQuery, isInitialLoading: - utxosQuery.isInitialLoading || isInitialLoading || isInitialLoadingInscriptions, + utxosQuery.isInitialLoading || + isInitialLoading || + isInitialLoadingInscriptions || + isInitialLoadingRunesData, }; } @@ -113,6 +140,29 @@ function useFilterInscriptionsByAddress(address: string) { }; } +function useFilterRuneUtxosByAddress(address: string) { + // TO-DO what if data is undefined? + const { data = [], isInitialLoading } = useRunesOutputsByAddress(address); + const runesEnabled = useRunesEnabled(); + + const filterOutRunesUtxos = useCallback( + (utxos: UtxoResponseItem[]) => { + // If Runes are not enabled, return all utxos + if (!runesEnabled) { + return utxos; + } + + return filterUtxosWithRunes(data, utxos); + }, + [data, runesEnabled] + ); + + return { + filterOutRunesUtxos, + isInitialLoadingRunesData: isInitialLoading, + }; +} + function useFilterPendingUtxosByAddress(address: string) { const { data: pendingInputs = [], isInitialLoading } = useBitcoinPendingTransactionsInputs(address); diff --git a/src/app/query/bitcoin/address/utxos-by-address.spec.tsx b/src/app/query/bitcoin/address/utxos-by-address.spec.tsx index 170f005ea2d..a457da2aaca 100644 --- a/src/app/query/bitcoin/address/utxos-by-address.spec.tsx +++ b/src/app/query/bitcoin/address/utxos-by-address.spec.tsx @@ -1,7 +1,8 @@ import { mockInscriptionsList } from '@tests/mocks/mock-inscriptions'; -import { mockUtxos } from '@tests/mocks/mock-utxos'; +import { mockRunesOutputsByAddressList } from '@tests/mocks/mock-runes'; +import { mockUtxos, mockUtxosListWithRunes } from '@tests/mocks/mock-utxos'; -import { filterUtxosWithInscriptions } from './utxos-by-address.hooks'; +import { filterUtxosWithInscriptions, filterUtxosWithRunes } from './utxos-by-address.hooks'; describe(filterUtxosWithInscriptions, () => { test('that it filters out utxos with inscriptions so they are not spent', () => { @@ -9,3 +10,26 @@ describe(filterUtxosWithInscriptions, () => { expect(filteredUtxos).toEqual([]); }); }); + +describe(filterUtxosWithRunes, () => { + test('that it filters out utxos with runes so they are not spent', () => { + const filteredUtxos = filterUtxosWithRunes( + mockRunesOutputsByAddressList, + mockUtxosListWithRunes + ); + + expect(filteredUtxos).toEqual([ + { + txid: '66ff7d54e345170e3a76819dc90140971fdae054c9b7eea2089ba5a9720f6e44', + vout: 1, + status: { + confirmed: true, + block_height: 2585955, + block_hash: '00000000000000181cae54c3c19d6ed02511a2f6302a666c3d78bcf1777bb029', + block_time: 1712829917, + }, + value: 546, + }, + ]); + }); +}); diff --git a/src/app/query/bitcoin/balance/btc-balance.hooks.ts b/src/app/query/bitcoin/balance/btc-balance.hooks.ts index 94596c6e059..c5deba04ffb 100644 --- a/src/app/query/bitcoin/balance/btc-balance.hooks.ts +++ b/src/app/query/bitcoin/balance/btc-balance.hooks.ts @@ -8,8 +8,11 @@ import { isUndefined } from '@shared/utils'; import { sumNumbers } from '@app/common/math/helpers'; import { useNativeSegwitUtxosByAddress } from '../address/utxos-by-address.hooks'; +import { useRunesEnabled } from '../runes/runes.hooks'; export function useGetBitcoinBalanceByAddress(address: string) { + const runesEnabled = useRunesEnabled(); + const { data: utxos, isInitialLoading, @@ -18,6 +21,7 @@ export function useGetBitcoinBalanceByAddress(address: string) { address, filterInscriptionUtxos: true, filterPendingTxsUtxos: true, + filterRunesUtxos: runesEnabled, }); const balance = useMemo(() => { diff --git a/src/app/query/bitcoin/bitcoin-client.ts b/src/app/query/bitcoin/bitcoin-client.ts index 5f86e5633bb..44796947f62 100644 --- a/src/app/query/bitcoin/bitcoin-client.ts +++ b/src/app/query/bitcoin/bitcoin-client.ts @@ -54,12 +54,12 @@ interface BestinslotInscription { byte_size: number; } -export interface BestinslotInscriptionByIdResponse { +interface BestinslotInscriptionByIdResponse { data: BestinslotInscription; block_height: number; } -export interface BestinslotInscriptionsByTxIdResponse { +interface BestinslotInscriptionsByTxIdResponse { data: { inscription_id: string }[]; blockHeight: number; } @@ -116,10 +116,64 @@ interface RunesWalletBalancesResponse { data: RuneBalance[]; } -export interface RuneToken extends RuneBalance { +export interface RuneTickerInfo { + rune_id: string; + rune_number: string; + rune_name: string; + spaced_rune_name: string; + symbol: string; + decimals: number; + per_mint_amount: string; + mint_cnt: string; + mint_cnt_limit: string; + premined_supply: string; + total_minted_supply: string; + burned_supply: string; + circulating_supply: string; + mint_progress: number; + mint_start_block: number | null; + mint_end_block: number | null; + genesis_block: number; + deploy_ts: string; + deploy_txid: string; + auto_upgrade: boolean; + holder_count: number; + event_count: number; + mintable: boolean; +} +interface RunesTickerInfoResponse { + block_height: number; + data: RuneTickerInfo; +} + +export interface RuneToken extends RuneBalance, RuneTickerInfo { balance: Money; } +export interface RunesOutputsByAddress { + pkscript: string; + wallet_addr: string; + output: string; + rune_ids: string[]; + balances: number[]; + rune_names: string[]; + spaced_rune_names: string[]; +} + +interface RunesOutputsByAddressArgs { + address: string; + network?: BitcoinNetworkModes; + sortBy?: 'output'; + order?: 'asc' | 'desc'; + offset?: number; + count?: number; +} + +interface RunesOutputsByAddressResponse { + block_height: number; + data: RunesOutputsByAddress[]; +} + class BestinslotApi { url = BESTINSLOT_API_BASE_URL_MAINNET; testnetUrl = BESTINSLOT_API_BASE_URL_TESTNET; @@ -181,6 +235,53 @@ class BestinslotApi { ); return resp.data.data; } + + async getRunesTickerInfo(runeName: string, network: BitcoinNetworkModes) { + const baseUrl = network === 'mainnet' ? this.url : this.testnetUrl; + const resp = await axios.get( + `${baseUrl}/runes/ticker_info?rune_name=${runeName}`, + { ...this.defaultOptions } + ); + return resp.data.data; + } + + async getRunesBatchOutputsInfo(outputs: string[], network: BitcoinNetworkModes) { + const baseUrl = network === 'mainnet' ? this.url : this.testnetUrl; + + const resp = await axios.post( + `${baseUrl}/runes/batch_output_info`, + { queries: outputs }, + { ...this.defaultOptions } + ); + return resp.data.data; + } + + /** + * @see https://docs.bestinslot.xyz/reference/api-reference/ordinals-and-brc-20-and-runes-and-bitmap-v3-api-mainnet+testnet/runes#runes-wallet-valid-outputs + */ + async getRunesOutputsByAddress({ + address, + network = 'mainnet', + sortBy = 'output', + order = 'asc', + offset = 0, + count = 100, + }: RunesOutputsByAddressArgs) { + const baseUrl = network === 'mainnet' ? this.url : this.testnetUrl; + const queryParams = new URLSearchParams({ + address, + sort_by: sortBy, + order, + offset: offset.toString(), + count: count.toString(), + }); + + const resp = await axios.get( + `${baseUrl}/runes/wallet_valid_outputs?${queryParams}`, + { ...this.defaultOptions } + ); + return resp.data.data; + } } class HiroApi { diff --git a/src/app/query/bitcoin/runes/runes-outputs-by-address.query.ts b/src/app/query/bitcoin/runes/runes-outputs-by-address.query.ts new file mode 100644 index 00000000000..9d7fe7928d4 --- /dev/null +++ b/src/app/query/bitcoin/runes/runes-outputs-by-address.query.ts @@ -0,0 +1,29 @@ +import { useQuery } from '@tanstack/react-query'; + +import type { AppUseQueryConfig } from '@app/query/query-config'; +import { useBitcoinClient } from '@app/store/common/api-clients.hooks'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; + +import type { RunesOutputsByAddress } from '../bitcoin-client'; +import { useRunesEnabled } from './runes.hooks'; + +export function useGetRunesOutputsByAddressQuery( + address: string, + options?: AppUseQueryConfig +) { + const client = useBitcoinClient(); + const runesEnabled = useRunesEnabled(); + const network = useCurrentNetwork(); + + return useQuery({ + queryKey: ['runes-outputs-by-address', address], + queryFn: () => + client.BestinslotApi.getRunesOutputsByAddress({ + address, + network: network.chain.bitcoin.bitcoinNetwork, + }), + staleTime: 1000 * 60, + enabled: !!address && runesEnabled, + ...options, + }); +} diff --git a/src/app/query/bitcoin/runes/runes-ticker-info.query.ts b/src/app/query/bitcoin/runes/runes-ticker-info.query.ts new file mode 100644 index 00000000000..9f5a3b2ebc0 --- /dev/null +++ b/src/app/query/bitcoin/runes/runes-ticker-info.query.ts @@ -0,0 +1,24 @@ +import { type UseQueryResult, useQueries } from '@tanstack/react-query'; + +import { useConfigRunesEnabled } from '@app/query/common/remote-config/remote-config.query'; +import { useBitcoinClient } from '@app/store/common/api-clients.hooks'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; + +import type { RuneTickerInfo } from '../bitcoin-client'; + +export function useGetRunesTickerInfoQuery(runeNames: string[]): UseQueryResult[] { + const client = useBitcoinClient(); + const network = useCurrentNetwork(); + const runesEnabled = useConfigRunesEnabled(); + + return useQueries({ + queries: runeNames.map(runeName => { + return { + enabled: !!runeName && (network.chain.bitcoin.bitcoinNetwork === 'testnet' || runesEnabled), + queryKey: ['runes-ticker-info', runeName], + queryFn: () => + client.BestinslotApi.getRunesTickerInfo(runeName, network.chain.bitcoin.bitcoinNetwork), + }; + }), + }); +} diff --git a/src/app/query/bitcoin/runes/runes.hooks.ts b/src/app/query/bitcoin/runes/runes.hooks.ts index 1d74ee1b94c..1ea8030e261 100644 --- a/src/app/query/bitcoin/runes/runes.hooks.ts +++ b/src/app/query/bitcoin/runes/runes.hooks.ts @@ -1,17 +1,56 @@ +import { logger } from '@shared/logger'; import { createMoney } from '@shared/models/money.model'; +import { isDefined } from '@shared/utils'; -import type { RuneBalance, RuneToken } from '../bitcoin-client'; +import { useConfigRunesEnabled } from '@app/query/common/remote-config/remote-config.query'; +import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; + +import type { RuneBalance, RuneTickerInfo, RuneToken } from '../bitcoin-client'; +import { useGetRunesOutputsByAddressQuery } from './runes-outputs-by-address.query'; +import { useGetRunesTickerInfoQuery } from './runes-ticker-info.query'; import { useGetRunesWalletBalancesByAddressesQuery } from './runes-wallet-balances.query'; -function makeRuneToken(rune: RuneBalance): RuneToken { +const defaultRunesSymbol = '¤'; + +function makeRuneToken(runeBalance: RuneBalance, tickerInfo: RuneTickerInfo): RuneToken { return { - ...rune, - balance: createMoney(Number(rune.total_balance), rune.rune_name, 0), + ...runeBalance, + ...tickerInfo, + symbol: tickerInfo.symbol ?? defaultRunesSymbol, + balance: createMoney( + Number(runeBalance.total_balance), + tickerInfo.rune_name, + tickerInfo.decimals + ), }; } +export function useRunesEnabled() { + const runesEnabled = useConfigRunesEnabled(); + const network = useCurrentNetwork(); + + return runesEnabled || network.chain.bitcoin.bitcoinNetwork === 'testnet'; +} + export function useRuneTokens(addresses: string[]) { - return useGetRunesWalletBalancesByAddressesQuery(addresses, { - select: resp => resp.map(makeRuneToken), + const runesBalances = useGetRunesWalletBalancesByAddressesQuery(addresses) + .flatMap(query => query.data) + .filter(isDefined); + + const runesTickerInfo = useGetRunesTickerInfoQuery(runesBalances.map(r => r.rune_name)) + .flatMap(query => query.data) + .filter(isDefined); + + return runesBalances.map(r => { + const tickerInfo = runesTickerInfo.find(t => t.rune_name === r.rune_name); + if (!tickerInfo) { + logger.error('No ticker info found for Rune'); + return; + } + return makeRuneToken(r, tickerInfo); }); } + +export function useRunesOutputsByAddress(address: string) { + return useGetRunesOutputsByAddressQuery(address); +} diff --git a/src/app/query/bitcoin/stamps/stamps-by-address.hooks.ts b/src/app/query/bitcoin/stamps/stamps-by-address.hooks.ts index 5e958187b93..1eeb7efca3b 100644 --- a/src/app/query/bitcoin/stamps/stamps-by-address.hooks.ts +++ b/src/app/query/bitcoin/stamps/stamps-by-address.hooks.ts @@ -3,7 +3,7 @@ import { useStampsByAddressQuery } from './stamps-by-address.query'; export function useStampsByAddress(address: string) { return useStampsByAddressQuery(address, { select(data) { - return data.data.stamps; + return data.data?.stamps; }, }); } @@ -11,7 +11,7 @@ export function useStampsByAddress(address: string) { export function useSrc20TokensByAddress(address: string) { return useStampsByAddressQuery(address, { select(data) { - return data.data.src20; + return data.data?.src20; }, }); } diff --git a/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts b/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts index 3441e8a297b..46b73f18ef6 100644 --- a/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts +++ b/src/app/query/bitcoin/transaction/use-bitcoin-broadcast-transaction.ts @@ -8,7 +8,7 @@ import { delay, isError } from '@shared/utils'; import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; import { useBitcoinClient } from '@app/store/common/api-clients.hooks'; -import { filterOutIntentionalUtxoSpend, useCheckInscribedUtxos } from './use-check-utxos'; +import { filterOutIntentionalUtxoSpend, useCheckUnspendableUtxos } from './use-check-utxos'; interface BroadcastCallbackArgs { tx: string; @@ -23,7 +23,7 @@ export function useBitcoinBroadcastTransaction() { const client = useBitcoinClient(); const [isBroadcasting, setIsBroadcasting] = useState(false); const analytics = useAnalytics(); - const { checkIfUtxosListIncludesInscribed } = useCheckInscribedUtxos(); + const { checkIfUtxosListIncludesInscribed } = useCheckUnspendableUtxos(); const broadcastTx = useCallback( async ({ diff --git a/src/app/query/bitcoin/transaction/use-check-utxos.ts b/src/app/query/bitcoin/transaction/use-check-utxos.ts index 8ecbda64b4b..71fe959a921 100644 --- a/src/app/query/bitcoin/transaction/use-check-utxos.ts +++ b/src/app/query/bitcoin/transaction/use-check-utxos.ts @@ -10,10 +10,7 @@ import { useAnalytics } from '@app/common/hooks/analytics/use-analytics'; import { useBitcoinClient } from '@app/store/common/api-clients.hooks'; import { useCurrentNetworkState } from '@app/store/networks/networks.hooks'; -import type { - BestinslotInscriptionByIdResponse, - BestinslotInscriptionsByTxIdResponse, -} from '../bitcoin-client'; +import type { BitcoinClient } from '../bitcoin-client'; import { getNumberOfInscriptionOnUtxoUsingOrdinalsCom } from '../ordinals/inscriptions.query'; class PreventTransactionError extends Error { @@ -45,27 +42,29 @@ export function filterOutIntentionalUtxoSpend({ interface CheckInscribedUtxosByBestinslotArgs { inputs: btc.TransactionInput[]; txids: string[]; - getInscriptionsByTransactionId(id: string): Promise; - getInscriptionById(id: string): Promise; + client: BitcoinClient; } async function checkInscribedUtxosByBestinslot({ inputs, txids, - getInscriptionsByTransactionId, - getInscriptionById, + client, }: CheckInscribedUtxosByBestinslotArgs): Promise { /** * @description Get the list of inscriptions moving on a transaction * @see https://docs.bestinslot.xyz/reference/api-reference/ordinals-and-brc-20-and-bitmap-v3-api-mainnet+testnet/inscriptions */ - const inscriptionIdsList = await Promise.all(txids.map(id => getInscriptionsByTransactionId(id))); + const inscriptionIdsList = await Promise.all( + txids.map(id => client.BestinslotApi.getInscriptionsByTransactionId(id)) + ); const inscriptionIds = inscriptionIdsList.flatMap(inscription => inscription.data.map(data => data.inscription_id) ); - const inscriptionsList = await Promise.all(inscriptionIds.map(id => getInscriptionById(id))); + const inscriptionsList = await Promise.all( + inscriptionIds.map(id => client.BestinslotApi.getInscriptionById(id)) + ); const hasInscribedUtxos = inscriptionsList.some(resp => { return inputs.some(input => { @@ -78,7 +77,7 @@ async function checkInscribedUtxosByBestinslot({ return hasInscribedUtxos; } -export function useCheckInscribedUtxos(blockTxAction?: () => void) { +export function useCheckUnspendableUtxos(blockTxAction?: () => void) { const client = useBitcoinClient(); const analytics = useAnalytics(); const [isLoading, setIsLoading] = useState(false); @@ -147,8 +146,7 @@ export function useCheckInscribedUtxos(blockTxAction?: () => void) { const hasInscribedUtxo = await checkInscribedUtxosByBestinslot({ inputs, txids, - getInscriptionsByTransactionId: client.BestinslotApi.getInscriptionsByTransactionId, - getInscriptionById: client.BestinslotApi.getInscriptionById, + client, }); if (hasInscribedUtxo) { diff --git a/src/app/store/accounts/blockchain/bitcoin/taproot-account.hooks.ts b/src/app/store/accounts/blockchain/bitcoin/taproot-account.hooks.ts index 6e61ad63583..5979209a9d3 100644 --- a/src/app/store/accounts/blockchain/bitcoin/taproot-account.hooks.ts +++ b/src/app/store/accounts/blockchain/bitcoin/taproot-account.hooks.ts @@ -69,7 +69,7 @@ export function useTaprootNetworkSigners() { ); } -function useTaprootSigner(accountIndex: number, network: BitcoinNetworkModes) { +export function useTaprootSigner(accountIndex: number, network: BitcoinNetworkModes) { const account = useTaprootAccount(accountIndex); const extendedPublicKeyVersions = useBitcoinExtendedPublicKeyVersions(); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index a6819aca2b7..9b5b770fcb8 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -76,7 +76,7 @@ export interface NetworkConfiguration { } export const BESTINSLOT_API_BASE_URL_MAINNET = 'https://leatherapi.bestinslot.xyz/v3'; -export const BESTINSLOT_API_BASE_URL_TESTNET = 'https://testnet.api.bestinslot.xyz/v3'; +export const BESTINSLOT_API_BASE_URL_TESTNET = 'https://leatherapi_testnet.bestinslot.xyz/v3'; export const HIRO_API_BASE_URL_MAINNET = 'https://api.hiro.so'; export const HIRO_API_BASE_URL_TESTNET = 'https://api.testnet.hiro.so'; diff --git a/src/shared/rpc/methods/send-transfer.ts b/src/shared/rpc/methods/send-transfer.ts index b3d0da8cb5b..bdb98e79cb6 100644 --- a/src/shared/rpc/methods/send-transfer.ts +++ b/src/shared/rpc/methods/send-transfer.ts @@ -8,6 +8,7 @@ import { btcAddressNetworkValidator, btcAddressValidator, } from '@shared/forms/bitcoin-address-validators'; +import type { Money } from '@shared/models/money.model'; import { accountSchema, @@ -56,13 +57,18 @@ export interface RpcSendTransferParamsLegacy extends SendTransferRequestParams { } export interface RpcSendTransferRecipient { + address: string; + amount: Money; +} + +interface RpcSendTransferRecipientParam { address: string; amount: string; } export interface RpcSendTransferParams { account?: number; - recipients: RpcSendTransferRecipient[]; + recipients: RpcSendTransferRecipientParam[]; network: string; } diff --git a/test-app/src/components/bitcoin.tsx b/test-app/src/components/bitcoin.tsx index 4d0a8aef8fd..82ae067387b 100644 --- a/test-app/src/components/bitcoin.tsx +++ b/test-app/src/components/bitcoin.tsx @@ -413,11 +413,11 @@ export const Bitcoin = () => { recipients: [ { address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, - amount: '10000', + amount: '800', }, { address: TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS, - amount: '0.010000', + amount: '10000', }, ], network: 'testnet', diff --git a/tests/mocks/mock-runes.ts b/tests/mocks/mock-runes.ts new file mode 100644 index 00000000000..f8ae1a45f64 --- /dev/null +++ b/tests/mocks/mock-runes.ts @@ -0,0 +1,11 @@ +export const mockRunesOutputsByAddressList = [ + { + pkscript: '00148027825ee06ad337f9716df8137a1b651163c5b0', + wallet_addr: 'tb1qsqncyhhqdtfn07t3dhupx7smv5gk83ds6k0gfa', + output: '3298edc745bdc2168e949382fd42956a7bbe43ab885a49f1212b097ac8243650:1', + rune_ids: ['2585883:3795'], + balances: [100000000], + rune_names: ['BESTINSLOTXYZ'], + spaced_rune_names: ['BESTINSLOT•XYZ'], + }, +]; diff --git a/tests/mocks/mock-utxos.ts b/tests/mocks/mock-utxos.ts index 7f661345db1..119f2273d90 100644 --- a/tests/mocks/mock-utxos.ts +++ b/tests/mocks/mock-utxos.ts @@ -11,3 +11,28 @@ export const mockUtxos = [ value: 546, }, ]; + +export const mockUtxosListWithRunes = [ + { + txid: '66ff7d54e345170e3a76819dc90140971fdae054c9b7eea2089ba5a9720f6e44', + vout: 1, + status: { + confirmed: true, + block_height: 2585955, + block_hash: '00000000000000181cae54c3c19d6ed02511a2f6302a666c3d78bcf1777bb029', + block_time: 1712829917, + }, + value: 546, + }, + { + txid: '3298edc745bdc2168e949382fd42956a7bbe43ab885a49f1212b097ac8243650', + vout: 1, + status: { + confirmed: true, + block_height: 2586064, + block_hash: '0000000000000019390bbd88e463230fa4bcc0e8313081c7a4e25fe0b3024712', + block_time: 1712920121, + }, + value: 546, + }, +]; diff --git a/tests/page-object-models/onboarding.page.ts b/tests/page-object-models/onboarding.page.ts index 1c54efe4791..e1d17fd4010 100644 --- a/tests/page-object-models/onboarding.page.ts +++ b/tests/page-object-models/onboarding.page.ts @@ -3,6 +3,7 @@ import { TEST_PASSWORD } from '@tests/mocks/constants'; import { HomePageSelectors } from '@tests/selectors/home.selectors'; import { OnboardingSelectors } from '@tests/selectors/onboarding.selectors'; +import type { SupportedBlockchains } from '@shared/constants'; import { RouteUrls } from '@shared/route-urls'; const TEST_ACCOUNT_SECRET_KEY = process.env.TEST_ACCOUNT_SECRET_KEY ?? ''; @@ -45,181 +46,189 @@ export const testSoftwareAccountDefaultWalletState = { _persist: { version: 2, rehydrated: true }, }; -const testLedgerAccountDefaultWalletState = { - _persist: { rehydrated: true, version: 2 }, - chains: { stx: { default: { currentAccountIndex: 0, highestAccountIndex: 0 } } }, - softwareKeys: { - entities: {}, - ids: [], - }, - ledger: { - bitcoin: { - entities: { - "default/84'/0'/0'": { - id: "default/84'/0'/0'", - path: "m/84'/0'/0'", - policy: - "[e87a850b/84'/0'/0']xpub6BuKrNqTrGfsy8VAAdUW2KCxbHywuSKjg7hZuAXERXDv7GfuxUgUWdVRKNsgujcwdjEHCjaXWouPKi1m5gMgdWX8JpRcyMkrSxPe4Da3Lx8", - walletId: 'default', - targetId: '', - }, - "default/84'/0'/1'": { - id: "default/84'/0'/1'", - path: "m/84'/0'/1'", - policy: - "[e87a850b/84'/0'/1']xpub6BuKrNqTrGft1dv2pR3Ey8VsBnSBkVVpehNsro8V8kaWMRGeUNv8yhJpTw62Ldqenm5kuVyC2bQqgc6yrKAruDKyzz18zi83Sg2FTwEHsrF", - walletId: 'default', - targetId: '', - }, - "default/84'/0'/2'": { - id: "default/84'/0'/2'", - path: "m/84'/0'/2'", - policy: - "[e87a850b/84'/0'/2']xpub6BuKrNqTrGft5UhSiYcXtN1d9Cp8iwj9tBVLjfJtLUqUFYA2xjVmAiB4TbUP6uaX3qwNhrW3baGE1Fz49YNSFcEMTtcd4Uz25juszoCCy8w", - walletId: 'default', - targetId: '', - }, - "default/84'/0'/3'": { - id: "default/84'/0'/3'", - path: "m/84'/0'/3'", - policy: - "[e87a850b/84'/0'/3']xpub6BuKrNqTrGft7h39ks3qJjcz3KusNtsDtr8t59t2MUneWoCqbGYLcqLeqRaXC5na2tWDDzncBBVNVPT55b6jLM4dT5f6aGvgaXEXV6VniL6", - walletId: 'default', - targetId: '', - }, - "default/84'/0'/4'": { - id: "default/84'/0'/4'", - path: "m/84'/0'/4'", - policy: - "[e87a850b/84'/0'/4']xpub6BuKrNqTrGftAswPZxdCzxArCp1bsUh3JPizsMymSVanfVJqXR2wjsX7PBnwMXnXttiWU6pMdBgB82mR2BPDtSGcUfjD8QJTNca47iYkGD3", - walletId: 'default', - targetId: '', - }, - "default/86'/0'/0'": { - id: "default/86'/0'/0'", - path: "m/86'/0'/0'", - policy: - "[e87a850b/86'/0'/0']xpub6C4MQD2bVDTfdnVe5AYKB6gE7BE4yQeKBRgukQ4Hi3phDB5fCYKEAdViQ2n7kZQ1t728QV4wKGgiR5qGigjNNrm5DCGWYUZDRVNWYb8ZWGK", - walletId: 'default', - targetId: '', - }, - "default/86'/0'/1'": { - id: "default/86'/0'/1'", - path: "m/86'/0'/1'", - policy: - "[e87a850b/86'/0'/1']xpub6C4MQD2bVDTfgjjWZhmPMNDMFHFmrSmGzqJVpuf98XB8F5eNaQus6XmrcrTrTiiL2EscdC4cjztP5LfaW13vZ6eDuDHXXAq71W5KEHeEeKH", - walletId: 'default', - targetId: '', - }, - "default/86'/0'/2'": { - id: "default/86'/0'/2'", - path: "m/86'/0'/2'", - policy: - "[e87a850b/86'/0'/2']xpub6C4MQD2bVDTfkGnARZXj6dRRF223bcyKAK2qCRKf9xyPQg7k4ZZc4FAHLcXhQ1NCVJCTVGEMd1YoRnBBDdgXKrmt4bm5XmF1ry9ox4Qsx3F", - walletId: 'default', - targetId: '', - }, - "default/86'/0'/3'": { - id: "default/86'/0'/3'", - path: "m/86'/0'/3'", - policy: - "[e87a850b/86'/0'/3']xpub6C4MQD2bVDTfmbN4ZJfozbNRMqyD1jmMFcQTNRUNyjE2J6tdVggFoQ8KmxUpijsZX1E4iDciY5AmnHbq95BHMVGJAGZ1MAm7iupHkTBV6YE", - walletId: 'default', - targetId: '', - }, - "default/86'/0'/4'": { - id: "default/86'/0'/4'", - path: "m/86'/0'/4'", - policy: - "[e87a850b/86'/0'/4']xpub6C4MQD2bVDTfq9RLtYxmqJRNsiviyuM51CFE1qqQbE6o8QN9Uix47Kvj4fqKFX5f88DyhxaX93L4H1WdSZChMZUWGUzPm54N9VfvsYJBvi9", - walletId: 'default', - targetId: '', - }, - }, - ids: [ - "default/84'/0'/0'", - "default/84'/0'/1'", - "default/84'/0'/2'", - "default/84'/0'/3'", - "default/84'/0'/4'", - "default/86'/0'/0'", - "default/86'/0'/1'", - "default/86'/0'/2'", - "default/86'/0'/3'", - "default/86'/0'/4'", - ], +const ledgerBitcoinKeysState = { + entities: { + "default/84'/0'/0'": { + id: "default/84'/0'/0'", + path: "m/84'/0'/0'", + policy: + "[e87a850b/84'/0'/0']xpub6BuKrNqTrGfsy8VAAdUW2KCxbHywuSKjg7hZuAXERXDv7GfuxUgUWdVRKNsgujcwdjEHCjaXWouPKi1m5gMgdWX8JpRcyMkrSxPe4Da3Lx8", + walletId: 'default', + targetId: '', }, - stacks: { - entities: { - "default/44'/5757'/0'/0/0": { - path: "m/44'/5757'/0'/0/0", - stxPublicKey: '0329b076bc20f7b1592b2a1a5cb91dfefe8c966e50e256458e23dd2c5d63f8f1af', - dataPublicKey: - '04716759aa2d2ec9066ff699626c3404c5cc7e84e7295af6768a0fce2defcd1c50a9ee4b1fd1e63295abc47c81f602e77c497f4549fa68535c7abbe73854b62df7', - id: "default/44'/5757'/0'/0/0", - walletId: 'default', - targetId: '', - }, - "default/44'/5757'/0'/0/1": { - path: "m/44'/5757'/0'/0/1", - stxPublicKey: '035c63a8042cd820ae59b50cfb225b886d0837c97a5f5daa190037fcadf60a1da6', - dataPublicKey: - '04c8fba749c7be4a817c1bee8c24b7464f3be6f7e78f5c9ab43a57710f703155e059ce8b5fcb33e8c8d0ff154e964f99c486eed8b8b19f108cf5137a07275a277f', - id: "default/44'/5757'/0'/0/1", - walletId: 'default', - targetId: '', - }, - "default/44'/5757'/0'/0/2": { - path: "m/44'/5757'/0'/0/2", - stxPublicKey: '02dbcd4e19f13709889eebdb450f84b48195f8ada1673cd8e663ca409a09379740', - dataPublicKey: - '04614af2cb5b9a07fb9049713a860a09cd97549373e73104e32b814922392a97a3c6d938f2b7f6e771c5e6611be64b762919a435a242fa5796b5bb4b9728eb079e', - id: "default/44'/5757'/0'/0/2", - walletId: 'default', - targetId: '', - }, - "default/44'/5757'/0'/0/3": { - path: "m/44'/5757'/0'/0/3", - stxPublicKey: '03a9ee7ccb82ecdd9de236b4d1909f79e75d93ba0ae68494f0cf710a5bf1e47837', - dataPublicKey: - '04e3c33077024159f2a1aa28e4e73811d477fac3303f6395bfb8937994bc61d1a3b762d52ea4a57d0f2ed36523a96ffec74d1f05676e4411601402013f16f16374', - id: "default/44'/5757'/0'/0/3", - walletId: 'default', - targetId: '', - }, - "default/44'/5757'/0'/0/4": { - path: "m/44'/5757'/0'/0/4", - stxPublicKey: '03e8e4daeece139da8e03d06734712b3dce83175791b94f44185c3fdae9122d264', - dataPublicKey: - '04673e21fc8fb98131d843bcb10edb015dd3219bb1f730c81c6de13a9df91d5f1a709099cd0d41d535f45b3119d3458ccdc98614ee4833c99f09c7c62d654350fa', - id: "default/44'/5757'/0'/0/4", - walletId: 'default', - targetId: '', - }, - }, - ids: [ - "default/44'/5757'/0'/0/0", - "default/44'/5757'/0'/0/1", - "default/44'/5757'/0'/0/2", - "default/44'/5757'/0'/0/3", - "default/44'/5757'/0'/0/4", - ], + "default/84'/0'/1'": { + id: "default/84'/0'/1'", + path: "m/84'/0'/1'", + policy: + "[e87a850b/84'/0'/1']xpub6BuKrNqTrGft1dv2pR3Ey8VsBnSBkVVpehNsro8V8kaWMRGeUNv8yhJpTw62Ldqenm5kuVyC2bQqgc6yrKAruDKyzz18zi83Sg2FTwEHsrF", + walletId: 'default', + targetId: '', + }, + "default/84'/0'/2'": { + id: "default/84'/0'/2'", + path: "m/84'/0'/2'", + policy: + "[e87a850b/84'/0'/2']xpub6BuKrNqTrGft5UhSiYcXtN1d9Cp8iwj9tBVLjfJtLUqUFYA2xjVmAiB4TbUP6uaX3qwNhrW3baGE1Fz49YNSFcEMTtcd4Uz25juszoCCy8w", + walletId: 'default', + targetId: '', + }, + "default/84'/0'/3'": { + id: "default/84'/0'/3'", + path: "m/84'/0'/3'", + policy: + "[e87a850b/84'/0'/3']xpub6BuKrNqTrGft7h39ks3qJjcz3KusNtsDtr8t59t2MUneWoCqbGYLcqLeqRaXC5na2tWDDzncBBVNVPT55b6jLM4dT5f6aGvgaXEXV6VniL6", + walletId: 'default', + targetId: '', + }, + "default/84'/0'/4'": { + id: "default/84'/0'/4'", + path: "m/84'/0'/4'", + policy: + "[e87a850b/84'/0'/4']xpub6BuKrNqTrGftAswPZxdCzxArCp1bsUh3JPizsMymSVanfVJqXR2wjsX7PBnwMXnXttiWU6pMdBgB82mR2BPDtSGcUfjD8QJTNca47iYkGD3", + walletId: 'default', + targetId: '', + }, + "default/86'/0'/0'": { + id: "default/86'/0'/0'", + path: "m/86'/0'/0'", + policy: + "[e87a850b/86'/0'/0']xpub6C4MQD2bVDTfdnVe5AYKB6gE7BE4yQeKBRgukQ4Hi3phDB5fCYKEAdViQ2n7kZQ1t728QV4wKGgiR5qGigjNNrm5DCGWYUZDRVNWYb8ZWGK", + walletId: 'default', + targetId: '', + }, + "default/86'/0'/1'": { + id: "default/86'/0'/1'", + path: "m/86'/0'/1'", + policy: + "[e87a850b/86'/0'/1']xpub6C4MQD2bVDTfgjjWZhmPMNDMFHFmrSmGzqJVpuf98XB8F5eNaQus6XmrcrTrTiiL2EscdC4cjztP5LfaW13vZ6eDuDHXXAq71W5KEHeEeKH", + walletId: 'default', + targetId: '', + }, + "default/86'/0'/2'": { + id: "default/86'/0'/2'", + path: "m/86'/0'/2'", + policy: + "[e87a850b/86'/0'/2']xpub6C4MQD2bVDTfkGnARZXj6dRRF223bcyKAK2qCRKf9xyPQg7k4ZZc4FAHLcXhQ1NCVJCTVGEMd1YoRnBBDdgXKrmt4bm5XmF1ry9ox4Qsx3F", + walletId: 'default', + targetId: '', + }, + "default/86'/0'/3'": { + id: "default/86'/0'/3'", + path: "m/86'/0'/3'", + policy: + "[e87a850b/86'/0'/3']xpub6C4MQD2bVDTfmbN4ZJfozbNRMqyD1jmMFcQTNRUNyjE2J6tdVggFoQ8KmxUpijsZX1E4iDciY5AmnHbq95BHMVGJAGZ1MAm7iupHkTBV6YE", + walletId: 'default', + targetId: '', + }, + "default/86'/0'/4'": { + id: "default/86'/0'/4'", + path: "m/86'/0'/4'", + policy: + "[e87a850b/86'/0'/4']xpub6C4MQD2bVDTfq9RLtYxmqJRNsiviyuM51CFE1qqQbE6o8QN9Uix47Kvj4fqKFX5f88DyhxaX93L4H1WdSZChMZUWGUzPm54N9VfvsYJBvi9", + walletId: 'default', targetId: '', }, }, - networks: { currentNetworkId: 'mainnet', entities: {}, ids: [] }, - onboarding: { - hideSteps: false, - stepsStatus: { - 'Add some funds': 0, - 'Back up secret key': 1, - 'Buy an NFT': 0, - 'Explore apps': 0, + ids: [ + "default/84'/0'/0'", + "default/84'/0'/1'", + "default/84'/0'/2'", + "default/84'/0'/3'", + "default/84'/0'/4'", + "default/86'/0'/0'", + "default/86'/0'/1'", + "default/86'/0'/2'", + "default/86'/0'/3'", + "default/86'/0'/4'", + ], +}; + +const ledgerStacksKeysState = { + entities: { + "default/44'/5757'/0'/0/0": { + path: "m/44'/5757'/0'/0/0", + stxPublicKey: '0329b076bc20f7b1592b2a1a5cb91dfefe8c966e50e256458e23dd2c5d63f8f1af', + dataPublicKey: + '04716759aa2d2ec9066ff699626c3404c5cc7e84e7295af6768a0fce2defcd1c50a9ee4b1fd1e63295abc47c81f602e77c497f4549fa68535c7abbe73854b62df7', + id: "default/44'/5757'/0'/0/0", + walletId: 'default', + targetId: '', + }, + "default/44'/5757'/0'/0/1": { + path: "m/44'/5757'/0'/0/1", + stxPublicKey: '035c63a8042cd820ae59b50cfb225b886d0837c97a5f5daa190037fcadf60a1da6', + dataPublicKey: + '04c8fba749c7be4a817c1bee8c24b7464f3be6f7e78f5c9ab43a57710f703155e059ce8b5fcb33e8c8d0ff154e964f99c486eed8b8b19f108cf5137a07275a277f', + id: "default/44'/5757'/0'/0/1", + walletId: 'default', + targetId: '', + }, + "default/44'/5757'/0'/0/2": { + path: "m/44'/5757'/0'/0/2", + stxPublicKey: '02dbcd4e19f13709889eebdb450f84b48195f8ada1673cd8e663ca409a09379740', + dataPublicKey: + '04614af2cb5b9a07fb9049713a860a09cd97549373e73104e32b814922392a97a3c6d938f2b7f6e771c5e6611be64b762919a435a242fa5796b5bb4b9728eb079e', + id: "default/44'/5757'/0'/0/2", + walletId: 'default', + targetId: '', + }, + "default/44'/5757'/0'/0/3": { + path: "m/44'/5757'/0'/0/3", + stxPublicKey: '03a9ee7ccb82ecdd9de236b4d1909f79e75d93ba0ae68494f0cf710a5bf1e47837', + dataPublicKey: + '04e3c33077024159f2a1aa28e4e73811d477fac3303f6395bfb8937994bc61d1a3b762d52ea4a57d0f2ed36523a96ffec74d1f05676e4411601402013f16f16374', + id: "default/44'/5757'/0'/0/3", + walletId: 'default', + targetId: '', + }, + "default/44'/5757'/0'/0/4": { + path: "m/44'/5757'/0'/0/4", + stxPublicKey: '03e8e4daeece139da8e03d06734712b3dce83175791b94f44185c3fdae9122d264', + dataPublicKey: + '04673e21fc8fb98131d843bcb10edb015dd3219bb1f730c81c6de13a9df91d5f1a709099cd0d41d535f45b3119d3458ccdc98614ee4833c99f09c7c62d654350fa', + id: "default/44'/5757'/0'/0/4", + walletId: 'default', + targetId: '', }, }, - settings: { dismissedMessages: [], hasAllowedAnalytics: false, userSelectedTheme: 'system' }, + ids: [ + "default/44'/5757'/0'/0/0", + "default/44'/5757'/0'/0/1", + "default/44'/5757'/0'/0/2", + "default/44'/5757'/0'/0/3", + "default/44'/5757'/0'/0/4", + ], + targetId: '', }; +const emptyKeysState = { entities: {}, ids: [] }; + +export function makeLedgerTestAccountWalletState(keysToInclude: SupportedBlockchains[]) { + return { + _persist: { rehydrated: true, version: 2 }, + chains: { stx: { default: { currentAccountIndex: 0, highestAccountIndex: 0 } } }, + softwareKeys: { + entities: {}, + ids: [], + }, + ledger: { + bitcoin: keysToInclude.includes('bitcoin') ? ledgerBitcoinKeysState : emptyKeysState, + stacks: keysToInclude.includes('stacks') ? ledgerStacksKeysState : emptyKeysState, + }, + networks: { currentNetworkId: 'mainnet', entities: {}, ids: [] }, + onboarding: { + hideSteps: false, + stepsStatus: { + 'Add some funds': 0, + 'Back up secret key': 1, + 'Buy an NFT': 0, + 'Explore apps': 0, + }, + }, + settings: { dismissedMessages: [], hasAllowedAnalytics: false, userSelectedTheme: 'system' }, + }; +} + export class OnboardingPage { constructor(readonly page: Page) {} @@ -294,10 +303,10 @@ export class OnboardingPage { * onboarding flow and initialise the wallet in a signed in state for the test * account */ - async signInWithLedgerAccount(id: string) { + async signInWithLedgerAccount(id: string, state: object) { await this.page.evaluate( async walletState => chrome.storage.local.set({ 'persist:root': walletState }), - testLedgerAccountDefaultWalletState + state ); await this.page.goto(`chrome-extension://${id}/index.html`); } diff --git a/tests/specs/ledger/ledger.spec.ts b/tests/specs/ledger/ledger.spec.ts index 398934113ff..2425516714c 100644 --- a/tests/specs/ledger/ledger.spec.ts +++ b/tests/specs/ledger/ledger.spec.ts @@ -1,29 +1,47 @@ import { TEST_ACCOUNT_1_STX_ADDRESS } from '@tests/mocks/constants'; +import { makeLedgerTestAccountWalletState } from '@tests/page-object-models/onboarding.page'; import { test } from '../../fixtures/fixtures'; +const specs = { + withBitcoinAndStacksKey: makeLedgerTestAccountWalletState(['bitcoin', 'stacks']), + withStacksKeysOnly: makeLedgerTestAccountWalletState(['stacks']), + withBitcoinKeysOnly: makeLedgerTestAccountWalletState(['bitcoin']), +}; + test.describe('App with Ledger', () => { - test.beforeEach(async ({ extensionId, globalPage, onboardingPage }) => { - await globalPage.setupAndUseApiCalls(extensionId); - await onboardingPage.signInWithLedgerAccount(extensionId); - }); + for (const [testName, state] of Object.entries(specs)) { + test.describe(testName, () => { + test.beforeEach(async ({ extensionId, globalPage, onboardingPage }) => { + await globalPage.setupAndUseApiCalls(extensionId); + await onboardingPage.signInWithLedgerAccount(extensionId, state); + }); + + test('that homepage renders correctly', async ({ homePage }) => { + await test.expect(homePage.page.locator('text="Send"').first()).toBeVisible(); + await test.expect(homePage.page.locator('text="Receive"').first()).toBeVisible(); + await test.expect(homePage.page.locator('text="Buy"').first()).toBeVisible(); + }); - test('that homepage renders correctly', async ({ homePage }) => { - await test.expect(homePage.page.locator('text="Send"').first()).toBeVisible(); - await test.expect(homePage.page.locator('text="Receive"').first()).toBeVisible(); - await test.expect(homePage.page.locator('text="Buy"').first()).toBeVisible(); - }); + test('receive modal opens', async ({ homePage }) => { + await homePage.goToReceiveDialog(); + test.expect(homePage.page.url()).toContain('/receive'); + }); - test('that receive modal opens', async ({ homePage }) => { - const stacksAddress = await homePage.getReceiveStxAddress(); - test.expect(stacksAddress).toEqual(TEST_ACCOUNT_1_STX_ADDRESS); - }); + if (testName === 'withStacksKeysOnly') { + test('stacks address is shown by default', async ({ homePage }) => { + const stacksAddress = await homePage.getReceiveStxAddress(); + test.expect(stacksAddress).toEqual(TEST_ACCOUNT_1_STX_ADDRESS); + }); + } - test('that you can navigate to activity page', async ({ homePage }) => { - await homePage.clickActivityTab(); - const noActivityText = homePage.page.getByText('No activity yet'); - // Account has activity to make sure we don't see label - await test.expect(noActivityText).not.toBeVisible(); - test.expect(homePage.page.url()).toContain('/activity'); - }); + test('that you can navigate to activity page', async ({ homePage }) => { + await homePage.clickActivityTab(); + const noActivityText = homePage.page.getByText('No activity yet'); + // Account has activity to make sure we don't see label + await test.expect(noActivityText).not.toBeVisible(); + test.expect(homePage.page.url()).toContain('/activity'); + }); + }); + } }); diff --git a/tests/specs/send/send-btc.spec.ts b/tests/specs/send/send-btc.spec.ts index 4ef10d57b09..4e40a7371b4 100644 --- a/tests/specs/send/send-btc.spec.ts +++ b/tests/specs/send/send-btc.spec.ts @@ -100,9 +100,65 @@ test.describe('send btc', () => { await sendPage.clickInfoCardButton(); - const isErrorPageVisible = await sendPage.broadcastErrorTitle.isVisible(); + await test.expect(sendPage.broadcastErrorTitle).toBeVisible(); + }); + + test('that fallbacks to other api provider if main fails', async ({ sendPage }) => { + let output = ''; + let id = ''; + let index = ''; + + await sendPage.page.route('**/ordinals-explorer.generative.xyz/**', async route => { + return route.fulfill({ + status: 500, + contentType: 'text/html', + body: mockOrdinalsComApiHtmlResponse, + }); + }); + + sendPage.page.on('request', async request => { + if (request.url().includes('ordinals-explorer.generative.xyz')) { + const url = request.url(); + output = url.split('/').pop() || ''; + id = output.split(':')[0]; + index = output.split(':')[1]; + } + }); + + await sendPage.page.route( + '**/leatherapi.bestinslot.xyz/v3/inscription/in_transaction**', + async route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: [{ txid: id, output, index, satpoint: output }], + }), + }); + } + ); + + await sendPage.page.route( + '**/leatherapi.bestinslot.xyz/v3/inscription/single_info_id**', + async route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { txid: id, output, index, satpoint: output }, + }), + }); + } + ); + await sendPage.amountInput.fill('0.00006'); + await sendPage.recipientInput.fill(TEST_TESTNET_ACCOUNT_2_BTC_ADDRESS); + + await sendPage.previewSendTxButton.click(); + await sendPage.feesListItem.filter({ hasText: BtcFeeType.High }).click(); + + await sendPage.clickInfoCardButton(); - test.expect(isErrorPageVisible).toBeTruthy(); + await test.expect(sendPage.broadcastErrorTitle).toBeVisible(); }); }); });