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/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/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..4fdb662192 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' @@ -44,6 +52,7 @@ export const Dashboard = () => { const {isLoading: isSyncing, sync} = useSync(wallet) const isOnline = useIsOnline(wallet) const {openModal, closeModal} = useModal() + const {height: windowHeight} = useWindowDimensions() const balances = useBalances(wallet) const primaryAmount = Amounts.getAmount(balances, wallet.portfolioPrimaryTokenInfo.id) @@ -71,7 +80,7 @@ export const Dashboard = () => { openModal( '', resetToTxHistory()} onCancel={() => closeModal()} />, - 450, + windowHeight * 0.8, ) } 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..dae44280f1 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, @@ -1086,10 +1085,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 09a0b1f1cb..e53127cda5 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' @@ -562,30 +562,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/legacy/Dashboard/Dashboard.json b/apps/wallet-mobile/translations/messages/src/legacy/Dashboard/Dashboard.json index 41e949cef2..ffe11d7ece 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": 238, "column": 23, - "index": 7486 + "index": 7594 }, "end": { - "line": 232, + "line": 241, "column": 3, - "index": 7619 + "index": 7727 } } ] \ 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..fa1807fa04 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< { 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', +}