diff --git a/apps/wallet-mobile/src/assets/img/chart-placeholder.png b/apps/wallet-mobile/src/assets/img/chart-placeholder.png deleted file mode 100644 index 383d949c9f..0000000000 Binary files a/apps/wallet-mobile/src/assets/img/chart-placeholder.png and /dev/null differ diff --git a/apps/wallet-mobile/src/assets/img/chart-placeholder@2x.png b/apps/wallet-mobile/src/assets/img/chart-placeholder@2x.png deleted file mode 100644 index 35590292b7..0000000000 Binary files a/apps/wallet-mobile/src/assets/img/chart-placeholder@2x.png and /dev/null differ diff --git a/apps/wallet-mobile/src/assets/img/chart-placeholder@3x.png b/apps/wallet-mobile/src/assets/img/chart-placeholder@3x.png deleted file mode 100644 index 83bbf8a4cf..0000000000 Binary files a/apps/wallet-mobile/src/assets/img/chart-placeholder@3x.png and /dev/null differ diff --git a/apps/wallet-mobile/src/components/Icon/MinSwap.tsx b/apps/wallet-mobile/src/components/Icon/MinSwap.tsx index b6469e3443..778fdbdcc9 100644 --- a/apps/wallet-mobile/src/components/Icon/MinSwap.tsx +++ b/apps/wallet-mobile/src/components/Icon/MinSwap.tsx @@ -1,25 +1,29 @@ import React from 'react' -import Svg, {Defs, Image, Path, Pattern, Use} from 'react-native-svg' +import Svg, {Path} from 'react-native-svg' type Props = { size?: number } export const MinSwap = ({size = 36}: Props) => ( - - + + - - - - + - - + ) diff --git a/apps/wallet-mobile/src/components/Icon/SundaeSwap.tsx b/apps/wallet-mobile/src/components/Icon/SundaeSwap.tsx index c218ef9328..c11744f735 100644 --- a/apps/wallet-mobile/src/components/Icon/SundaeSwap.tsx +++ b/apps/wallet-mobile/src/components/Icon/SundaeSwap.tsx @@ -1,25 +1,78 @@ import React from 'react' -import Svg, {Defs, Image, Path, Pattern, Use} from 'react-native-svg' +import Svg, {Path} from 'react-native-svg' type Props = { size?: number } export const SundaeSwap = ({size = 36}: Props) => ( - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) diff --git a/apps/wallet-mobile/src/components/PairedBalance/PairedBalance.tsx b/apps/wallet-mobile/src/components/PairedBalance/PairedBalance.tsx index 56c91435c5..e5a450f392 100644 --- a/apps/wallet-mobile/src/components/PairedBalance/PairedBalance.tsx +++ b/apps/wallet-mobile/src/components/PairedBalance/PairedBalance.tsx @@ -64,7 +64,7 @@ const Price = ({amount, textStyle, ignorePrivacy, hidePrimaryPair}: Props) => { if (isPrivacyActive && !ignorePrivacy) return `${privacyPlaceholder} ${currency}` - if (!isPrimaryToken(amount.info) && tokenPrice == null) return `—— ${currency}` + if (!isPrimaryToken(amount.info) && tokenPrice == null) return `— ${currency}` if (hidePrimaryPair && isPrimaryToken(amount.info) && isPrimaryTokenActive) return '' diff --git a/apps/wallet-mobile/src/features/Exchange/common/ShowBuyBanner/ShowBuyBanner.tsx b/apps/wallet-mobile/src/features/Exchange/common/ShowBuyBanner/ShowBuyBanner.tsx index be5d3b5c2e..69e03bbcc8 100644 --- a/apps/wallet-mobile/src/features/Exchange/common/ShowBuyBanner/ShowBuyBanner.tsx +++ b/apps/wallet-mobile/src/features/Exchange/common/ShowBuyBanner/ShowBuyBanner.tsx @@ -1,7 +1,8 @@ import {Chain} from '@yoroi/types' +import _ from 'lodash' import * as React from 'react' -import {useBalances} from '../../../../yoroi-wallets/hooks' +import {useBalances, useTransactionInfos} from '../../../../yoroi-wallets/hooks' import {Amounts, Quantities} from '../../../../yoroi-wallets/utils/utils' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' import {useWalletManager} from '../../../WalletManager/context/WalletManagerProvider' @@ -14,19 +15,21 @@ import {SanchonetFaucetBanner} from './SanchonetFaucetBanner' export const ShowBuyBanner = () => { const {wallet} = useSelectedWallet() + const transactionInfos = useTransactionInfos({wallet}) const { selected: {network}, } = useWalletManager() const balances = useBalances(wallet) const primaryAmount = Amounts.getAmount(balances, wallet.portfolioPrimaryTokenInfo.id) const hasZeroPt = Quantities.isZero(primaryAmount.quantity) + const hasZeroTx = _.isEmpty(transactionInfos) const showSmallBanner = useShowBuyBannerSmall() const {resetShowBuyBannerSmall} = useResetShowBuyBannerSmall() - if (hasZeroPt && network === Chain.Network.Preprod) return - if (hasZeroPt && network === Chain.Network.Sancho) return - if (hasZeroPt) return + if (hasZeroPt && hasZeroTx && network === Chain.Network.Preprod) return + if (hasZeroPt && hasZeroTx && network === Chain.Network.Sancho) return + if (hasZeroPt && hasZeroTx) return if (showSmallBanner) return return null diff --git a/apps/wallet-mobile/src/features/Portfolio/PortfolioNavigator.tsx b/apps/wallet-mobile/src/features/Portfolio/PortfolioNavigator.tsx index daf86d3d52..9ca226fec0 100644 --- a/apps/wallet-mobile/src/features/Portfolio/PortfolioNavigator.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/PortfolioNavigator.tsx @@ -8,7 +8,7 @@ import {SearchProvider} from '../Search/SearchContext' import {NetworkTag} from '../Settings/ChangeNetwork/NetworkTag' import {TxDetails} from '../Transactions/useCases/TxDetails/TxDetails' import {useStrings} from './common/hooks/useStrings' -import {PortfolioTokenDetailProvider} from './common/PortfolioTokenDetailContext' +import {PortfolioProvider} from './common/PortfolioProvider' import {NftsNavigator} from './NftsNavigator' import {PortfolioDashboardScreen} from './useCases/PortfolioDashboard/PortfolioDashboardScreen' import ExportTokenTransactions from './useCases/PortfolioTokenDetails/ExportTokenTransactions' @@ -22,7 +22,7 @@ export const PortfolioNavigator = () => { const strings = useStrings() return ( - + { )} - + ) } diff --git a/apps/wallet-mobile/src/features/Portfolio/common/PortfolioProvider.tsx b/apps/wallet-mobile/src/features/Portfolio/common/PortfolioProvider.tsx index 6f83e4547f..0a1b1662ae 100644 --- a/apps/wallet-mobile/src/features/Portfolio/common/PortfolioProvider.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/common/PortfolioProvider.tsx @@ -2,16 +2,47 @@ import {invalid} from '@yoroi/common' import {produce} from 'immer' import * as React from 'react' +export const PortfolioDetailsTab = { + Performance: 'Performance', + Overview: 'Overview', + Transactions: 'Transactions', +} as const +export type PortfolioDetailsTab = (typeof PortfolioDetailsTab)[keyof typeof PortfolioDetailsTab] + +export const PortfolioListTab = { + Wallet: 'Wallet', + Dapps: 'Dapps', +} as const +export type PortfolioListTab = (typeof PortfolioListTab)[keyof typeof PortfolioListTab] + +export const PortfolioDappsTab = { + LiquidityPool: 'LiquidityPool', + OpenOrders: 'OpenOrders', + LendAndBorrow: 'LendAndBorrow', +} as const + +export type PortfolioDappsTab = (typeof PortfolioDappsTab)[keyof typeof PortfolioDappsTab] + const defaultActions: PortfolioActions = { setIsPrimaryTokenActive: () => invalid('missing init'), + setDetailsTab: () => invalid('missing init'), + setListTab: () => invalid('missing init'), + setDappsTab: () => invalid('missing init'), + resetTabs: () => invalid('missing init'), } as const const defaultState: PortfolioState = { isPrimaryTokenActive: false, + detailsTab: PortfolioDetailsTab.Overview, + listTab: PortfolioListTab.Wallet, + dappsTab: PortfolioDappsTab.LiquidityPool, } as const type PortfolioState = { isPrimaryTokenActive: boolean + detailsTab: PortfolioDetailsTab + listTab: PortfolioListTab + dappsTab: PortfolioDappsTab } const PortfolioContext = React.createContext({ @@ -32,6 +63,16 @@ export const PortfolioProvider = ({ setIsPrimaryTokenActive: (isActive) => { dispatch({type: PortfolioActionType.SetIsPrimaryTokenActive, payload: {isActive}}) }, + setDetailsTab: (tab) => { + dispatch({type: PortfolioActionType.SetDetailsTab, payload: {tab}}) + }, + setListTab: (tab) => { + dispatch({type: PortfolioActionType.SetListTab, payload: {tab}}) + }, + setDappsTab: (tab) => { + dispatch({type: PortfolioActionType.SetDappsTab, payload: {tab}}) + }, + resetTabs: () => dispatch({type: PortfolioActionType.ResetTabs}), }).current const context = React.useMemo( @@ -45,17 +86,52 @@ export const PortfolioProvider = ({ export const usePortfolio = () => React.useContext(PortfolioContext) ?? {isPrimaryTokenActive: false, setIsPrimaryTokenActive: () => null} -enum PortfolioActionType { - SetIsPrimaryTokenActive = 'setIsPrimaryTokenActive', -} +const PortfolioActionType = { + SetIsPrimaryTokenActive: 'SetIsPrimaryTokenActive', + SetDetailsTab: 'SetDetailsTab', + SetListTab: 'SetListTab', + SetDappsTab: 'SetDappsTab', + ResetTabs: 'ResetTabs', +} as const +type PortfolioActionType = (typeof PortfolioActionType)[keyof typeof PortfolioActionType] + +type PortfolioContextAction = + | SetIsPrimaryTokenActiveAction + | SetDetailsTabAction + | SetListTabAction + | SetDappsTabAction + | ResetTabsAction -type PortfolioContextAction = { - type: PortfolioActionType.SetIsPrimaryTokenActive +type SetIsPrimaryTokenActiveAction = { + type: typeof PortfolioActionType.SetIsPrimaryTokenActive payload: {isActive: boolean} } +type SetDetailsTabAction = { + type: typeof PortfolioActionType.SetDetailsTab + payload: {tab: PortfolioDetailsTab} +} + +type SetListTabAction = { + type: typeof PortfolioActionType.SetListTab + payload: {tab: PortfolioListTab} +} + +type SetDappsTabAction = { + type: typeof PortfolioActionType.SetDappsTab + payload: {tab: PortfolioDappsTab} +} + +type ResetTabsAction = { + type: typeof PortfolioActionType.ResetTabs +} + type PortfolioActions = Readonly<{ setIsPrimaryTokenActive: (isActive: boolean) => void + setDetailsTab: (tab: PortfolioDetailsTab) => void + setListTab: (tab: PortfolioListTab) => void + setDappsTab: (tab: PortfolioDappsTab) => void + resetTabs: () => void }> const portfolioReducer = (state: PortfolioState, action: PortfolioContextAction): PortfolioState => { @@ -64,6 +140,20 @@ const portfolioReducer = (state: PortfolioState, action: PortfolioContextAction) case PortfolioActionType.SetIsPrimaryTokenActive: draft.isPrimaryTokenActive = action.payload.isActive break + case PortfolioActionType.SetDetailsTab: + draft.detailsTab = action.payload.tab + break + case PortfolioActionType.SetListTab: + draft.listTab = action.payload.tab + break + case PortfolioActionType.SetDappsTab: + draft.dappsTab = action.payload.tab + break + case PortfolioActionType.ResetTabs: + draft.detailsTab = defaultState.detailsTab + draft.listTab = defaultState.listTab + draft.dappsTab = defaultState.dappsTab + break } }) } diff --git a/apps/wallet-mobile/src/features/Portfolio/common/PortfolioTokenDetailContext.tsx b/apps/wallet-mobile/src/features/Portfolio/common/PortfolioTokenDetailContext.tsx deleted file mode 100644 index bc28d4d4f1..0000000000 --- a/apps/wallet-mobile/src/features/Portfolio/common/PortfolioTokenDetailContext.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' - -import {features} from '../../../kernel/features' - -type ActiveTab = 'performance' | 'overview' | 'transactions' -interface TokenDetailContext { - activeTab: ActiveTab - setActiveTab: (value: ActiveTab) => void -} - -const Context = React.createContext(null) - -export const usePortfolioTokenDetailContext = () => { - const ctx = React.useContext(Context) - - if (ctx === null) { - throw new Error('PortfolioTokenDetailProvider is missing') - } - - return ctx -} - -export const PortfolioTokenDetailProvider = ({children}: {children: React.ReactNode}) => { - const [activeTab, setActiveTab] = React.useState( - features.portfolioPerformance ? 'performance' : 'overview', - ) - const setActiveTabTx = (value: ActiveTab) => { - React.startTransition(() => { - setActiveTab(value) - }) - } - - return ( - - {children} - - ) -} diff --git a/apps/wallet-mobile/src/features/Portfolio/common/hooks/useGetPortfolioTokenChart.ts b/apps/wallet-mobile/src/features/Portfolio/common/hooks/useGetPortfolioTokenChart.ts index 0ef0471d1c..431878febf 100644 --- a/apps/wallet-mobile/src/features/Portfolio/common/hooks/useGetPortfolioTokenChart.ts +++ b/apps/wallet-mobile/src/features/Portfolio/common/hooks/useGetPortfolioTokenChart.ts @@ -1,12 +1,14 @@ import {isRight} from '@yoroi/common' import {isPrimaryToken} from '@yoroi/portfolio' -import {Chain} from '@yoroi/types' +import {Chain, Portfolio} from '@yoroi/types' import {useQuery, UseQueryOptions} from 'react-query' import {supportedCurrencies, time} from '../../../../kernel/constants' import {useLanguage} from '../../../../kernel/i18n' +import {logger} from '../../../../kernel/logger/logger' import {fetchPtPriceActivity} from '../../../../yoroi-wallets/cardano/usePrimaryTokenActivity' import {useCurrencyPairing} from '../../../Settings/Currency/CurrencyContext' +import {useSelectedNetwork} from '../../../WalletManager/common/hooks/useSelectedNetwork' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' import {networkConfigs} from '../../../WalletManager/network-manager/network-manager' import {priceChange} from '../helpers/priceChange' @@ -30,60 +32,6 @@ type TokenChartData = { changeValue: number } -function generateMockChartData(timeInterval: TokenChartInterval = TOKEN_CHART_INTERVAL.DAY): TokenChartData[] { - const dataPoints = 50 - const startValue = 100 - const volatility = 50 - - const startDate = new Date('2024-02-02T15:09:00') - - function getTimeIncrement(interval: TokenChartInterval): number { - switch (interval) { - case TOKEN_CHART_INTERVAL.DAY: - return 60 * 60 * 1000 // 1 hour - case TOKEN_CHART_INTERVAL.WEEK: - return 24 * 60 * 60 * 1000 // 1 day - case TOKEN_CHART_INTERVAL.MONTH: - return 30 * 24 * 60 * 60 * 1000 // 1 month (approximated as 30 days) - case TOKEN_CHART_INTERVAL.SIX_MONTHS: - return 6 * 30 * 24 * 60 * 60 * 1000 // 6 months - case TOKEN_CHART_INTERVAL.YEAR: - return 12 * 30 * 24 * 60 * 60 * 1000 // 1 year (approximated as 360 days) - default: - return 60 * 1000 // Default to 1 minute - } - } - - const increment = getTimeIncrement(timeInterval) - const chartData: TokenChartData[] = [] - - let previousValue = startValue - - for (let i = 0; i < dataPoints; i++) { - const date = new Date(startDate.getTime() + i * increment) - const label = `${date.getDate().toString().padStart(2, '0')}/${(date.getMonth() + 1) - .toString() - .padStart(2, '0')}/${date.getFullYear().toString().substr(-2)} ${date.getHours()}:${date - .getMinutes() - .toString() - .padStart(2, '0')}` - const value = i === 0 ? startValue : previousValue + (Math.random() - 0.5) * volatility - const changeValue = i === 0 ? 0 : value - previousValue - const changePercent = i === 0 ? 0 : (changeValue / previousValue) * 100 - - chartData.push({ - label, - value, - changePercent, - changeValue, - }) - - previousValue = value // Update previousValue for the next iteration - } - - return chartData -} - const getTimestamps = (timeInterval: TokenChartInterval) => { const now = Date.now() const [from, resolution] = { @@ -104,9 +52,9 @@ const ptTicker = networkConfigs[Chain.Network.Mainnet].primaryTokenInfo.ticker export const useGetPortfolioTokenChart = ( timeInterval = TOKEN_CHART_INTERVAL.DAY as TokenChartInterval, options: UseQueryOptions< - TokenChartData[], + TokenChartData[] | null, Error, - TokenChartData[], + TokenChartData[] | null, ['useGetPortfolioTokenChart', string, TokenChartInterval, ReturnType['currency']?] > = {}, ) => { @@ -114,6 +62,9 @@ export const useGetPortfolioTokenChart = ( const { wallet: {balances}, } = useSelectedWallet() + const { + networkManager: {tokenManager}, + } = useSelectedNetwork() const tokenInfo = balances.records.get(tokenId) const {currency} = useCurrencyPairing() const {languageCode} = useLanguage() @@ -135,6 +86,8 @@ export const useGetPortfolioTokenChart = ( if (response.value.data.error) throw new Error(response.value.data.error) const tickers = response.value.data.tickers + if (tickers.length === 0) return null + const validCurrency = currency === ptTicker ? supportedCurrencies.USD : currency ?? supportedCurrencies.USD const initialPrice = tickers[0].prices[validCurrency] @@ -153,7 +106,8 @@ export const useGetPortfolioTokenChart = ( return records } - throw new Error('Failed to fetch token chart data') + logger.error('Failed to fetch token chart data for PT') + return null }, }) @@ -164,10 +118,42 @@ export const useGetPortfolioTokenChart = ( ...options, queryKey: ['useGetPortfolioTokenChart', tokenInfo?.info.id ?? '', timeInterval], queryFn: async () => { - await new Promise((resolve) => setTimeout(resolve, 1)) - return generateMockChartData(timeInterval) + const response = await tokenManager.api.tokenHistory(tokenId, chartIntervalToHistoryPeriod(timeInterval)) + if (isRight(response)) { + const prices = response.value.data.prices + + if (prices.length === 0) return null + + const initialPrice = prices[0].open.toNumber() + const records = prices + .map((price) => { + const value = price.close.toNumber() + if (value === undefined) return undefined + const {changePercent, changeValue} = priceChange(initialPrice, value) + const label = new Date(price.ts).toLocaleString(languageCode, { + dateStyle: 'short', + timeStyle: 'short', + }) + return {label, value, changePercent, changeValue} + }) + .filter(Boolean) as TokenChartData[] + + return records + } + logger.error(`Failed to fetch token chart data for ${tokenId}`) + return null }, }) return tokenInfo && isPrimaryToken(tokenInfo.info) ? ptQuery : otherQuery } + +const chartIntervalToHistoryPeriod = (i: TokenChartInterval): Portfolio.Token.HistoryPeriod => + ({ + [TOKEN_CHART_INTERVAL.DAY]: Portfolio.Token.HistoryPeriod.OneDay, + [TOKEN_CHART_INTERVAL.WEEK]: Portfolio.Token.HistoryPeriod.OneWeek, + [TOKEN_CHART_INTERVAL.MONTH]: Portfolio.Token.HistoryPeriod.OneMonth, + [TOKEN_CHART_INTERVAL.SIX_MONTHS]: Portfolio.Token.HistoryPeriod.SixMonth, + [TOKEN_CHART_INTERVAL.YEAR]: Portfolio.Token.HistoryPeriod.OneYear, + [TOKEN_CHART_INTERVAL.ALL]: Portfolio.Token.HistoryPeriod.All, + }[i] ?? Portfolio.Token.HistoryPeriod.OneDay) diff --git a/apps/wallet-mobile/src/features/Portfolio/common/types.ts b/apps/wallet-mobile/src/features/Portfolio/common/types.ts deleted file mode 100644 index 5de20fa7c8..0000000000 --- a/apps/wallet-mobile/src/features/Portfolio/common/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const portfolioDAppsTabs = { - LIQUIDITY_POOL: 'liquidityPool', - OPEN_ORDERS: 'openOrders', - LEND_BORROW: 'lendAndBorrow', -} as const - -export type TPortfolioDAppsTabs = (typeof portfolioDAppsTabs)[keyof typeof portfolioDAppsTabs] diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioDashboard/DashboardTokensList/DashboardTokenItem.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioDashboard/DashboardTokensList/DashboardTokenItem.tsx index 35e5f00441..db49a33b5f 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioDashboard/DashboardTokensList/DashboardTokenItem.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioDashboard/DashboardTokensList/DashboardTokenItem.tsx @@ -48,7 +48,7 @@ export const DashboardTokenItem = ({tokenInfo}: Props) => { - {isMissingPrices ? '—— ' : formatPriceChange(changePercent)}% + {isMissingPrices ? '— ' : formatPriceChange(changePercent)}% diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioDashboard/PortfolioDashboardScreen.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioDashboard/PortfolioDashboardScreen.tsx index 345a84dfdf..9297a5eba9 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioDashboard/PortfolioDashboardScreen.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioDashboard/PortfolioDashboardScreen.tsx @@ -6,6 +6,7 @@ import {SafeAreaView} from 'react-native-safe-area-context' import {Spacer} from '../../../../components/Spacer/Spacer' import {useMetrics} from '../../../../kernel/metrics/metricsManager' +import {usePortfolio} from '../../common/PortfolioProvider' import {BalanceCard} from './BalanceCard/BalanceCard' import {DashboardNFTsList} from './DashboardNFTsList/DashboardNFTsList' import {DashboardTokensList} from './DashboardTokensList/DashboardTokensList' @@ -13,11 +14,13 @@ import {DashboardTokensList} from './DashboardTokensList/DashboardTokensList' export const PortfolioDashboardScreen = () => { const {styles} = useStyles() const {track} = useMetrics() + const {resetTabs} = usePortfolio() useFocusEffect( React.useCallback(() => { + resetTabs() track.portfolioDashboardPageViewed() - }, [track]), + }, [resetTabs, track]), ) return ( diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/ExportTokenTransactions.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/ExportTokenTransactions.tsx index 0d167869fe..10a2e8c3f9 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/ExportTokenTransactions.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/ExportTokenTransactions.tsx @@ -4,13 +4,13 @@ import {StyleSheet, TouchableOpacity} from 'react-native' import {Icon} from '../../../../components/Icon' import {features} from '../../../../kernel/features' -import {usePortfolioTokenDetailContext} from '../../common/PortfolioTokenDetailContext' +import {PortfolioDetailsTab, usePortfolio} from '../../common/PortfolioProvider' const ExportTokenTransactions = () => { const {styles, colors} = useStyles() - const {activeTab} = usePortfolioTokenDetailContext() + const {detailsTab} = usePortfolio() - if (!features.portfolioExport || activeTab !== 'transactions') return null + if (!features.portfolioExport || detailsTab !== PortfolioDetailsTab.Transactions) return null return ( diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/ChartPlaceholder.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/ChartPlaceholder.tsx new file mode 100644 index 0000000000..d9ac221f88 --- /dev/null +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/ChartPlaceholder.tsx @@ -0,0 +1,19 @@ +import {useTheme} from '@yoroi/theme' +import React from 'react' +import {useWindowDimensions} from 'react-native' +import Svg, {Path} from 'react-native-svg' + +export const ChartPlaceholder = () => { + const {width} = useWindowDimensions() + const {color} = useTheme() + + return ( + + + + ) +} diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/PortfolioTokenChart.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/PortfolioTokenChart.tsx index 1f7d11afd6..3bb1067dfb 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/PortfolioTokenChart.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/PortfolioTokenChart.tsx @@ -7,6 +7,7 @@ import { TOKEN_CHART_INTERVAL, useGetPortfolioTokenChart, } from '../../../common/hooks/useGetPortfolioTokenChart' +import {ChartPlaceholder} from './ChartPlaceholder' import {PortfolioTokenChartSkeleton} from './PortfolioTokenChartSkeleton' import {TokenChart} from './TokenChart' import {TokenChartToolbar} from './TokenChartToolBar' @@ -29,16 +30,16 @@ export const PortfolioTokenChart = () => { return ( - {isFetching || !data ? ( + {isFetching ? ( ) : ( <> - + {!data ? : } )} @@ -49,6 +50,7 @@ export const PortfolioTokenChart = () => { const useStyles = () => { const {atoms} = useTheme() + const styles = StyleSheet.create({ root: { ...atoms.flex_1, diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/PortfolioTokenChartSkeleton.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/PortfolioTokenChartSkeleton.tsx index 138b6c69f6..c452a0f45c 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/PortfolioTokenChartSkeleton.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/PortfolioTokenChartSkeleton.tsx @@ -1,10 +1,9 @@ import {useTheme} from '@yoroi/theme' import * as React from 'react' -import {Image, StyleSheet, View} from 'react-native' +import {StyleSheet, View} from 'react-native' import SkeletonPlaceholder from 'react-native-skeleton-placeholder' -import ChartPlaceholder from '../../../../../assets/img/chart-placeholder.png' -import {Icon} from '../../../../../components/Icon' +import {ChartPlaceholder} from './ChartPlaceholder' export const PortfolioTokenChartSkeleton = () => { const {color, styles} = useStyles() @@ -19,8 +18,6 @@ export const PortfolioTokenChartSkeleton = () => { - - @@ -28,7 +25,7 @@ export const PortfolioTokenChartSkeleton = () => { - + ) } @@ -51,12 +48,6 @@ const useStyles = () => { ...atoms.align_center, ...atoms.gap_2xs, }, - chartPlaceholder: { - height: 112, - width: '100%', - marginVertical: 16, - resizeMode: 'stretch', - }, }) return {styles, color} as const } diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/TokenPerformance.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/TokenPerformance.tsx index bbe0c21850..b14eaef25b 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/TokenPerformance.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenChart/TokenPerformance.tsx @@ -3,7 +3,6 @@ import {useTheme} from '@yoroi/theme' import * as React from 'react' import {StyleSheet, View} from 'react-native' -import {Icon} from '../../../../../components/Icon' import {Text} from '../../../../../components/Text' import {Tooltip} from '../../../../../components/Tooltip/Tooltip' import {useCurrencyPairing} from '../../../../Settings/Currency/CurrencyContext' @@ -13,7 +12,7 @@ import {useStrings} from '../../../common/hooks/useStrings' import {PnlTag} from '../../../common/PnlTag/PnlTag' type Props = { - tokenPerformance: { + tokenPerformance?: { changePercent: number changeValue: number value: number @@ -27,11 +26,12 @@ export const TokenPerformance = ({tokenPerformance, timeInterval}: Props) => { const {currency, config} = useCurrencyPairing() const variant = React.useMemo(() => { + if (!tokenPerformance) return 'neutral' if (Number(tokenPerformance.changePercent) > 0) return 'success' if (Number(tokenPerformance.changePercent) < 0) return 'danger' return 'neutral' - }, [tokenPerformance.changePercent]) + }, [tokenPerformance]) const intervalLabel = React.useMemo(() => { switch (timeInterval) { @@ -54,25 +54,31 @@ export const TokenPerformance = ({tokenPerformance, timeInterval}: Props) => { return ( - - - {formatPriceChange(tokenPerformance.changePercent)}% - + + + + {!tokenPerformance ? '—' : formatPriceChange(tokenPerformance.changePercent)}% + - {`${formatPriceChange( - tokenPerformance.changeValue, - config.decimals, - )} ${currency}`} - - - - - + {`${ + !tokenPerformance ? '—' : formatPriceChange(tokenPerformance.changeValue, config.decimals) + } ${currency}`} + + - {formatPriceChange(tokenPerformance.value, config.decimals)} + {!tokenPerformance ? ( + + ) : ( + <> + {formatPriceChange(tokenPerformance.value, config.decimals)} - {currency} + {currency} + + )} ) diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenDetailsScreen.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenDetailsScreen.tsx index 43263f40f6..28bd845fe8 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenDetailsScreen.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenDetailsScreen.tsx @@ -1,4 +1,3 @@ -import {isPrimaryTokenInfo} from '@yoroi/portfolio' import {useTheme} from '@yoroi/theme' import {App} from '@yoroi/types' import * as React from 'react' @@ -15,31 +14,23 @@ import {TxList} from '../../../Transactions/useCases/TxList/TxList' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' import {usePortfolioTokenDetailParams} from '../../common/hooks/useNavigateTo' import {useStrings} from '../../common/hooks/useStrings' -import {usePortfolioTokenDetailContext} from '../../common/PortfolioTokenDetailContext' +import {PortfolioDetailsTab, usePortfolio} from '../../common/PortfolioProvider' import {BuyADABanner} from '../PortfolioDashboard/DashboardTokensList/BuyADABanner/BuyADABanner' import {Actions} from './Actions' import {PortfolioTokenBalance} from './PortfolioTokenBalance/PortfolioTokenBalance' import {PortfolioTokenChart} from './PortfolioTokenChart/PortfolioTokenChart' import {PortfolioTokenInfo} from './PortfolioTokenInfo/PortfolioTokenInfo' -type ActiveTab = 'performance' | 'overview' | 'transactions' +const HEADER_HEIGHT = 304 -type Tabs = 'Performance' | 'Overview' | 'Transactions' -const tabs: Record = { - performance: 'Performance', - overview: 'Overview', - transactions: 'Transactions', -} export const PortfolioTokenDetailsScreen = () => { const strings = useStrings() - const {activeTab, setActiveTab} = usePortfolioTokenDetailContext() + const {detailsTab, setDetailsTab} = usePortfolio() const {track} = useMetrics() const [isStickyTab, setIsStickyTab] = React.useState(false) const {id: tokenId} = usePortfolioTokenDetailParams() const {wallet} = useSelectedWallet() const tokenInfo = wallet.balances.records.get(tokenId)?.info - const isPrimaryToken = isPrimaryTokenInfo(tokenInfo) - const HEADER_HEIGHT = isPrimaryToken ? 304 : 85 // Graph only in PT const {styles} = useStyles(HEADER_HEIGHT) if (!tokenInfo) throwLoggedError(new App.Errors.InvalidState('Token info not found, invalid state')) @@ -50,8 +41,8 @@ export const PortfolioTokenDetailsScreen = () => { } React.useEffect(() => { - track.portfolioTokenDetails({token_details_tab: tabs[activeTab]}) - }, [activeTab, track]) + track.portfolioTokenDetails({token_details_tab: detailsTab}) + }, [detailsTab, track]) const renderTabs = React.useMemo(() => { return ( @@ -59,28 +50,28 @@ export const PortfolioTokenDetailsScreen = () => { {features.portfolioPerformance && ( setActiveTab('performance')} + active={detailsTab === PortfolioDetailsTab.Performance} + onPress={() => setDetailsTab(PortfolioDetailsTab.Performance)} label={strings.performance} /> )} setActiveTab('overview')} + active={detailsTab === PortfolioDetailsTab.Overview} + onPress={() => setDetailsTab(PortfolioDetailsTab.Overview)} label={strings.overview} /> setActiveTab('transactions')} + active={detailsTab === PortfolioDetailsTab.Transactions} + onPress={() => setDetailsTab(PortfolioDetailsTab.Transactions)} label={strings.transactions} /> ) - }, [activeTab, setActiveTab, strings.overview, strings.performance, strings.transactions, styles.tab, styles.tabs]) + }, [detailsTab, setDetailsTab, strings.overview, strings.performance, strings.transactions, styles.tab, styles.tabs]) return ( @@ -100,13 +91,9 @@ export const PortfolioTokenDetailsScreen = () => { - {isPrimaryToken && ( - <> - + - - - )} + {renderTabs} @@ -114,8 +101,8 @@ export const PortfolioTokenDetailsScreen = () => { } - {...(activeTab !== 'transactions' && {data: []})} - {...(activeTab === 'transactions' && {ListEmptyComponent: })} + {...(detailsTab !== PortfolioDetailsTab.Transactions && {data: []})} + {...(detailsTab === PortfolioDetailsTab.Transactions && {ListEmptyComponent: })} /> diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenInfo/PortfolioTokenInfo.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenInfo/PortfolioTokenInfo.tsx index 22b17d107d..19b0ef9dc6 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenInfo/PortfolioTokenInfo.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokenDetails/PortfolioTokenInfo/PortfolioTokenInfo.tsx @@ -3,22 +3,22 @@ import React from 'react' import {StyleSheet, View} from 'react-native' import {TabPanel, TabPanels} from '../../../../../components/Tabs/Tabs' -import {usePortfolioTokenDetailContext} from '../../../common/PortfolioTokenDetailContext' +import {PortfolioDetailsTab, usePortfolio} from '../../../common/PortfolioProvider' import {Overview} from './Overview/Overview' import {Performance} from './Performance' export const PortfolioTokenInfo = () => { const {styles} = useStyles() - const {activeTab} = usePortfolioTokenDetailContext() + const {detailsTab} = usePortfolio() return ( - + - + diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioDAppsTokenList/PortfolioDAppTabs.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioDAppsTokenList/PortfolioDAppTabs.tsx index fbce1fe295..2530192ae2 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioDAppsTokenList/PortfolioDAppTabs.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioDAppsTokenList/PortfolioDAppTabs.tsx @@ -3,40 +3,37 @@ import * as React from 'react' import {StyleSheet, Text, TouchableOpacity, TouchableOpacityProps} from 'react-native' import {useStrings} from '../../../common/hooks/useStrings' +import {PortfolioDappsTab, usePortfolio} from '../../../common/PortfolioProvider' import {TabsGradient} from '../../../common/TabsGradient/Tabs' -import {portfolioDAppsTabs, TPortfolioDAppsTabs} from '../../../common/types' -type DAppTabsProps = { - activeTab: TPortfolioDAppsTabs - onChangeTab: (tab: TPortfolioDAppsTabs) => void -} -export const PortfolioDAppTabs = ({activeTab, onChangeTab}: DAppTabsProps) => { +export const PortfolioDAppTabs = () => { const strings = useStrings() + const {dappsTab, setDappsTab} = usePortfolio() return ( { - onChangeTab(portfolioDAppsTabs.LIQUIDITY_POOL) + setDappsTab(PortfolioDappsTab.LiquidityPool) }} label={strings.liquidityPool} - active={activeTab === portfolioDAppsTabs.LIQUIDITY_POOL} + active={dappsTab === PortfolioDappsTab.LiquidityPool} /> { - onChangeTab(portfolioDAppsTabs.OPEN_ORDERS) + setDappsTab(PortfolioDappsTab.OpenOrders) }} label={strings.openOrders} - active={activeTab === portfolioDAppsTabs.OPEN_ORDERS} + active={dappsTab === PortfolioDappsTab.OpenOrders} /> { - onChangeTab(portfolioDAppsTabs.LEND_BORROW) + setDappsTab(PortfolioDappsTab.LendAndBorrow) }} label={strings.lendAndBorrow} - active={activeTab === portfolioDAppsTabs.LEND_BORROW} + active={dappsTab === PortfolioDappsTab.LendAndBorrow} /> ) diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioDAppsTokenList/PortfolioDAppsTokenList.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioDAppsTokenList/PortfolioDAppsTokenList.tsx index 235ed22c4e..b144260af6 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioDAppsTokenList/PortfolioDAppsTokenList.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioDAppsTokenList/PortfolioDAppsTokenList.tsx @@ -12,7 +12,7 @@ import {ILiquidityPool, useGetLiquidityPool} from '../../../common/hooks/useGetL import {IOpenOrders, useGetOpenOrders} from '../../../common/hooks/useGetOpenOrders' import {usePortfolioPrimaryBalance} from '../../../common/hooks/usePortfolioPrimaryBalance' import {Line} from '../../../common/Line' -import {portfolioDAppsTabs, TPortfolioDAppsTabs} from '../../../common/types' +import {PortfolioDappsTab, usePortfolio} from '../../../common/PortfolioProvider' import {TotalTokensValue} from '../TotalTokensValue/TotalTokensValue' import {LendAndBorrowTab} from './LendAndBorrowTab' import {LiquidityPoolTab} from './LiquidityPoolTab' @@ -25,8 +25,7 @@ export const PortfolioDAppsTokenList = () => { const {wallet} = useSelectedWallet() const {track} = useMetrics() const primaryBalance = usePortfolioPrimaryBalance({wallet}) - - const [activeTab, setActiveTab] = React.useState(portfolioDAppsTabs.LIQUIDITY_POOL) + const {dappsTab} = usePortfolio() const {data: liquidityPools, isFetching: liquidityPoolFetching} = useGetLiquidityPool() const {data: openOrders, isFetching: openOrdersFetching} = useGetOpenOrders() @@ -86,7 +85,7 @@ export const PortfolioDAppsTokenList = () => { {!isSearching ? ( - + @@ -94,9 +93,9 @@ export const PortfolioDAppsTokenList = () => { - + - + { /> - + - + diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioTokenListScreen.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioTokenListScreen.tsx index 0b41ce2abd..684c305a1f 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioTokenListScreen.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioTokenListScreen.tsx @@ -9,29 +9,27 @@ import {useSearchOnNavBar} from '../../../Search/SearchContext' import {NetworkTag} from '../../../Settings/ChangeNetwork/NetworkTag' import {useGetDAppsPortfolioBalance} from '../../common/hooks/useGetDAppsPortfolioBalance' import {useStrings} from '../../common/hooks/useStrings' -import {PortfolioProvider} from '../../common/PortfolioProvider' +import {PortfolioListTab, usePortfolio} from '../../common/PortfolioProvider' import {PortfolioDAppsTokenList} from './PortfolioDAppsTokenList/PortfolioDAppsTokenList' import {PortfolioWalletTokenList} from './PortfolioWalletTokenList/PortfolioWalletTokenList' -type ActiveTab = 'wallet' | 'dapps' -type Tabs = 'Wallet Token' | 'Dapps Token' -const tabs: Record = { - wallet: 'Wallet Token', - dapps: 'Dapps Token', -} +const tabs = { + [PortfolioListTab.Wallet]: 'Wallet Token', + [PortfolioListTab.Dapps]: 'Dapps Token', +} as const + export const PortfolioTokenListScreen = () => { const {styles} = useStyles() const strings = useStrings() const {track} = useMetrics() + const {listTab, setListTab} = usePortfolio() // TODO: missing dAppsBalance const dAppsBalance = useGetDAppsPortfolioBalance(0n) const hasDApps = dAppsBalance !== undefined && Number(dAppsBalance.quantity) > 0 - const [activeTab, setActiveTab] = React.useState<'wallet' | 'dapps'>('wallet') - React.useEffect(() => { - track.portfolioTokensListPageViewed({tokens_tab: tabs[activeTab]}) - }, [activeTab, track]) + track.portfolioTokensListPageViewed({tokens_tab: tabs[listTab]}) + }, [listTab, track]) useSearchOnNavBar({ title: strings.tokenList, @@ -40,25 +38,31 @@ export const PortfolioTokenListScreen = () => { }) return ( - - - {hasDApps && ( - - setActiveTab('wallet')} label={strings.walletToken} active={activeTab === 'wallet'} /> + + {hasDApps && ( + + setListTab(PortfolioListTab.Wallet)} + label={strings.walletToken} + active={listTab === PortfolioListTab.Wallet} + /> - setActiveTab('dapps')} label={strings.dappsToken} active={activeTab === 'dapps'} /> - - )} + setListTab(PortfolioListTab.Dapps)} + label={strings.dappsToken} + active={listTab === PortfolioListTab.Dapps} + /> + + )} - - - + + + - - - - - + + + + ) } diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioWalletTokenList/PortfolioWalletTokenList.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioWalletTokenList/PortfolioWalletTokenList.tsx index 2584d88e0a..bf63f5206c 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioWalletTokenList/PortfolioWalletTokenList.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioWalletTokenList/PortfolioWalletTokenList.tsx @@ -1,3 +1,4 @@ +import {useFocusEffect} from '@react-navigation/native' import {infoExtractName, isPrimaryToken} from '@yoroi/portfolio' import {useTheme} from '@yoroi/theme' import {Chain, Portfolio} from '@yoroi/types' @@ -16,6 +17,7 @@ import {aggregatePrimaryAmount} from '../../../common/helpers/aggregatePrimaryAm import {useStrings} from '../../../common/hooks/useStrings' import {useZeroBalance} from '../../../common/hooks/useZeroBalance' import {Line} from '../../../common/Line' +import {usePortfolio} from '../../../common/PortfolioProvider' import {usePortfolioTokenActivity} from '../../../common/PortfolioTokenActivityProvider' import {TokenEmptyList} from '../../../common/TokenEmptyList/TokenEmptyList' import {BuyADABanner} from '../../PortfolioDashboard/DashboardTokensList/BuyADABanner/BuyADABanner' @@ -28,6 +30,7 @@ export const PortfolioWalletTokenList = () => { const {styles} = useStyles() const {search, isSearching} = useSearch() const isZeroADABalance = useZeroBalance() + const {resetTabs} = usePortfolio() const {track} = useMetrics() const { selected: {network}, @@ -64,6 +67,12 @@ export const PortfolioWalletTokenList = () => { return tokensList }, [isSearching, search, tokensList]) + useFocusEffect( + React.useCallback(() => { + resetTabs() + }, [resetTabs]), + ) + React.useEffect(() => { let timeout: ReturnType | undefined @@ -153,7 +162,7 @@ const HeadingList = ({isFirstUser, isShowBalanceCard, countTokensList, amount}: {isShowBalanceCard ? ( - + diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioWalletTokenList/TokenBalanceItem.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioWalletTokenList/TokenBalanceItem.tsx index 923e0f5516..43ae21f7d8 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioWalletTokenList/TokenBalanceItem.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/PortfolioWalletTokenList/TokenBalanceItem.tsx @@ -53,7 +53,7 @@ export const TokenBalanceItem = ({amount}: Props) => { - {isMissingPrices ? '—— ' : formatPriceChange(changePercent)}% + {isMissingPrices ? '— ' : formatPriceChange(changePercent)}% diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/TotalTokensValue/TotalTokensValue.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/TotalTokensValue/TotalTokensValue.tsx index 63ff81629f..246e9276fa 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/TotalTokensValue/TotalTokensValue.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/TotalTokensValue/TotalTokensValue.tsx @@ -3,37 +3,23 @@ import {Portfolio} from '@yoroi/types' import * as React from 'react' import {StyleSheet, Text, View} from 'react-native' -import {Icon} from '../../../../../components/Icon' -import {Tooltip} from '../../../../../components/Tooltip/Tooltip' import {useStrings} from '../../../common/hooks/useStrings' +import {PortfolioListTab, usePortfolio} from '../../../common/PortfolioProvider' import {TotalTokensValueContent} from './TotalTokensValueContent' type Props = { amount: Portfolio.Token.Amount - cardType: 'wallet' | 'dapps' } -export const TotalTokensValue = ({amount, cardType}: Props) => { +export const TotalTokensValue = ({amount}: Props) => { const strings = useStrings() const {styles} = useStyles() - const isWallet = cardType === 'wallet' - const title = isWallet ? strings.totalWalletValue : strings.totalDAppValue - const tooltip = isWallet ? strings.totalWalletValueTooltip : strings.totalDAppsValueTooltip + const {listTab} = usePortfolio() + const title = listTab === PortfolioListTab.Wallet ? strings.totalWalletValue : strings.totalDAppValue return ( - - - {title} - - - - - } - /> + {title}} /> ) } @@ -47,11 +33,6 @@ const useStyles = () => { ...atoms.body_3_sm_regular, color: color.gray_600, }, - labelContainer: { - ...atoms.flex_row, - ...atoms.align_center, - ...atoms.gap_xs, - }, }) return {styles} as const diff --git a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/TotalTokensValue/TotalTokensValueContent.tsx b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/TotalTokensValue/TotalTokensValueContent.tsx index e7d540ca4e..18efd6bf30 100644 --- a/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/TotalTokensValue/TotalTokensValueContent.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/useCases/PortfolioTokensList/TotalTokensValue/TotalTokensValueContent.tsx @@ -5,11 +5,13 @@ import {StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {Icon} from '../../../../../components/Icon' import {Spacer} from '../../../../../components/Spacer/Spacer' +import {Tooltip} from '../../../../../components/Tooltip/Tooltip' import {useCurrencyPairing} from '../../../../Settings/Currency/CurrencyContext' import {usePrivacyMode} from '../../../../Settings/PrivacyMode/PrivacyMode' import {formatPriceChange, priceChange} from '../../../common/helpers/priceChange' +import {useStrings} from '../../../common/hooks/useStrings' import {PnlTag} from '../../../common/PnlTag/PnlTag' -import {usePortfolio} from '../../../common/PortfolioProvider' +import {PortfolioListTab, usePortfolio} from '../../../common/PortfolioProvider' import {SkeletonQuantityChange} from './SkeletonQuantityChange' import {TokenValueBalance} from './TokenValueBalance' import {TokenValuePairedBalance} from './TokenValuePairedBalance' @@ -20,6 +22,7 @@ type Props = { } export const TotalTokensValueContent = ({amount, headerCard}: Props) => { + const strings = useStrings() const {styles, color} = useStyles() const { currency, @@ -27,7 +30,7 @@ export const TotalTokensValueContent = ({amount, headerCard}: Props) => { ptActivity: {close, open}, isLoading, } = useCurrencyPairing() - const {isPrimaryTokenActive, setIsPrimaryTokenActive} = usePortfolio() + const {isPrimaryTokenActive, setIsPrimaryTokenActive, listTab} = usePortfolio() const {togglePrivacyMode} = usePrivacyMode() const {changePercent, changeValue, variantPnl} = priceChange(open, close) @@ -57,26 +60,32 @@ export const TotalTokensValueContent = ({amount, headerCard}: Props) => { - - {isLoading ? ( - - ) : ( - - {formatPriceChange(changePercent)}% - - )} + + + {isLoading ? ( + + ) : ( + + {formatPriceChange(changePercent)}% + + )} - {isLoading ? ( - - ) : ( - - {`${changeValue > 0 ? '+' : ''}${formatPriceChange( - changeValue, - config.decimals, - )} ${currency}`} - - )} - + {isLoading ? ( + + ) : ( + + {`${changeValue > 0 ? '+' : ''}${formatPriceChange( + changeValue, + config.decimals, + )} ${currency}`} + + )} + + diff --git a/apps/wallet-mobile/src/features/Staking/Governance/common/helpers.ts b/apps/wallet-mobile/src/features/Staking/Governance/common/helpers.ts index d740e5319e..304f619fbb 100644 --- a/apps/wallet-mobile/src/features/Staking/Governance/common/helpers.ts +++ b/apps/wallet-mobile/src/features/Staking/Governance/common/helpers.ts @@ -4,8 +4,9 @@ import * as React from 'react' import {governaceAfterBlock} from '../../../../kernel/config' import {YoroiWallet} from '../../../../yoroi-wallets/cardano/types' -import {useStakingKey, useTipStatus} from '../../../../yoroi-wallets/hooks' +import {useStakingKey} from '../../../../yoroi-wallets/hooks' import {CardanoMobile} from '../../../../yoroi-wallets/wallets' +import {useBestBlock} from '../../../WalletManager/common/hooks/useBestBlock' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' import {GovernanceVote} from '../types' @@ -30,7 +31,7 @@ export const mapStakingKeyStateToGovernanceAction = (state: StakingKeyState): Go } export const useIsGovernanceFeatureEnabled = (wallet: YoroiWallet) => { - const {bestBlock} = useTipStatus({wallet, options: {suspense: true}}) + const bestBlock = useBestBlock({options: {suspense: true}}) return bestBlock.height >= governaceAfterBlock[wallet.networkManager.network] } diff --git a/apps/wallet-mobile/src/features/Transactions/useCases/TxDetails/TxDetails.tsx b/apps/wallet-mobile/src/features/Transactions/useCases/TxDetails/TxDetails.tsx index e531f46800..fd582c9dbc 100644 --- a/apps/wallet-mobile/src/features/Transactions/useCases/TxDetails/TxDetails.tsx +++ b/apps/wallet-mobile/src/features/Transactions/useCases/TxDetails/TxDetails.tsx @@ -30,12 +30,13 @@ import {useModal} from '../../../../components/Modal/ModalContext' import {Text} from '../../../../components/Text' import {isEmptyString} from '../../../../kernel/utils' import {MultiToken} from '../../../../yoroi-wallets/cardano/MultiToken' -import {CardanoTypes, YoroiWallet} from '../../../../yoroi-wallets/cardano/types' -import {useTipStatus, useTransactionInfos} from '../../../../yoroi-wallets/hooks' +import {CardanoTypes} from '../../../../yoroi-wallets/cardano/types' +import {useTransactionInfos} from '../../../../yoroi-wallets/hooks' import {TransactionInfo} from '../../../../yoroi-wallets/types/other' import {formatDateAndTime, formatTokenWithSymbol} from '../../../../yoroi-wallets/utils/format' import {asQuantity} from '../../../../yoroi-wallets/utils/utils' import {usePrivacyMode} from '../../../Settings/PrivacyMode/PrivacyMode' +import {useBestBlock} from '../../../WalletManager/common/hooks/useBestBlock' import {useSelectedWallet} from '../../../WalletManager/common/hooks/useSelectedWallet' import {useWalletManager} from '../../../WalletManager/context/WalletManagerProvider' import {messages, useStrings} from '../../common/strings' @@ -173,7 +174,7 @@ export const TxDetails = () => { - + @@ -201,18 +202,17 @@ export const TxDetails = () => { ) } -const Confirmations = ({transaction, wallet}: {transaction: TransactionInfo; wallet: YoroiWallet}) => { +const Confirmations = ({transaction}: {transaction: TransactionInfo}) => { const strings = useStrings() - const tipStatus = useTipStatus({ - wallet, + const bestBlock = useBestBlock({ options: { - refetchInterval: 5000, + refetchInterval: 5_000, }, }) return ( - {strings.confirmations(transaction.blockNumber === 0 ? 0 : tipStatus.bestBlock.height - transaction.blockNumber)} + {strings.confirmations(transaction.blockNumber === 0 ? 0 : bestBlock.height - transaction.blockNumber)} ) } diff --git a/apps/wallet-mobile/src/features/WalletManager/common/hooks/useBestBlock.tsx b/apps/wallet-mobile/src/features/WalletManager/common/hooks/useBestBlock.tsx new file mode 100644 index 0000000000..ca667fc087 --- /dev/null +++ b/apps/wallet-mobile/src/features/WalletManager/common/hooks/useBestBlock.tsx @@ -0,0 +1,21 @@ +import {Chain} from '@yoroi/types' +import {useQuery, UseQueryOptions} from 'react-query' + +import {useSelectedNetwork} from './useSelectedNetwork' + +export const useBestBlock = ({options}: {options?: UseQueryOptions}) => { + const {networkManager, network} = useSelectedNetwork() + const query = useQuery({ + suspense: true, + staleTime: 10_000, + retry: 3, + retryDelay: 1_000, + queryKey: [network, 'tipStatus'], + queryFn: () => networkManager.api.bestBlock(), + ...options, + }) + + if (!query.data) throw new Error('Failed to retrive tipStatus') + + return query.data +} diff --git a/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/date-to-epoch-info.test.ts b/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/date-to-epoch-info.test.ts index 032bddaf2e..1d313b544e 100644 --- a/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/date-to-epoch-info.test.ts +++ b/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/date-to-epoch-info.test.ts @@ -18,6 +18,7 @@ describe('dateToEpochInfo', () => { start: new Date('2024-05-09T21:44:51.000Z'), end: new Date('2024-05-14T21:44:51.000Z'), era: shelleyEraConfig, + eras: networkManagers[Chain.Network.Mainnet].eras, } const result = convertDateToEpoch(inputDate) @@ -42,6 +43,7 @@ describe('dateToEpochInfo', () => { start: new Date('2017-09-23T21:44:51.000Z'), end: new Date('2017-09-28T21:44:51.000Z'), era: byronEraConfig, + eras: networkManagers[Chain.Network.Mainnet].eras, } const result = convertDateToEpoch(inputDate) @@ -58,6 +60,7 @@ describe('dateToEpochInfo', () => { start: new Date('2020-07-24T21:44:51.000Z'), end: new Date('2020-07-29T21:44:51.000Z'), era: byronEraConfig, + eras: networkManagers[Chain.Network.Mainnet].eras, } const result = convertDateToEpoch(inputDate) @@ -75,6 +78,7 @@ describe('dateToEpochInfo', () => { start: new Date('2020-07-29T21:44:51.000Z'), end: new Date('2020-08-03T21:44:51.000Z'), era: shelleyEraConfig, + eras: networkManagers[Chain.Network.Mainnet].eras, } const result = convertDateToEpoch(inputDate) @@ -91,6 +95,7 @@ describe('dateToEpochInfo', () => { start: new Date('2024-05-11T01:00:00.000Z'), end: new Date('2024-05-16T01:00:00.000Z'), era: shelleyPreprodEraConfig, + eras: networkManagers['preprod'].eras, } const result = convertDateToEpoch(inputDate) diff --git a/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/date-to-epoch-info.ts b/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/date-to-epoch-info.ts index f7031a1102..d4d5fc77ee 100644 --- a/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/date-to-epoch-info.ts +++ b/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/date-to-epoch-info.ts @@ -17,6 +17,7 @@ export function dateToEpochInfo(eras: Network.Manager['eras']) { start: epochStart, end: epochEnd, era: era, + eras: eras, }) } diff --git a/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/epoch-progress.test.ts b/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/epoch-progress.test.ts index 37b686c9e7..c5ace04ceb 100644 --- a/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/epoch-progress.test.ts +++ b/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/epoch-progress.test.ts @@ -15,6 +15,7 @@ describe('epochProgress', () => { expect(result).toEqual({ progress: 100, currentSlot: 431999, + absoluteSlot: 124156799, timeRemaining: {days: 0, hours: 0, minutes: 0, seconds: 1}, }) }) @@ -24,6 +25,7 @@ describe('epochProgress', () => { const result = epochProgress(dateToEpochInfo(networkManagers['mainnet'].eras)(currentDate))(currentDate) expect(result).toEqual({ + absoluteSlot: 123724800, progress: 0, currentSlot: 0, timeRemaining: {days: 5, hours: 0, minutes: 0, seconds: 0}, @@ -35,6 +37,7 @@ describe('epochProgress', () => { const result = epochProgress(dateToEpochInfo(networkManagers['mainnet'].eras)(currentDate))(currentDate) expect(result).toEqual({ + absoluteSlot: 123940800, progress: 50, currentSlot: 216000, timeRemaining: {days: 2, hours: 12, minutes: 0, seconds: 0}, @@ -47,6 +50,7 @@ describe('epochProgress', () => { start: new Date('2024-05-09T21:44:51.000Z'), end: new Date('2024-05-14T21:44:51.000Z'), era: shelleyEraConfig, + eras: networkManagers['mainnet'].eras, } const currentDate = new Date('2022-01-02T00:00:01Z') @@ -56,6 +60,7 @@ describe('epochProgress', () => { expect(result).toEqual({ progress: 100, currentSlot: 432000, + absoluteSlot: 49515310, timeRemaining: {days: 0, hours: 0, minutes: 0, seconds: 0}, }) }) @@ -68,6 +73,7 @@ describe('epochProgress', () => { const result = progressFn(currentDate) expect(result).toEqual({ + absoluteSlot: 61718400, progress: 86.67, currentSlot: 374400, timeRemaining: {days: 0, hours: 16, minutes: 0, seconds: 0}, diff --git a/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/epoch-progress.ts b/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/epoch-progress.ts index d412bce685..a6ef809775 100644 --- a/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/epoch-progress.ts +++ b/apps/wallet-mobile/src/features/WalletManager/network-manager/helpers/epoch-progress.ts @@ -6,11 +6,24 @@ export function epochProgress(epochInfo: Network.EpochInfo) { const epochStart = epochInfo.start const epochEnd = epochInfo.end - if (date > epochEnd || date < epochStart) + let absoluteSlot = 0 + + for (const era of epochInfo.eras) { + if (date >= era.start && (era.end === undefined || date < era.end)) { + absoluteSlot += Math.floor((date.getTime() - era.start.getTime()) / 1e3 / era.slotInSeconds) + break + } + absoluteSlot += Math.floor( + ((era.end?.getTime() ?? date.getTime()) - era.start.getTime()) / 1e3 / era.slotInSeconds, + ) + } + + if (date > epochEnd || date < epochStart) { return freeze( { progress: 100, currentSlot: epochInfo.era.slotsPerEpoch, + absoluteSlot, timeRemaining: { days: 0, hours: 0, @@ -20,6 +33,7 @@ export function epochProgress(epochInfo: Network.EpochInfo) { }, true, ) + } const progress = Math.round( @@ -36,13 +50,14 @@ export function epochProgress(epochInfo: Network.EpochInfo) { return freeze( { - progress: progress, - currentSlot: currentSlot, + progress, + currentSlot, + absoluteSlot, timeRemaining: { - days: days, - hours: hours, - minutes: minutes, - seconds: seconds, + days, + hours, + minutes, + seconds, }, }, true, diff --git a/apps/wallet-mobile/src/features/WalletManager/network-manager/network-manager.ts b/apps/wallet-mobile/src/features/WalletManager/network-manager/network-manager.ts index e8329e1862..d5098f604d 100644 --- a/apps/wallet-mobile/src/features/WalletManager/network-manager/network-manager.ts +++ b/apps/wallet-mobile/src/features/WalletManager/network-manager/network-manager.ts @@ -2,7 +2,7 @@ import {CardanoApi} from '@yoroi/api' import {mountAsyncStorage, mountMMKVStorage, observableStorageMaker} from '@yoroi/common' import {explorerManager} from '@yoroi/explorers' import {createPrimaryTokenInfo} from '@yoroi/portfolio' -import {Chain, Network} from '@yoroi/types' +import {Api, Chain, Network} from '@yoroi/types' import {freeze} from 'immer' import {logger} from '../../../kernel/logger/logger' @@ -128,8 +128,10 @@ export const networkConfigs: Readonly Api.Cardano.Api }): Readonly> { const managers = Object.entries(networkConfigs).reduce>( (networkManagers, [network, config]) => { @@ -137,13 +139,14 @@ export function buildNetworkManagers({ const networkRootStorage = mountMMKVStorage({path: `/`, id: `${network}.manager.v1`}) const rootStorage = observableStorageMaker(networkRootStorage) const legacyRootStorage = observableStorageMaker(mountAsyncStorage({path: `/legacy/${network}/v1/`})) - const {getProtocolParams} = CardanoApi.cardanoApiMaker({network: config.network}) + const {getProtocolParams, getBestBlock} = apiMaker({network: config.network}) const api = { protocolParams: () => getProtocolParams().catch((error) => { logger.error(`networkManager: ${network} protocolParams has failed, using hardcoded`, {error}) return Promise.resolve(protocolParamsPlaceholder) }), + bestBlock: getBestBlock, } const info = dateToEpochInfo(config.eras) diff --git a/apps/wallet-mobile/src/legacy/Dashboard/Dashboard.tsx b/apps/wallet-mobile/src/legacy/Dashboard/Dashboard.tsx index cc27a4ec46..0059f870db 100644 --- a/apps/wallet-mobile/src/legacy/Dashboard/Dashboard.tsx +++ b/apps/wallet-mobile/src/legacy/Dashboard/Dashboard.tsx @@ -5,7 +5,15 @@ import {useTheme} from '@yoroi/theme' import BigNumber from 'bignumber.js' import React from 'react' import {defineMessages, useIntl} from 'react-intl' -import {ActivityIndicator, RefreshControl, ScrollView, StyleSheet, View, ViewProps} from 'react-native' +import { + ActivityIndicator, + RefreshControl, + ScrollView, + StyleSheet, + useWindowDimensions, + View, + ViewProps, +} from 'react-native' import {SafeAreaView} from 'react-native-safe-area-context' import {Banner} from '../../components/Banner/Banner' @@ -43,15 +51,14 @@ export const Dashboard = () => { const {wallet, meta} = useSelectedWallet() const {isLoading: isSyncing, sync} = useSync(wallet) const isOnline = useIsOnline(wallet) - const {openModal, closeModal} = useModal() + const {openModal} = useModal() + const {height: windowHeight} = useWindowDimensions() const balances = useBalances(wallet) const primaryAmount = Amounts.getAmount(balances, wallet.portfolioPrimaryTokenInfo.id) const {stakingInfo, refetch: refetchStakingInfo, error, isLoading} = useStakingInfo(wallet) const isGovernanceFeatureEnabled = useIsGovernanceFeatureEnabled(wallet) - const {resetToTxHistory} = useWalletNavigation() - const isParticipatingInGovernance = useIsParticipatingInGovernance(wallet) const walletNavigateTo = useWalletNavigation() @@ -68,11 +75,7 @@ export const Dashboard = () => { return } - openModal( - '', - resetToTxHistory()} onCancel={() => closeModal()} />, - 450, - ) + openModal('', , windowHeight * 0.8) } return ( diff --git a/apps/wallet-mobile/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx b/apps/wallet-mobile/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx index 95ebba8658..ae0a03bfe8 100644 --- a/apps/wallet-mobile/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx +++ b/apps/wallet-mobile/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx @@ -6,42 +6,51 @@ import {ScrollView, StyleSheet, Text, View} from 'react-native' import {Boundary} from '../../../components/Boundary/Boundary' import {Button} from '../../../components/Button/Button' import {Checkbox} from '../../../components/Checkbox/Checkbox' +import {useModal} from '../../../components/Modal/ModalContext' import {PleaseWaitView} from '../../../components/PleaseWaitModal' import {Space} from '../../../components/Space/Space' import {Warning} from '../../../components/Warning/Warning' import {useSelectedWallet} from '../../../features/WalletManager/common/hooks/useSelectedWallet' import globalMessages, {confirmationMessages, ledgerMessages} from '../../../kernel/i18n/global-messages' +import {useWalletNavigation} from '../../../kernel/navigation' import {YoroiWallet} from '../../../yoroi-wallets/cardano/types' import {useWithdrawalTx} from '../../../yoroi-wallets/hooks' import {YoroiUnsignedTx} from '../../../yoroi-wallets/types/yoroi' +import {delay} from '../../../yoroi-wallets/utils/timeUtils' import {Quantities} from '../../../yoroi-wallets/utils/utils' import {useStakingInfo} from '../StakePoolInfos' import {ConfirmTx} from './ConfirmTx/ConfirmTx' type Props = { wallet: YoroiWallet - onCancel: () => void - onSuccess: () => void } -export const WithdrawStakingRewards = ({wallet, onSuccess, onCancel}: Props) => { +export const WithdrawStakingRewards = ({wallet}: Props) => { const strings = useWithdrawStakingRewardsStrings() - const [state, setState] = React.useState< - {step: 'form'; withdrawalTx: undefined} | {step: 'confirm'; withdrawalTx: YoroiUnsignedTx} - >({step: 'form', withdrawalTx: undefined}) + const {closeModal, openModal} = useModal() + const {resetToTxHistory} = useWalletNavigation() + + const handleOnConfirm = async (withdrawalTx: YoroiUnsignedTx) => { + closeModal() + + await delay(1000) + + openModal( + '', + + resetToTxHistory()} + onCancel={() => closeModal()} + /> + , + 400, + ) + } return ( }}> - - - setState({step: 'confirm', withdrawalTx})} /> - - - - {state.step === 'confirm' && ( - - - - )} + ) } @@ -128,8 +137,6 @@ const Header = ({title}: {title: string}) => { return {title !== '' && {title}} } -const Route = ({active, children}: {active: boolean; children: React.ReactNode}) => <>{active ? children : null} - const useWithdrawStakingRewardsStrings = () => { const intl = useIntl() diff --git a/apps/wallet-mobile/src/yoroi-wallets/cardano/cardano-wallet.ts b/apps/wallet-mobile/src/yoroi-wallets/cardano/cardano-wallet.ts index 3cf22f7670..52df12db52 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/cardano/cardano-wallet.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/cardano/cardano-wallet.ts @@ -30,7 +30,6 @@ import type { FundInfoResponse, PoolInfoRequest, RawUtxo, - TipStatusResponse, Transaction, TxStatusRequest, TxStatusResponse, @@ -372,12 +371,9 @@ export const makeCardanoWallet = (networkManager: Network.Manager, implementatio addressMode: Wallet.AddressMode }) { if (implementationConfig.features.staking) { - const time = await this.checkServerStatus() - .then(({serverTime}) => serverTime || Date.now()) - .catch(() => Date.now()) const primaryTokenId = this.portfolioPrimaryTokenInfo.id - const absSlotNumber = new BigNumber(this.networkManager.epoch.progress(new Date(time)).currentSlot) + const absSlotNumber = await this.getAbsoluteSlotNumber() const changeAddr = this.getAddressedChangeAddress(addressMode) const addressedUtxos = await this.getAddressedUtxos() const registrationStatus = this.getDelegationStatus().isRegistered @@ -438,11 +434,7 @@ export const makeCardanoWallet = (networkManager: Network.Manager, implementatio const primaryTokenId = this.portfolioPrimaryTokenInfo.id try { - const time = await this.checkServerStatus() - .then(({serverTime}) => serverTime || Date.now()) - .catch(() => Date.now()) - - const absSlotNumber = new BigNumber(this.networkManager.epoch.progress(new Date(time)).currentSlot) + const absSlotNumber = await this.getAbsoluteSlotNumber() const votingPublicKey = await Promise.resolve(Buffer.from(catalystKeyHex, 'hex')) .then((bytes) => CardanoMobile.PrivateKey.fromExtendedBytes(bytes)) .then((key) => key.toPublic()) @@ -529,12 +521,9 @@ export const makeCardanoWallet = (networkManager: Network.Manager, implementatio addressMode: Wallet.AddressMode }): Promise { if (implementationConfig.features.staking) { - const time = await this.checkServerStatus() - .then(({serverTime}) => serverTime || Date.now()) - .catch(() => Date.now()) const primaryTokenId = this.portfolioPrimaryTokenInfo.id - const absSlotNumber = new BigNumber(this.networkManager.epoch.progress(new Date(time)).currentSlot) + const absSlotNumber = await this.getAbsoluteSlotNumber() const changeAddr = this.getAddressedChangeAddress(addressMode) const addressedUtxos = await this.getAddressedUtxos() const accountState = await legacyApi.getAccountState( @@ -590,11 +579,8 @@ export const makeCardanoWallet = (networkManager: Network.Manager, implementatio votingCertificates: CardanoTypes.Certificate[] addressMode: Wallet.AddressMode }) { - const time = await this.checkServerStatus() - .then(({serverTime}) => serverTime || Date.now()) - .catch(() => Date.now()) const primaryTokenId = this.portfolioPrimaryTokenInfo.id - const absSlotNumber = new BigNumber(this.networkManager.epoch.progress(new Date(time)).currentSlot) + const absSlotNumber = await this.getAbsoluteSlotNumber() const changeAddr = this.getAddressedChangeAddress(addressMode) const addressedUtxos = await this.getAddressedUtxos() @@ -759,6 +745,13 @@ export const makeCardanoWallet = (networkManager: Network.Manager, implementatio return Promise.resolve(addressedUtxos) } + private async getAbsoluteSlotNumber() { + const time = await this.checkServerStatus() + .then(({serverTime}) => serverTime || Date.now()) + .catch(() => Date.now()) + return new BigNumber(this.networkManager.epoch.progress(new Date(time)).absoluteSlot) + } + async createUnsignedTx({ entries, addressMode, @@ -768,11 +761,9 @@ export const makeCardanoWallet = (networkManager: Network.Manager, implementatio addressMode: Wallet.AddressMode metadata?: Array }) { - const time = await this.checkServerStatus() - .then(({serverTime}) => serverTime || Date.now()) - .catch(() => Date.now()) const primaryTokenId = this.portfolioPrimaryTokenInfo.id - const absSlotNumber = new BigNumber(this.networkManager.epoch.progress(new Date(time)).currentSlot) + const absSlotNumber = await this.getAbsoluteSlotNumber() + const changeAddr = this.getAddressedChangeAddress(addressMode) const addressedUtxos = await this.getAddressedUtxos() @@ -1086,10 +1077,6 @@ export const makeCardanoWallet = (networkManager: Network.Manager, implementatio return legacyApi.fetchTxStatus(request, networkManager.legacyApiBaseUrl) } - async fetchTipStatus(): Promise { - return legacyApi.getTipStatus(networkManager.legacyApiBaseUrl) - } - private isInitialized = false private subscriptions: Array = [] diff --git a/apps/wallet-mobile/src/yoroi-wallets/cardano/types.ts b/apps/wallet-mobile/src/yoroi-wallets/cardano/types.ts index cb3875cfcf..d80576eb94 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/cardano/types.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/cardano/types.ts @@ -17,7 +17,6 @@ import {WalletEncryptedStorage} from '../../kernel/storage/EncryptedStorage' import type { FundInfoResponse, RawUtxo, - TipStatusResponse, TransactionInfo, TxStatusRequest, TxStatusResponse, @@ -155,7 +154,6 @@ export interface YoroiWallet { saveMemo(txId: string, memo: string): Promise get transactions(): Record get confirmationCounts(): Record - fetchTipStatus(): Promise fetchTxStatus(request: TxStatusRequest): Promise // Utxos @@ -232,7 +230,6 @@ const yoroiWalletKeys: Array = [ // Balances, TxDetails 'transactions', 'confirmationCounts', - 'fetchTipStatus', 'fetchTxStatus', // Other diff --git a/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts b/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts index f12949a183..fb3ee1a9d8 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/hooks/index.ts @@ -26,7 +26,7 @@ import {logger} from '../../kernel/logger/logger' import {deriveAddressFromXPub} from '../cardano/account-manager/derive-address-from-xpub' import {getSpendingKey, getStakingKey} from '../cardano/addressInfo/addressInfo' import {WalletEvent, YoroiWallet} from '../cardano/types' -import {TipStatusResponse, TRANSACTION_DIRECTION, TRANSACTION_STATUS, TxSubmissionStatus} from '../types/other' +import {TRANSACTION_DIRECTION, TRANSACTION_STATUS, TxSubmissionStatus} from '../types/other' import {YoroiSignedTx, YoroiUnsignedTx} from '../types/yoroi' import {delay} from '../utils/timeUtils' import {Utxos} from '../utils/utils' @@ -547,30 +547,6 @@ const fetchTxStatus = async ( } } -// TODO: tipStatus is a network responsability -export const useTipStatus = ({ - wallet, - options, -}: { - wallet: YoroiWallet - options?: UseQueryOptions -}) => { - const {network} = useSelectedNetwork() - const query = useQuery({ - suspense: true, - staleTime: 10000, - retry: 3, - retryDelay: 1000, - queryKey: [network, 'tipStatus'], - queryFn: () => wallet.fetchTipStatus(), - ...options, - }) - - if (!query.data) throw new Error('Failed to retrive tipStatus') - - return query.data -} - export const useBalances = (wallet: YoroiWallet): Balance.Amounts => { const utxos = useUtxos(wallet) diff --git a/apps/wallet-mobile/src/yoroi-wallets/mocks/wallet.ts b/apps/wallet-mobile/src/yoroi-wallets/mocks/wallet.ts index e8e25096da..92cb84b8f6 100644 --- a/apps/wallet-mobile/src/yoroi-wallets/mocks/wallet.ts +++ b/apps/wallet-mobile/src/yoroi-wallets/mocks/wallet.ts @@ -188,25 +188,6 @@ const wallet: YoroiWallet = { action('fetchTxStatus')(...args) return {} }, - fetchTipStatus: async (...args: unknown[]) => { - action('fetchTipStatus')(...args) - return Promise.resolve({ - bestBlock: { - epoch: 210, - slot: 76027, - globalSlot: 60426427, - hash: '2cf5a471a0c58cbc22534a0d437fbd91576ef10b98eea7ead5887e28f7a4fed8', - height: 3617708, - }, - safeBlock: { - epoch: 210, - slot: 75415, - globalSlot: 60425815, - hash: 'ca18a2b607411dd18fbb2c1c0e653ec8a6a3f794f46ce050b4a07cf8ba4ab916', - height: 3617698, - }, - }) - }, submitTransaction: () => { throw new Error('Not implemented: submitTransaction') }, diff --git a/apps/wallet-mobile/translations/messages/src/components/PairedBalance/PairedBalance.json b/apps/wallet-mobile/translations/messages/src/components/PairedBalance/PairedBalance.json index cc74c5db5c..33ff0e463c 100644 --- a/apps/wallet-mobile/translations/messages/src/components/PairedBalance/PairedBalance.json +++ b/apps/wallet-mobile/translations/messages/src/components/PairedBalance/PairedBalance.json @@ -6,12 +6,12 @@ "start": { "line": 107, "column": 22, - "index": 3610 + "index": 3609 }, "end": { "line": 110, "column": 3, - "index": 3742 + "index": 3741 } } ] \ No newline at end of file diff --git a/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/Dashboard.json b/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/Dashboard.json index 41e949cef2..8a49d92ce6 100644 --- a/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/Dashboard.json +++ b/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/Dashboard.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!Go to Staking Center", "file": "src/legacy/Dashboard/Dashboard.tsx", "start": { - "line": 229, + "line": 232, "column": 23, - "index": 7486 + "index": 7438 }, "end": { - "line": 232, + "line": 235, "column": 3, - "index": 7619 + "index": 7571 } } ] \ No newline at end of file diff --git a/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.json b/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.json index 29ac43c0cd..c4a7cc8c5b 100644 --- a/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.json +++ b/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!Also deregister staking key?", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 156, + "line": 163, "column": 21, - "index": 5822 + "index": 5754 }, "end": { - "line": 159, + "line": 166, "column": 3, - "index": 5950 + "index": 5882 } }, { @@ -19,14 +19,14 @@ "defaultMessage": "!!!When withdrawing rewards, you also have the option to deregister the staking key.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 160, + "line": 167, "column": 16, - "index": 5968 + "index": 5900 }, "end": { - "line": 163, + "line": 170, "column": 3, - "index": 6151 + "index": 6083 } }, { @@ -34,14 +34,14 @@ "defaultMessage": "!!!Keeping the staking key will allow you to withdraw the rewards, but continue delegating to the same pool.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 164, + "line": 171, "column": 16, - "index": 6169 + "index": 6101 }, "end": { - "line": 169, + "line": 176, "column": 3, - "index": 6393 + "index": 6325 } }, { @@ -49,14 +49,14 @@ "defaultMessage": "!!!Deregistering the staking key will give you back your deposit and undelegate the key from any pool.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 170, + "line": 177, "column": 16, - "index": 6411 + "index": 6343 }, "end": { - "line": 174, + "line": 181, "column": 3, - "index": 6618 + "index": 6550 } }, { @@ -64,14 +64,14 @@ "defaultMessage": "!!!You do NOT need to deregister to delegate to a different stake pool. You can change your delegation preference at any time.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 175, + "line": 182, "column": 12, - "index": 6632 + "index": 6564 }, "end": { - "line": 180, + "line": 187, "column": 3, - "index": 6863 + "index": 6795 } }, { @@ -79,14 +79,14 @@ "defaultMessage": "!!!You should NOT deregister if this staking key is used as a stake pool's reward account, as this will cause all pool operator rewards to be sent back to the reserve.", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 181, + "line": 188, "column": 12, - "index": 6877 + "index": 6809 }, "end": { - "line": 187, + "line": 194, "column": 3, - "index": 7160 + "index": 7092 } }, { @@ -94,14 +94,14 @@ "defaultMessage": "!!!Deregistering means this key will no longer receive rewards until you re-register the staking key (usually by delegating to a pool again)", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 188, + "line": 195, "column": 12, - "index": 7174 + "index": 7106 }, "end": { - "line": 193, + "line": 200, "column": 3, - "index": 7419 + "index": 7351 } }, { @@ -109,14 +109,14 @@ "defaultMessage": "!!!Keep registered", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 194, + "line": 201, "column": 14, - "index": 7435 + "index": 7367 }, "end": { - "line": 197, + "line": 204, "column": 3, - "index": 7543 + "index": 7475 } }, { @@ -124,14 +124,14 @@ "defaultMessage": "!!!Deregister", "file": "src/legacy/Dashboard/WithdrawStakingRewards/WithdrawStakingRewards.tsx", "start": { - "line": 198, + "line": 205, "column": 20, - "index": 7565 + "index": 7497 }, "end": { - "line": 201, + "line": 208, "column": 3, - "index": 7674 + "index": 7606 } } ] \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index 7d5f550cff..0ab222dd75 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -174,6 +174,7 @@ "peerDependencies": { "@yoroi/common": "^1.5.4", "axios": "^1.5.0", + "immer": "^10.0.3", "react": ">= 16.8.0 <= 19.0.0", "react-query": "^3.39.3", "zod": "^3.22.1" diff --git a/packages/api/src/cardano/api/best-block.mocks.ts b/packages/api/src/cardano/api/best-block.mocks.ts new file mode 100644 index 0000000000..eb830df8f0 --- /dev/null +++ b/packages/api/src/cardano/api/best-block.mocks.ts @@ -0,0 +1,9 @@ +import {Chain} from '@yoroi/types' + +export const bestBlockMockResponse: Chain.Cardano.BestBlock = { + epoch: 510, + slot: 130081, + globalSlot: 135086881, + hash: 'ab0093eb78bcb0146355741388632eb50c69407df8fa32de85e5f198d725e8f4', + height: 10850697, +} diff --git a/packages/api/src/cardano/api/best-block.test.ts b/packages/api/src/cardano/api/best-block.test.ts new file mode 100644 index 0000000000..9ff006e25d --- /dev/null +++ b/packages/api/src/cardano/api/best-block.test.ts @@ -0,0 +1,79 @@ +import {getBestBlock, isBestBlock} from './best-block' +import {bestBlockMockResponse} from './best-block.mocks' +import {fetcher, Fetcher} from '@yoroi/common' +import axios from 'axios' + +jest.mock('axios') +const mockedAxios = axios as jest.MockedFunction + +describe('getBestBlock', () => { + const baseUrl = 'https://localhost' + const mockFetch = jest.fn() + const customFetcher: Fetcher = jest + .fn() + .mockResolvedValue(bestBlockMockResponse) + + it('returns parsed data when response is valid', async () => { + mockFetch.mockResolvedValue(bestBlockMockResponse) + const tipStatus = getBestBlock(baseUrl, mockFetch) + const result = await tipStatus() + expect(result).toEqual(bestBlockMockResponse) + }) + + it('throws an error if response is invalid', async () => { + mockFetch.mockResolvedValue(null) + const tipStatus = getBestBlock(baseUrl, mockFetch) + await expect(tipStatus()).rejects.toThrow('Invalid best block response') + }) + + it('rejects when response data fails validation', async () => { + const invalidResponse = {unexpectedField: 'invalid data'} + mockFetch.mockResolvedValue(invalidResponse) + const tipStatus = getBestBlock(baseUrl, mockFetch) + + await expect(tipStatus()).rejects.toThrow('Invalid best block response') + }) + + it('uses a custom fetcher function', async () => { + const tipStatus = getBestBlock(baseUrl, customFetcher) + const result = await tipStatus() + expect(customFetcher).toHaveBeenCalled() + expect(result).toEqual(bestBlockMockResponse) + + // coverage + const tipStatus2 = getBestBlock(baseUrl) + expect(tipStatus2).toBeDefined() + }) + + it('uses fetcher and returns data on successful fetch', async () => { + mockedAxios.mockResolvedValue({data: bestBlockMockResponse}) + const tipStatus = getBestBlock(baseUrl, fetcher) + const result = await tipStatus() + + expect(mockedAxios).toHaveBeenCalled() + expect(result).toEqual(bestBlockMockResponse) + }) + + it('throws an error on network issues', async () => { + const networkError = new Error('Network Error') + mockFetch.mockRejectedValue(networkError) + const tipStatus = getBestBlock(baseUrl, mockFetch) + await expect(tipStatus()).rejects.toThrow(networkError.message) + }) +}) + +describe('isBestBlock', () => { + it('returns true for a valid best block response', () => { + expect(isBestBlock(bestBlockMockResponse)).toBe(true) + }) + + it('returns false for an invalid best block response', () => { + const invalidResponse = {...bestBlockMockResponse, epoch: 'invalid'} + expect(isBestBlock(invalidResponse)).toBe(false) + }) + + it('returns false for an incomplete best block response', () => { + const incompleteResponse = {bestBlock: {epoch: 1}} // Missing fields + expect(isBestBlock(incompleteResponse)).toBe(false) + }) +}) diff --git a/packages/api/src/cardano/api/best-block.ts b/packages/api/src/cardano/api/best-block.ts new file mode 100644 index 0000000000..7ab19a0b52 --- /dev/null +++ b/packages/api/src/cardano/api/best-block.ts @@ -0,0 +1,39 @@ +import {z} from 'zod' +import {createTypeGuardFromSchema, fetcher, Fetcher} from '@yoroi/common' +import {Api} from '@yoroi/types' + +export const getBestBlock = + (baseUrl: string, request: Fetcher = fetcher) => + async (): Promise => { + return request({ + url: `${baseUrl}/bestblock`, + data: undefined, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Response-Type': 'application/json', + }, + }).then((response: Api.Cardano.BestBlock) => { + const parsedResponse = parseBestBlock(response) + + if (!parsedResponse) + return Promise.reject(new Error('Invalid best block response')) + return Promise.resolve(parsedResponse) + }) + } + +export const parseBestBlock = ( + data: Api.Cardano.BestBlock, +): Api.Cardano.BestBlock | undefined => { + return isBestBlock(data) ? data : undefined +} + +const BestBlockSchema = z.object({ + epoch: z.number(), + slot: z.number(), + globalSlot: z.number(), + hash: z.string(), + height: z.number(), +}) + +export const isBestBlock = createTypeGuardFromSchema(BestBlockSchema) diff --git a/packages/api/src/cardano/api/cardano-api-maker.mocks.ts b/packages/api/src/cardano/api/cardano-api-maker.mocks.ts index 972c7019a2..c01d45d752 100644 --- a/packages/api/src/cardano/api/cardano-api-maker.mocks.ts +++ b/packages/api/src/cardano/api/cardano-api-maker.mocks.ts @@ -1,6 +1,7 @@ /* istanbul ignore file */ import {Api} from '@yoroi/types' import {paramsMockResponse} from './protocol-params.mocks' +import {bestBlockMockResponse} from './best-block.mocks' const loading = () => new Promise(() => {}) const unknownError = () => Promise.reject(new Error('Unknown error')) @@ -26,6 +27,18 @@ const getProtocolParams = { }, } +const getBestBlock = { + success: () => Promise.resolve(bestBlockMockResponse), + delayed: (timeout?: number) => + delayedResponse({data: bestBlockMockResponse, timeout}), + empty: () => Promise.resolve({}), + loading, + error: { + unknown: unknownError, + }, +} + export const mockCardanoApi: Api.Cardano.Api = { getProtocolParams: getProtocolParams.success, + getBestBlock: getBestBlock.success, } as const diff --git a/packages/api/src/cardano/api/cardano-api-maker.ts b/packages/api/src/cardano/api/cardano-api-maker.ts index 0ed82ef9af..7e2ce201a1 100644 --- a/packages/api/src/cardano/api/cardano-api-maker.ts +++ b/packages/api/src/cardano/api/cardano-api-maker.ts @@ -1,7 +1,9 @@ import {Fetcher, fetcher} from '@yoroi/common' +import {freeze} from 'immer' +import {Api, Chain} from '@yoroi/types' import {getProtocolParams as getProtocolParamsWrapper} from './protocol-params' -import {Api, Chain} from '@yoroi/types' +import {getBestBlock as getBestBlockWrapper} from './best-block' import {API_ENDPOINTS} from './config' export const cardanoApiMaker = ({ @@ -13,8 +15,10 @@ export const cardanoApiMaker = ({ }): Readonly => { const baseUrl = API_ENDPOINTS[network].root const getProtocolParams = getProtocolParamsWrapper(baseUrl, request) + const getBestBlock = getBestBlockWrapper(baseUrl, request) - return { + return freeze({ getProtocolParams, - } as const + getBestBlock, + } as const) } diff --git a/packages/api/src/cardano/api/config.ts b/packages/api/src/cardano/api/config.ts index 3e4859d441..8417fd4924 100644 --- a/packages/api/src/cardano/api/config.ts +++ b/packages/api/src/cardano/api/config.ts @@ -1,8 +1,9 @@ import {Chain} from '@yoroi/types' +import {freeze} from 'immer' export const API_ENDPOINTS: Readonly< Record -> = { +> = freeze({ [Chain.Network.Mainnet]: { root: 'https://zero.yoroiwallet.com', }, @@ -15,4 +16,4 @@ export const API_ENDPOINTS: Readonly< [Chain.Network.Preview]: { root: 'https://yoroi-backend-zero-preview.emurgornd.com', }, -} as const +} as const) diff --git a/packages/api/src/cardano/api/protocol-params.mocks.ts b/packages/api/src/cardano/api/protocol-params.mocks.ts index e6797d1a36..d11036027f 100644 --- a/packages/api/src/cardano/api/protocol-params.mocks.ts +++ b/packages/api/src/cardano/api/protocol-params.mocks.ts @@ -1,6 +1,6 @@ -import {Api} from '@yoroi/types' +import {Chain} from '@yoroi/types' -export const paramsMockResponse: Api.Cardano.ProtocolParams = { +export const paramsMockResponse: Chain.Cardano.ProtocolParams = { coinsPerUtxoByte: '4310', keyDeposit: '2000000', linearFee: {coefficient: '44', constant: '155381'}, diff --git a/packages/claim/src/manager.test.ts b/packages/claim/src/manager.test.ts index 19b57cd9c0..777196bed0 100644 --- a/packages/claim/src/manager.test.ts +++ b/packages/claim/src/manager.test.ts @@ -1,5 +1,9 @@ -import {fetchData} from '@yoroi/common' -import {tokenInfoMocks, tokenMocks} from '@yoroi/portfolio' +import {cacheRecordMaker, fetchData} from '@yoroi/common' +import { + createTokenManagerMock, + tokenInfoMocks, + tokenMocks, +} from '@yoroi/portfolio' import {Api, Portfolio, Scan} from '@yoroi/types' import {claimManagerMaker} from './manager' @@ -27,31 +31,24 @@ describe('claimManagerMaker - postClaimTokens', () => { jest.clearAllMocks() }) - const tokenManagerMock = { - sync: jest.fn(), - api: { - tokenActivity: jest.fn(), - tokenDiscovery: jest.fn(), - tokenImageInvalidate: jest.fn(), - tokenInfo: jest.fn(), - tokenInfos: jest.fn(), - tokenTraits: jest.fn(), - }, - clear: jest.fn(), - destroy: jest.fn(), - hydrate: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - observable$: {} as any, - } + const tokenManagerMock = createTokenManagerMock() tokenManagerMock.sync.mockResolvedValue( new Map([ [ tokenMocks.nftCryptoKitty.info.id, - {record: tokenMocks.nftCryptoKitty.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.nftCryptoKitty.info, + ), + ], + [ + tokenMocks.rnftWhatever.info.id, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.rnftWhatever.info, + ), ], - [tokenMocks.rnftWhatever.info.id, {record: tokenMocks.rnftWhatever.info}], ]), ) diff --git a/packages/claim/src/transformers.test.ts b/packages/claim/src/transformers.test.ts index 2c81677982..386fa4941b 100644 --- a/packages/claim/src/transformers.test.ts +++ b/packages/claim/src/transformers.test.ts @@ -1,27 +1,12 @@ import {Api, Claim, Portfolio} from '@yoroi/types' -import {tokenMocks} from '@yoroi/portfolio' +import {tokenMocks, createTokenManagerMock} from '@yoroi/portfolio' import {asClaimApiError, asClaimToken} from './transformers' import {claimFaucetResponses} from './api-faucet.mocks' import {claimApiMockResponses} from './manager.mocks' +import {cacheRecordMaker} from '@yoroi/common' -const tokenManagerMock = { - sync: jest.fn(), - api: { - tokenActivity: jest.fn(), - tokenDiscovery: jest.fn(), - tokenImageInvalidate: jest.fn(), - tokenInfo: jest.fn(), - tokenInfos: jest.fn(), - tokenTraits: jest.fn(), - }, - clear: jest.fn(), - destroy: jest.fn(), - hydrate: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - observable$: {} as any, -} +const tokenManagerMock = createTokenManagerMock() describe('asClaimApiError', () => { afterEach(() => { @@ -65,11 +50,17 @@ describe('asClaimToken', () => { new Map([ [ tokenMocks.nftCryptoKitty.info.id, - {record: tokenMocks.nftCryptoKitty.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.nftCryptoKitty.info, + ), ], [ tokenMocks.rnftWhatever.info.id, - {record: tokenMocks.rnftWhatever.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.rnftWhatever.info, + ), ], ]), ) @@ -98,11 +89,17 @@ describe('asClaimToken', () => { new Map([ [ tokenMocks.nftCryptoKitty.info.id, - {record: tokenMocks.nftCryptoKitty.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.nftCryptoKitty.info, + ), ], [ tokenMocks.rnftWhatever.info.id, - {record: tokenMocks.rnftWhatever.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.rnftWhatever.info, + ), ], ]), ) @@ -123,11 +120,17 @@ describe('asClaimToken', () => { new Map([ [ tokenMocks.nftCryptoKitty.info.id, - {record: tokenMocks.nftCryptoKitty.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.nftCryptoKitty.info, + ), ], [ tokenMocks.rnftWhatever.info.id, - {record: tokenMocks.rnftWhatever.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.rnftWhatever.info, + ), ], ]), ) @@ -156,13 +159,19 @@ describe('asClaimToken', () => { new Map([ [ tokenMocks.nftCryptoKitty.info.id, - {record: tokenMocks.nftCryptoKitty.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.nftCryptoKitty.info, + ), ], [ tokenMocks.rnftWhatever.info.id, - {record: tokenMocks.rnftWhatever.info}, + cacheRecordMaker( + {expires: Date.now() + 3_600_000, hash: 'hash3'}, + tokenMocks.rnftWhatever.info, + ), ], - ['invalid.', undefined], + ['invalid.', undefined] as any, ['dead.', {record: tokenMocks.rnftWhatever.info}], ]), ) diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts index b6d5ba795f..07892f19da 100644 --- a/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts +++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts @@ -5,6 +5,7 @@ import {Api, Portfolio} from '@yoroi/types' import {tokenMocks} from '../token.mocks' import {tokenTraitsMocks} from '../token-traits.mocks' import {tokenActivityMocks} from '../token-activity.mocks' +import {tokenHistoryMocks} from '../token-history.mocks' export const responseTokenDiscoveryMocks = asyncBehavior.maker< Api.Response @@ -59,6 +60,19 @@ export const responseTokenActivity = asyncBehavior.maker< emptyRepresentation: null, }) +export const responseTokenHistory = asyncBehavior.maker< + Api.Response +>({ + data: { + tag: 'right', + value: { + status: 200, + data: tokenHistoryMocks.api.responseDataOnly, + }, + }, + emptyRepresentation: null, +}) + export const responseTokenImageInvalidate = asyncBehavior.maker({ data: undefined, emptyRepresentation: null, @@ -70,6 +84,7 @@ const success: Portfolio.Api.Api = { tokenInfos: responseTokenInfosMocks.success, tokenTraits: responseTokenTraits.success, tokenActivity: responseTokenActivity.success, + tokenHistory: responseTokenHistory.success, tokenImageInvalidate: responseTokenImageInvalidate.success, } @@ -79,6 +94,7 @@ const delayed: Portfolio.Api.Api = { tokenInfos: responseTokenInfosMocks.delayed, tokenTraits: responseTokenTraits.delayed, tokenActivity: responseTokenActivity.delayed, + tokenHistory: responseTokenHistory.delayed, tokenImageInvalidate: responseTokenImageInvalidate.delayed, } @@ -88,6 +104,7 @@ const loading: Portfolio.Api.Api = { tokenInfos: responseTokenInfosMocks.loading, tokenTraits: responseTokenTraits.loading, tokenActivity: responseTokenActivity.loading, + tokenHistory: responseTokenHistory.loading, tokenImageInvalidate: responseTokenImageInvalidate.loading, } @@ -137,6 +154,15 @@ const error: Portfolio.Api.Api = { responseData: {message: 'Bad Request'}, }, }), + tokenHistory: () => + Promise.resolve({ + tag: 'left', + error: { + status: 400, + message: 'Bad Request', + responseData: {message: 'Bad Request'}, + }, + }), tokenImageInvalidate: () => Promise.resolve(undefined), } @@ -146,6 +172,7 @@ const empty: Portfolio.Api.Api = { tokenInfos: responseTokenInfosMocks.empty, tokenTraits: responseTokenTraits.empty, tokenActivity: responseTokenActivity.empty, + tokenHistory: responseTokenHistory.empty, tokenImageInvalidate: responseTokenImageInvalidate.empty, } diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts index cc43a58d37..48a510cdab 100644 --- a/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts +++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts @@ -6,6 +6,7 @@ import {tokenDiscoveryMocks} from '../token-discovery.mocks' import {tokenMocks} from '../token.mocks' import {tokenActivityMocks} from '../token-activity.mocks' import {tokenImageInvalidateMocks} from '../token-image-invalidate.mocks' +import {tokenHistoryMocks} from '../token-history.mocks' describe('portfolioApiMaker', () => { const mockNetwork: Chain.Network = Chain.Network.Mainnet @@ -29,6 +30,7 @@ describe('portfolioApiMaker', () => { expect(api).toHaveProperty('tokenInfos') expect(api).toHaveProperty('tokenTraits') expect(api).toHaveProperty('tokenActivity') + expect(api).toHaveProperty('tokenHistory') expect(api).toHaveProperty('tokenImageInvalidate') }) @@ -44,6 +46,7 @@ describe('portfolioApiMaker', () => { expect(api).toHaveProperty('tokenInfos') expect(api).toHaveProperty('tokenTraits') expect(api).toHaveProperty('tokenActivity') + expect(api).toHaveProperty('tokenHistory') expect(api).toHaveProperty('tokenImageInvalidate') }) @@ -51,7 +54,7 @@ describe('portfolioApiMaker', () => { mockRequest.mockResolvedValue({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: {}, }, }) @@ -76,8 +79,12 @@ describe('portfolioApiMaker', () => { tokenActivityMocks.api.request, Portfolio.Token.ActivityWindow.OneDay, ) + await api.tokenHistory( + tokenHistoryMocks.api.request.tokenId, + tokenHistoryMocks.api.request.period, + ) - expect(mockRequest).toHaveBeenCalledTimes(5) + expect(mockRequest).toHaveBeenCalledTimes(6) expect(mockRequest).toHaveBeenCalledWith({ method: 'get', @@ -132,13 +139,22 @@ describe('portfolioApiMaker', () => { 'Content-Type': 'application/json', }, }) + expect(mockRequest).toHaveBeenCalledWith({ + method: 'post', + url: apiConfig[Chain.Network.Mainnet].tokenHistory, + data: tokenHistoryMocks.api.request, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) }) it('should return error when returning data is malformed', async () => { mockRequest.mockResolvedValue({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: { ['wrong']: [200, 'data'], }, @@ -256,7 +272,7 @@ describe('portfolioApiMaker', () => { }, }) - const resultTokenActivityUpdates = await api.tokenActivity( + const resultTokenActivity = await api.tokenActivity( tokenActivityMocks.api.request, Portfolio.Token.ActivityWindow.OneDay, ) @@ -273,7 +289,7 @@ describe('portfolioApiMaker', () => { }, }) - expect(resultTokenActivityUpdates).toEqual({ + expect(resultTokenActivity).toEqual({ tag: 'left', error: { status: -3, @@ -283,13 +299,39 @@ describe('portfolioApiMaker', () => { }, }, }) + + const resultTokenHistory = await api.tokenHistory( + tokenHistoryMocks.api.request.tokenId, + tokenHistoryMocks.api.request.period, + ) + expect(mockRequest).toHaveBeenCalledTimes(6) + expect(mockRequest).toHaveBeenCalledWith({ + method: 'post', + url: apiConfig[Chain.Network.Mainnet].tokenHistory, + data: tokenHistoryMocks.api.request, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) + + expect(resultTokenHistory).toEqual({ + tag: 'left', + error: { + status: -3, + message: 'Failed to transform token history response', + responseData: { + ['wrong']: [200, 'data'], + }, + }, + }) }) it('should return the error and not throw', async () => { mockRequest.mockResolvedValue({ tag: 'left', value: { - status: 500, + status: Api.HttpStatusCode.InternalServerError, message: 'Internal Server Error', responseData: {}, }, @@ -310,7 +352,7 @@ describe('portfolioApiMaker', () => { await expect(api.tokenInfos(mockTokenIdsWithCache)).resolves.toEqual({ tag: 'left', value: { - status: 500, + status: Api.HttpStatusCode.InternalServerError, message: 'Internal Server Error', responseData: {}, }, @@ -331,7 +373,7 @@ describe('portfolioApiMaker', () => { ).resolves.toEqual({ tag: 'left', value: { - status: 500, + status: Api.HttpStatusCode.InternalServerError, message: 'Internal Server Error', responseData: {}, }, @@ -354,7 +396,7 @@ describe('portfolioApiMaker', () => { ).resolves.toEqual({ tag: 'left', value: { - status: 500, + status: Api.HttpStatusCode.InternalServerError, message: 'Internal Server Error', responseData: {}, }, @@ -377,7 +419,7 @@ describe('portfolioApiMaker', () => { ).resolves.toEqual({ tag: 'left', value: { - status: 500, + status: Api.HttpStatusCode.InternalServerError, message: 'Internal Server Error', responseData: {}, }, @@ -403,7 +445,7 @@ describe('portfolioApiMaker', () => { ).resolves.toEqual({ tag: 'left', value: { - status: 500, + status: Api.HttpStatusCode.InternalServerError, message: 'Internal Server Error', responseData: {}, }, @@ -420,13 +462,37 @@ describe('portfolioApiMaker', () => { 'Content-Type': 'application/json', }, }) + + await expect( + api.tokenHistory( + tokenHistoryMocks.api.request.tokenId, + tokenHistoryMocks.api.request.period, + ), + ).resolves.toEqual({ + tag: 'left', + value: { + status: Api.HttpStatusCode.InternalServerError, + message: 'Internal Server Error', + responseData: {}, + }, + }) + expect(mockRequest).toHaveBeenCalledTimes(6) + expect(mockRequest).toHaveBeenCalledWith({ + method: 'post', + url: apiConfig[Chain.Network.Mainnet].tokenHistory, + data: tokenHistoryMocks.api.request, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) }) it('should return the data on success (traits)', async () => { mockRequest.mockResolvedValue({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: tokenMocks.nftCryptoKitty.traits, }, }) @@ -453,7 +519,7 @@ describe('portfolioApiMaker', () => { expect(result).toEqual({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: tokenMocks.nftCryptoKitty.traits, }, }) @@ -463,7 +529,7 @@ describe('portfolioApiMaker', () => { mockRequest.mockResolvedValue({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: tokenMocks.nftCryptoKitty.info, }, }) @@ -490,7 +556,7 @@ describe('portfolioApiMaker', () => { expect(result).toEqual({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: tokenMocks.nftCryptoKitty.info, }, }) @@ -527,17 +593,52 @@ describe('portfolioApiMaker', () => { expect(result).toEqual({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: tokenActivityMocks.api.responseDataOnly, }, }) }) + it('should return the data on success (tokenHistory)', async () => { + mockRequest.mockResolvedValue(tokenHistoryMocks.api.responses.success) + + const api = portfolioApiMaker({ + network: mockNetwork, + request: mockRequest, + maxIdsPerRequest: 10, + maxConcurrentRequests: 10, + }) + + const result = await api.tokenHistory( + tokenHistoryMocks.api.request.tokenId, + tokenHistoryMocks.api.request.period, + ) + + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(mockRequest).toHaveBeenCalledWith({ + method: 'post', + url: apiConfig[Chain.Network.Mainnet].tokenHistory, + data: tokenHistoryMocks.api.request, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) + + expect(result).toEqual({ + tag: 'right', + value: { + status: Api.HttpStatusCode.Ok, + data: tokenHistoryMocks.api.responseDataOnly, + }, + }) + }) + it('should return error when returning data is malformed token-discovery', async () => { mockRequest.mockResolvedValue({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: 0, }, }) @@ -577,7 +678,7 @@ describe('portfolioApiMaker', () => { mockRequest.mockResolvedValue({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: { ...tokenDiscoveryMocks.nftCryptoKitty, supply: undefined, @@ -605,7 +706,7 @@ describe('portfolioApiMaker', () => { mockRequest.mockResolvedValue({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: tokenDiscoveryMocks.nftCryptoKitty, }, }) @@ -616,7 +717,7 @@ describe('portfolioApiMaker', () => { expect(right).toEqual({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: tokenDiscoveryMocks.nftCryptoKitty, }, }) @@ -626,7 +727,7 @@ describe('portfolioApiMaker', () => { mockRequest.mockResolvedValue({ tag: 'right', value: { - status: 200, + status: Api.HttpStatusCode.Ok, data: {}, }, }) diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.ts index f49cfac336..a101999e22 100644 --- a/packages/portfolio/src/adapters/dullahan-api/api-maker.ts +++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.ts @@ -13,12 +13,14 @@ import { toDullahanRequest, toProcessedMediaRequest, toSecondaryTokenInfos, - toTokenActivityUpdates, + toTokenActivity, + toTokenHistory, } from './transformers' import { DullahanApiCachedIdsRequest, DullahanApiTokenActivityResponse, DullahanApiTokenDiscoveryResponse, + DullahanApiTokenHistoryResponse, DullahanApiTokenInfoResponse, DullahanApiTokenInfosResponse, DullahanApiTokenTraitsResponse, @@ -267,7 +269,7 @@ export const portfolioApiMaker = ({ return firstError try { - const transformedResponseData = toTokenActivityUpdates(activities) + const transformedResponseData = toTokenActivity(activities) const transformedResponse: Api.Response = freeze( @@ -297,6 +299,51 @@ export const portfolioApiMaker = ({ } }, + async tokenHistory(tokenId, period) { + const response = await request({ + method: 'post', + url: config.tokenHistory, + data: {tokenId, period}, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }) + + if (isRight(response)) { + const history: Portfolio.Token.History | undefined = toTokenHistory( + response.value.data, + ) + + if (!history) { + return freeze( + { + tag: 'left', + error: { + status: -3, + message: 'Failed to transform token history response', + responseData: response.value.data, + }, + }, + true, + ) + } + + return freeze( + { + tag: 'right', + value: { + status: response.value.status, + data: history, + }, + }, + true, + ) + } + + return response + }, + async tokenImageInvalidate(ids) { const tasks = ids.map( (id) => () => @@ -325,8 +372,7 @@ export const apiConfig: ApiConfig = freeze( tokenInfos: 'https://zero.yoroiwallet.com/tokens/info/multi', tokenTraits: 'https://zero.yoroiwallet.com/tokens/nft/traits', tokenActivity: 'https://zero.yoroiwallet.com/tokens/activity/multi', - tokenPriceHistory: - 'https://add50d9d-76d7-47b7-b17f-e34021f63a02.mock.pstmn.io/v1/token-price-history', + tokenHistory: 'https://zero.yoroiwallet.com/tokens/history/price', tokenImageInvalidate: 'https://mainnet.processed-media.yoroiwallet.com/invalidate', }, @@ -340,8 +386,8 @@ export const apiConfig: ApiConfig = freeze( 'https://yoroi-backend-zero-preprod.emurgornd.com/tokens/nft/traits', tokenActivity: 'https://yoroi-backend-zero-preprod.emurgornd.com/tokens/activity/multi', - tokenPriceHistory: - 'https://add50d9d-76d7-47b7-b17f-e34021f63a02.mock.pstmn.io/v1/token-price-history', + tokenHistory: + 'https://yoroi-backend-zero-preprod.emurgornd.com/tokens/history/price', tokenImageInvalidate: 'https://preprod.processed-media.yoroiwallet.com/invalidate', }, @@ -356,8 +402,8 @@ export const apiConfig: ApiConfig = freeze( 'https://yoroi-backend-zero-sanchonet.emurgornd.com/tokens/nft/traits', tokenActivity: 'https://yoroi-backend-zero-sanchonet.emurgornd.com/tokens/activity/multi', - tokenPriceHistory: - 'https://add50d9d-76d7-47b7-b17f-e34021f63a02.mock.pstmn.io/v1/token-price-history', + tokenHistory: + 'https://yoroi-backend-zero-sanchonet.emurgornd.com/tokens/history/price', tokenImageInvalidate: 'https://preprod.processed-media.yoroiwallet.com/invalidate', }, @@ -371,8 +417,8 @@ export const apiConfig: ApiConfig = freeze( 'https://yoroi-backend-zero-preview.emurgornd.com/tokens/nft/traits', tokenActivity: 'https://yoroi-backend-zero-preview.emurgornd.com/tokens/activity/multi', - tokenPriceHistory: - 'https://add50d9d-76d7-47b7-b17f-e34021f63a02.mock.pstmn.io/v1/token-price-history', + tokenHistory: + 'https://yoroi-backend-zero-preview.emurgornd.com/tokens/history/price', tokenImageInvalidate: 'https://preview.processed-media.yoroiwallet.com/invalidate', }, diff --git a/packages/portfolio/src/adapters/dullahan-api/token-activity.mocks.ts b/packages/portfolio/src/adapters/dullahan-api/token-activity.mocks.ts index 930c6851a1..7e24eaf861 100644 --- a/packages/portfolio/src/adapters/dullahan-api/token-activity.mocks.ts +++ b/packages/portfolio/src/adapters/dullahan-api/token-activity.mocks.ts @@ -53,7 +53,7 @@ const apiResponseSuccessDataOnly: Readonly = { [tokenInfoMocks.ftNameless.id]: [Api.HttpStatusCode.Ok, ftNameless], } -export const duallahanTokenActivityUpdatesMocks = { +export const duallahanTokenActivityMocks = { primaryETH, rnftWhatever, ftNoTicker, diff --git a/packages/portfolio/src/adapters/dullahan-api/transformers.test.ts b/packages/portfolio/src/adapters/dullahan-api/transformers.test.ts index 7c9813798c..52498eeb42 100644 --- a/packages/portfolio/src/adapters/dullahan-api/transformers.test.ts +++ b/packages/portfolio/src/adapters/dullahan-api/transformers.test.ts @@ -2,14 +2,19 @@ import { toDullahanRequest, toProcessedMediaRequest, toSecondaryTokenInfos, - toTokenActivityUpdates, + toTokenActivity, + toTokenHistory, } from './transformers' import {Portfolio, Api} from '@yoroi/types' import {tokenMocks} from '../token.mocks' -import {DullahanApiTokenActivityResponse} from './types' +import { + DullahanApiTokenActivityResponse, + DullahanApiTokenHistoryResponse, +} from './types' import {tokenActivityMocks} from '../token-activity.mocks' -import {duallahanTokenActivityUpdatesMocks} from './token-activity.mocks' +import {duallahanTokenActivityMocks} from './token-activity.mocks' +import {tokenHistoryMocks} from '../token-history.mocks' describe('transformers', () => { describe('toSecondaryTokenInfos', () => { @@ -107,24 +112,42 @@ describe('transformers', () => { }) }) - describe('toTokenActivityUpdates', () => { - it('should return an empty object if apiTokenActivityUpdates response is empty', () => { + describe('toTokenActivity', () => { + it('should return an empty object if apiTokenActivity response is empty', () => { const apiTokenInfosResponse: DullahanApiTokenActivityResponse = {} - expect(toTokenActivityUpdates(apiTokenInfosResponse)).toEqual({}) + expect(toTokenActivity(apiTokenInfosResponse)).toEqual({}) }) it('should return the data and deal with empty records', () => { const responseWithEmptyRecords = { - ...duallahanTokenActivityUpdatesMocks.api.responseSuccessDataOnly, + ...duallahanTokenActivityMocks.api.responseSuccessDataOnly, 'token.4': undefined, 'token.5': [Api.HttpStatusCode.InternalServerError, 'Not found'], } as any - const result = toTokenActivityUpdates(responseWithEmptyRecords) + const result = toTokenActivity(responseWithEmptyRecords) expect(result).toEqual(tokenActivityMocks.api.responseDataOnly) }) }) + + describe('toTokenHistory', () => { + it('should return undefined if apiTokenHistory response is malformed', () => { + expect( + toTokenHistory({ + whatever: false, + } as unknown as DullahanApiTokenHistoryResponse), + ).toEqual(undefined) + }) + + it('should return the data', () => { + const result = toTokenHistory( + tokenHistoryMocks.ftNamelessRaw as unknown as DullahanApiTokenHistoryResponse, + ) + + expect(result).toEqual(tokenHistoryMocks.api.responseDataOnly) + }) + }) }) describe('toDullahanRequest', () => { diff --git a/packages/portfolio/src/adapters/dullahan-api/transformers.ts b/packages/portfolio/src/adapters/dullahan-api/transformers.ts index 339385ac39..4bc6c06a48 100644 --- a/packages/portfolio/src/adapters/dullahan-api/transformers.ts +++ b/packages/portfolio/src/adapters/dullahan-api/transformers.ts @@ -6,6 +6,7 @@ import {parseSecondaryTokenInfoWithCacheRecord} from '../../validators/token-inf import { DullahanApiCachedIdsRequest, DullahanApiTokenActivityResponse, + DullahanApiTokenHistoryResponse, DullahanApiTokenInfosResponse, } from './types' import {z} from 'zod' @@ -49,46 +50,58 @@ export const toSecondaryTokenInfos = ( ) } -export const toTokenActivityUpdates = ( +export const toTokenActivity = ( apiTokenActivityResponse: Readonly, ) => { - const tokenActivityUpdates: Record< - Portfolio.Token.Id, - Portfolio.Token.Activity - > = {} + const tokenActivity: Record = {} return freeze( - Object.entries(apiTokenActivityResponse).reduce( - (acc, [id, tokenActivity]) => { - if (!Array.isArray(tokenActivity)) return acc - const castedId = id as Portfolio.Token.Id + Object.entries(apiTokenActivityResponse).reduce((acc, [id, response]) => { + if (!Array.isArray(response)) return acc + const castedId = id as Portfolio.Token.Id - const [statusCode, tokenActivityData] = tokenActivity - if (statusCode !== Api.HttpStatusCode.Ok) return acc + const [statusCode, tokenActivityData] = response + if (statusCode !== Api.HttpStatusCode.Ok) return acc - TokenAcvitivyResponseSchema.parse(tokenActivityData) + TokenActivityResponseSchema.parse(tokenActivityData) - const parsedTokenActivity: Portfolio.Token.Activity = { - price: { - ts: tokenActivityData.price.ts, - open: new BigNumber(tokenActivityData.price.open), - close: new BigNumber(tokenActivityData.price.close), - low: new BigNumber(tokenActivityData.price.low), - high: new BigNumber(tokenActivityData.price.high), - change: tokenActivityData.price.change, - }, - } + const parsedTokenActivity: Portfolio.Token.Activity = { + price: { + ts: tokenActivityData.price.ts, + open: new BigNumber(tokenActivityData.price.open), + close: new BigNumber(tokenActivityData.price.close), + low: new BigNumber(tokenActivityData.price.low), + high: new BigNumber(tokenActivityData.price.high), + change: tokenActivityData.price.change, + }, + } - acc[castedId] = parsedTokenActivity + acc[castedId] = parsedTokenActivity - return acc - }, - tokenActivityUpdates, - ), + return acc + }, tokenActivity), true, ) } +export const toTokenHistory = ( + apiTokenHistoryResponse: Readonly, +) => { + if (!TokenHistoryResponseSchema.safeParse(apiTokenHistoryResponse).success) + return undefined + + return freeze({ + prices: apiTokenHistoryResponse.prices.map((tokenHistoryRecord) => ({ + ts: tokenHistoryRecord.ts, + open: new BigNumber(tokenHistoryRecord.open), + close: new BigNumber(tokenHistoryRecord.close), + low: new BigNumber(tokenHistoryRecord.low), + high: new BigNumber(tokenHistoryRecord.high), + change: tokenHistoryRecord.change, + })), + }) +} + export const toDullahanRequest = ( request: ReadonlyArray>, ) => @@ -96,7 +109,7 @@ export const toDullahanRequest = ( ([tokenId, hash]) => `${tokenId}:${hash}`, ) as DullahanApiCachedIdsRequest -const TokenAcvitivyResponseSchema = z.object({ +const TokenActivityResponseSchema = z.object({ price: z.object({ ts: z.number(), open: z.string(), @@ -107,6 +120,19 @@ const TokenAcvitivyResponseSchema = z.object({ }), }) +const TokenHistoryResponseSchema = z.object({ + prices: z.array( + z.object({ + ts: z.number(), + open: z.string(), + close: z.string(), + low: z.string(), + high: z.string(), + change: z.number(), + }), + ), +}) + export const toProcessedMediaRequest = (request: Portfolio.Token.Id) => { const [policy, name] = request.split('.') as [string, string] return {policy, name} diff --git a/packages/portfolio/src/adapters/dullahan-api/types.ts b/packages/portfolio/src/adapters/dullahan-api/types.ts index d5f5c9d20b..25160eabbd 100644 --- a/packages/portfolio/src/adapters/dullahan-api/types.ts +++ b/packages/portfolio/src/adapters/dullahan-api/types.ts @@ -42,6 +42,8 @@ export type DullahanApiTokenActivityResponse = Readonly<{ [key: Portfolio.Token.Id]: DullahanApiTokenActivity }> +export type DullahanApiTokenHistoryResponse = Readonly + export type ProcessedMediaApiTokenImageInvalidateRequest = { name: string policy: string diff --git a/packages/portfolio/src/adapters/token-activity.mocks.ts b/packages/portfolio/src/adapters/token-activity.mocks.ts index 10c8d5e2ce..ac5e72d044 100644 --- a/packages/portfolio/src/adapters/token-activity.mocks.ts +++ b/packages/portfolio/src/adapters/token-activity.mocks.ts @@ -2,7 +2,7 @@ import {Api, Portfolio} from '@yoroi/types' import {freeze} from 'immer' import {BigNumber} from 'bignumber.js' import {tokenInfoMocks} from './token-info.mocks' -import {duallahanTokenActivityUpdatesMocks} from './dullahan-api/token-activity.mocks' +import {duallahanTokenActivityMocks} from './dullahan-api/token-activity.mocks' const primaryETH: Portfolio.Token.Activity = { price: { @@ -58,16 +58,14 @@ const apiResponseSuccessDataOnly = { const apiResponseTokenActivity: Readonly< Record< 'success' | 'error', - Api.Response< - typeof duallahanTokenActivityUpdatesMocks.api.responseSuccessDataOnly - > + Api.Response > > = { success: { tag: 'right', value: { status: 200, - data: duallahanTokenActivityUpdatesMocks.api.responseSuccessDataOnly, + data: duallahanTokenActivityMocks.api.responseSuccessDataOnly, }, }, error: { diff --git a/packages/portfolio/src/adapters/token-history.mocks.ts b/packages/portfolio/src/adapters/token-history.mocks.ts new file mode 100644 index 0000000000..583628f4f6 --- /dev/null +++ b/packages/portfolio/src/adapters/token-history.mocks.ts @@ -0,0 +1,83 @@ +import {Api, Portfolio} from '@yoroi/types' +import {freeze} from 'immer' +import {BigNumber} from 'bignumber.js' + +const ftNamelessRaw = { + prices: [ + { + ts: 1722849529169, + open: '500000', + close: '1000000', + low: '500000', + high: '1000000', + change: 100, + }, + { + ts: 1722949529169, + open: '500000', + close: '1000000', + low: '500000', + high: '1000000', + change: 100, + }, + ], +} + +const ftNameless: Portfolio.Token.History = { + prices: [ + { + ts: 1722849529169, + open: new BigNumber(500_000), + low: new BigNumber(500_000), + close: new BigNumber(1_000_000), + high: new BigNumber(1_000_000), + change: 100, + }, + { + ts: 1722949529169, + open: new BigNumber(500_000), + low: new BigNumber(500_000), + close: new BigNumber(1_000_000), + high: new BigNumber(1_000_000), + change: 100, + }, + ], +} + +const apiResponseTokenHistory: Readonly< + Record<'success' | 'error', Api.Response> +> = { + success: { + tag: 'right', + value: { + status: 200, + data: ftNamelessRaw, + }, + }, + error: { + tag: 'left', + error: { + status: 500, + responseData: null, + message: 'Internal Server Error', + }, + }, +} + +const apiRequestTokenHistoryArgs: Readonly<{ + tokenId: Portfolio.Token.Id + period: Portfolio.Token.HistoryPeriod +}> = { + tokenId: 'ft.nameless', + period: Portfolio.Token.HistoryPeriod.OneDay, +} + +export const tokenHistoryMocks = freeze({ + ftNameless, + ftNamelessRaw, + api: { + responses: apiResponseTokenHistory, + request: apiRequestTokenHistoryArgs, + responseDataOnly: ftNameless, + }, +}) diff --git a/packages/portfolio/src/balance-manager.test.ts b/packages/portfolio/src/balance-manager.test.ts index 32a3964ca7..bffab5c420 100644 --- a/packages/portfolio/src/balance-manager.test.ts +++ b/packages/portfolio/src/balance-manager.test.ts @@ -10,6 +10,7 @@ import {portfolioBalanceStorageMaker} from './adapters/mmkv-storage/balance-stor import {tokenInfoMocks} from './adapters/token-info.mocks' import {isFt} from './helpers/is-ft' import {isNft} from './helpers/is-nft' +import {createTokenManagerMock} from './token-manager.mock' const tokenInfoStorage = observableStorageMaker( mountMMKVStorage({ @@ -37,23 +38,7 @@ describe('portfolioBalanceManagerMaker', () => { primaryBreakdownStorage, primaryTokenId, }) - const tokenManager: Portfolio.Manager.Token = { - destroy: jest.fn(), - hydrate: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - observable$: new BehaviorSubject({} as any).asObservable(), - sync: jest.fn().mockResolvedValue(new Map()), - clear: jest.fn(), - api: { - tokenInfo: jest.fn(), - tokenInfos: jest.fn(), - tokenDiscovery: jest.fn(), - tokenTraits: jest.fn(), - tokenActivity: jest.fn(), - tokenImageInvalidate: jest.fn(), - }, - } + const tokenManager = createTokenManagerMock() it('should be instantiated', () => { const manager = portfolioBalanceManagerMaker({ @@ -85,23 +70,7 @@ describe('hydrate', () => { primaryBreakdownStorage, primaryTokenId, }) - const tokenManager: Portfolio.Manager.Token = { - destroy: jest.fn(), - hydrate: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - observable$: new BehaviorSubject({} as any).asObservable(), - sync: jest.fn().mockResolvedValue(new Map()), - clear: jest.fn(), - api: { - tokenInfo: jest.fn(), - tokenInfos: jest.fn(), - tokenDiscovery: jest.fn(), - tokenTraits: jest.fn(), - tokenActivity: jest.fn(), - tokenImageInvalidate: jest.fn(), - }, - } + const tokenManager = createTokenManagerMock() afterEach(() => { storage.clear() @@ -174,24 +143,7 @@ describe('destroy', () => { primaryBreakdownStorage, primaryTokenId, }) - const tokenManagerObservable = new BehaviorSubject({} as any).asObservable() - const tokenManager: jest.Mocked = { - destroy: jest.fn(), - hydrate: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - observable$: tokenManagerObservable, - sync: jest.fn().mockResolvedValue(new Map()), - clear: jest.fn(), - api: { - tokenInfo: jest.fn(), - tokenInfos: jest.fn(), - tokenDiscovery: jest.fn(), - tokenTraits: jest.fn(), - tokenActivity: jest.fn(), - tokenImageInvalidate: jest.fn(), - }, - } + const tokenManager = createTokenManagerMock() const queueDestroy = jest.fn() const observerDestroy = jest.fn() @@ -258,24 +210,7 @@ describe('primary updates', () => { primaryBreakdownStorage, primaryTokenId, }) - const tokenManagerObservable = new BehaviorSubject({} as any) - const tokenManager: jest.Mocked = { - destroy: jest.fn(), - hydrate: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - observable$: tokenManagerObservable.asObservable(), - sync: jest.fn().mockResolvedValue(new Map()), - clear: jest.fn(), - api: { - tokenInfo: jest.fn(), - tokenInfos: jest.fn(), - tokenDiscovery: jest.fn(), - tokenTraits: jest.fn(), - tokenActivity: jest.fn(), - tokenImageInvalidate: jest.fn(), - }, - } + const tokenManager = createTokenManagerMock() afterEach(() => { storage.clear() @@ -418,23 +353,9 @@ describe('sync & refresh', () => { primaryTokenId, }) const tokenManagerObservable = new BehaviorSubject({} as any) - const tokenManager: jest.Mocked = { - destroy: jest.fn(), - hydrate: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - observable$: tokenManagerObservable.asObservable(), - sync: jest.fn().mockResolvedValue(new Map()), - clear: jest.fn(), - api: { - tokenInfo: jest.fn(), - tokenInfos: jest.fn(), - tokenDiscovery: jest.fn(), - tokenTraits: jest.fn(), - tokenActivity: jest.fn(), - tokenImageInvalidate: jest.fn(), - }, - } + const tokenManager = createTokenManagerMock( + tokenManagerObservable.asObservable(), + ) afterEach(() => { storage.clear() @@ -710,23 +631,7 @@ describe('clear', () => { primaryBreakdownStorage, primaryTokenId, }) - const tokenManager: Portfolio.Manager.Token = { - destroy: jest.fn(), - hydrate: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - observable$: new BehaviorSubject({} as any).asObservable(), - sync: jest.fn().mockResolvedValue(new Map()), - clear: jest.fn(), - api: { - tokenInfo: jest.fn(), - tokenInfos: jest.fn(), - tokenDiscovery: jest.fn(), - tokenTraits: jest.fn(), - tokenActivity: jest.fn(), - tokenImageInvalidate: jest.fn(), - }, - } + const tokenManager = createTokenManagerMock() afterEach(() => { storage.clear() diff --git a/packages/portfolio/src/index.ts b/packages/portfolio/src/index.ts index 14fe8e508e..c6b07dcc37 100644 --- a/packages/portfolio/src/index.ts +++ b/packages/portfolio/src/index.ts @@ -42,6 +42,7 @@ export * from './translators/reactjs/usePortfolioTokenInfo' export * from './balance-manager' export * from './token-manager' +export * from './token-manager.mock' export * from './types' export * from './constants' diff --git a/packages/portfolio/src/token-manager.mock.ts b/packages/portfolio/src/token-manager.mock.ts new file mode 100644 index 0000000000..cbdad48d6f --- /dev/null +++ b/packages/portfolio/src/token-manager.mock.ts @@ -0,0 +1,24 @@ +import {Portfolio} from '@yoroi/types' +import {BehaviorSubject, Observable} from 'rxjs' + +export const createTokenManagerMock = ( + tokenManagerObservable?: Observable, +): jest.Mocked => ({ + destroy: jest.fn(), + hydrate: jest.fn(), + subscribe: jest.fn(), + unsubscribe: jest.fn(), + observable$: + tokenManagerObservable ?? new BehaviorSubject({} as any).asObservable(), + sync: jest.fn().mockResolvedValue(new Map()), + clear: jest.fn(), + api: { + tokenInfo: jest.fn(), + tokenInfos: jest.fn(), + tokenDiscovery: jest.fn(), + tokenTraits: jest.fn(), + tokenActivity: jest.fn(), + tokenHistory: jest.fn(), + tokenImageInvalidate: jest.fn(), + }, +}) diff --git a/packages/types/src/chain/cardano.ts b/packages/types/src/chain/cardano.ts index 99886fbfa6..85df82723c 100644 --- a/packages/types/src/chain/cardano.ts +++ b/packages/types/src/chain/cardano.ts @@ -17,6 +17,14 @@ export type ChainCardanoProtocolParams = Readonly<{ epoch: number }> +export type ChainCardanoBestBlock = Readonly<{ + epoch: number + slot: number + globalSlot: number + hash: string + height: number +}> + // START legacy export type CardanoUnsignedTx = CardanoTxInfo & { unsignedTx: UnsignedTxType diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0a6b69dfbc..ecc912b53d 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -121,6 +121,7 @@ import { CardanoUnsignedTx, CardanoVoting, ChainCardanoProtocolParams, + ChainCardanoBestBlock, } from './chain/cardano' import {ExchangeBlockchainCode} from './exchange/blockchain' import {ExchangeManagerOptions} from './exchange/build' @@ -176,6 +177,7 @@ import { PortfolioApi, PortfolioApiTokenActivityResponse, PortfolioApiTokenDiscoveryResponse, + PortfolioApiTokenHistoryResponse, PortfolioApiTokenInfosResponse, PortfolioApiTokenTraitsResponse, } from './portfolio/api' @@ -252,6 +254,10 @@ import { ClaimApiClaimTokensRequestPayload, ClaimApiClaimTokensResponse, } from './claim/api' +import { + PortfolioTokenHistory, + PortfolioTokenHistoryPeriod, +} from './portfolio/history' export namespace App { export namespace Errors { @@ -446,9 +452,11 @@ export namespace Api { export type TokenId = ApiTokenId export type ProtocolParams = ChainCardanoProtocolParams + export type BestBlock = ChainCardanoBestBlock export interface Api { - getProtocolParams: () => Promise + getProtocolParams: () => Promise + getBestBlock: () => Promise } } } @@ -524,6 +532,7 @@ export namespace Portfolio { export type TokenDiscoveryResponse = PortfolioApiTokenDiscoveryResponse export type TokenTraitsResponse = PortfolioApiTokenTraitsResponse export type TokenActivityResponse = PortfolioApiTokenActivityResponse + export type TokenHistoryResponse = PortfolioApiTokenHistoryResponse export type Api = PortfolioApi } @@ -571,6 +580,10 @@ export namespace Portfolio { export type ActivityWindow = PortfolioTokenActivityWindow export const ActivityWindow = PortfolioTokenActivityWindow export type ActivityRecord = PortfolioTokenActivityRecord + + export type History = PortfolioTokenHistory + export type HistoryPeriod = PortfolioTokenHistoryPeriod + export const HistoryPeriod = PortfolioTokenHistoryPeriod } } @@ -589,6 +602,7 @@ export namespace Chain { export type Address = CardanoAddress export type TokenId = CardanoTokenId export type ProtocolParams = ChainCardanoProtocolParams + export type BestBlock = ChainCardanoBestBlock } } diff --git a/packages/types/src/network/manager.ts b/packages/types/src/network/manager.ts index 37607d4b33..705cec63c1 100644 --- a/packages/types/src/network/manager.ts +++ b/packages/types/src/network/manager.ts @@ -1,5 +1,8 @@ import {AppObservableStorage} from '../app/observable-storage' -import {ChainCardanoProtocolParams} from '../chain/cardano' +import { + ChainCardanoProtocolParams, + ChainCardanoBestBlock, +} from '../chain/cardano' import {ChainSupportedNetworks} from '../chain/network' import {ExplorersExplorer} from '../explorers/explorer' import {ExplorersManager} from '../explorers/manager' @@ -21,6 +24,7 @@ export type NetworkConfig = { // NOTE: NetworkConfig will be a generic type in the future export type NetworkApi = { protocolParams: () => Promise> + bestBlock: () => Promise } export type NetworkManager = Readonly< { @@ -49,11 +53,13 @@ export type NetworkEpochInfo = { start: Date end: Date era: NetworkEraConfig + eras: ReadonlyArray } export type NetworkEpochProgress = { progress: number currentSlot: number + absoluteSlot: number timeRemaining: { days: number hours: number diff --git a/packages/types/src/portfolio/api.ts b/packages/types/src/portfolio/api.ts index 650841f7aa..66deba286a 100644 --- a/packages/types/src/portfolio/api.ts +++ b/packages/types/src/portfolio/api.ts @@ -5,6 +5,7 @@ import { import {ApiResponse} from '../api/response' import {PortfolioTokenActivity, PortfolioTokenActivityWindow} from './activity' import {PortfolioTokenDiscovery} from './discovery' +import {PortfolioTokenHistory, PortfolioTokenHistoryPeriod} from './history' import {PortfolioTokenInfo} from './info' import {PortfolioTokenId} from './token' import {PortfolioTokenTraits} from './traits' @@ -21,6 +22,8 @@ export type PortfolioApiTokenActivityResponse = { export type PortfolioApiTokenTraitsResponse = PortfolioTokenTraits +export type PortfolioApiTokenHistoryResponse = PortfolioTokenHistory + export type PortfolioApi = Readonly<{ tokenInfo( id: PortfolioTokenId, @@ -38,5 +41,9 @@ export type PortfolioApi = Readonly<{ ids: ReadonlyArray, window: PortfolioTokenActivityWindow, ): Promise>> + tokenHistory( + id: PortfolioTokenId, + period: PortfolioTokenHistoryPeriod, + ): Promise>> tokenImageInvalidate(ids: ReadonlyArray): Promise }> diff --git a/packages/types/src/portfolio/history.ts b/packages/types/src/portfolio/history.ts new file mode 100644 index 0000000000..54b59f317a --- /dev/null +++ b/packages/types/src/portfolio/history.ts @@ -0,0 +1,14 @@ +import {PortfolioTokenActivityRecord} from './activity' + +export type PortfolioTokenHistory = Readonly<{ + prices: ReadonlyArray +}> + +export enum PortfolioTokenHistoryPeriod { + OneDay = '1d', + OneWeek = '1w', + OneMonth = '1m', + SixMonth = '6m', + OneYear = '1y', + All = 'all', +}