From 2cd850f375280aa185229e5fc133b4e030696822 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 5 Mar 2026 13:28:49 +0100 Subject: [PATCH 1/7] add tokenSymbol to transaction details and format transactions accordingly --- .../model/transactionDetailsSchema.ts | 2 + .../TransactionsTableBody.tsx | 2 + .../TransactionsTableCellValue.tsx | 29 ++-- ...matHmtDecimals.ts => formatTokenAmount.ts} | 10 +- .../src/modules/details/details.service.ts | 152 ++++++++++++++---- .../src/modules/details/details.spec.ts | 60 +++++++ .../modules/details/dto/transaction.dto.ts | 6 + 7 files changed, 211 insertions(+), 50 deletions(-) rename packages/apps/dashboard/client/src/shared/lib/{formatHmtDecimals.ts => formatTokenAmount.ts} (73%) diff --git a/packages/apps/dashboard/client/src/features/searchResults/model/transactionDetailsSchema.ts b/packages/apps/dashboard/client/src/features/searchResults/model/transactionDetailsSchema.ts index 810967e0f2..eb6b703ced 100644 --- a/packages/apps/dashboard/client/src/features/searchResults/model/transactionDetailsSchema.ts +++ b/packages/apps/dashboard/client/src/features/searchResults/model/transactionDetailsSchema.ts @@ -8,6 +8,7 @@ const internalTransactionSchema = z.object({ receiver: z.string().nullable(), escrow: z.string().nullable(), token: z.string().nullable(), + tokenSymbol: z.string().nullable().optional(), }); const transactionDetailsSchema = z.object({ @@ -18,6 +19,7 @@ const transactionDetailsSchema = z.object({ receiver: z.string().nullable(), block: z.number(), value: z.string(), + tokenSymbol: z.string().nullable().optional(), internalTransactions: z.array(internalTransactionSchema), }); diff --git a/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableBody.tsx b/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableBody.tsx index f39de49cd3..5ebeb02c22 100644 --- a/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableBody.tsx +++ b/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableBody.tsx @@ -114,6 +114,7 @@ const TransactionsTableBody: FC = ({ data, isLoading, error }) => { @@ -150,6 +151,7 @@ const TransactionsTableBody: FC = ({ data, isLoading, error }) => { diff --git a/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableCellValue.tsx b/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableCellValue.tsx index 4c031b002c..9dcbe63123 100644 --- a/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableCellValue.tsx +++ b/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableCellValue.tsx @@ -1,8 +1,7 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import Typography from '@mui/material/Typography'; -import useHmtPrice from '@/shared/api/useHmtPrice'; -import formatHmtDecimals from '@/shared/lib/formatHmtDecimals'; +import formatTokenAmount from '@/shared/lib/formatTokenAmount'; import CustomTooltip from '@/shared/ui/CustomTooltip'; const InfoTooltip = ({ title }: { title: string }) => ( @@ -19,26 +18,24 @@ const InfoTooltip = ({ title }: { title: string }) => ( const TransactionsTableCellValue = ({ value, method, + tokenSymbol, }: { value: string; method: string; + tokenSymbol?: string | null; }) => { - const { isError, isPending } = useHmtPrice(); - - if (isError) { - return N/A; - } - - if (isPending) { - return ...; - } - return ( - {formatHmtDecimals(value)} - - HMT - + {Number(value) === 0 && !tokenSymbol ? ( + '-' + ) : ( + <> + {formatTokenAmount(value)} + + {tokenSymbol} + + + )} {method === 'approve' && ( )} diff --git a/packages/apps/dashboard/client/src/shared/lib/formatHmtDecimals.ts b/packages/apps/dashboard/client/src/shared/lib/formatTokenAmount.ts similarity index 73% rename from packages/apps/dashboard/client/src/shared/lib/formatHmtDecimals.ts rename to packages/apps/dashboard/client/src/shared/lib/formatTokenAmount.ts index c0610ad83b..2698afa7a3 100644 --- a/packages/apps/dashboard/client/src/shared/lib/formatHmtDecimals.ts +++ b/packages/apps/dashboard/client/src/shared/lib/formatTokenAmount.ts @@ -1,7 +1,9 @@ -import { formatEther } from 'ethers'; +const formatTokenAmount = (value: string) => { + const formattedValue = Number(value); -const formatHmtDecimals = (value: string) => { - const formattedValue = Number(formatEther(value)); + if (Number.isNaN(formattedValue)) { + return value; + } if (Number.isInteger(formattedValue)) { return formattedValue.toString(); @@ -23,4 +25,4 @@ const formatHmtDecimals = (value: string) => { : formattedValue.toString(); }; -export default formatHmtDecimals; +export default formatTokenAmount; diff --git a/packages/apps/dashboard/server/src/modules/details/details.service.ts b/packages/apps/dashboard/server/src/modules/details/details.service.ts index 63905022ea..94a034ef26 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.service.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.service.ts @@ -3,6 +3,7 @@ import { EscrowUtils, IEscrowsFilter, IOperatorsFilter, + ITransaction, KVStoreUtils, NETWORKS, OperatorUtils, @@ -19,7 +20,6 @@ import { plainToInstance } from 'class-transformer'; import { ethers } from 'ethers'; import { firstValueFrom } from 'rxjs'; -import { GetOperatorsPaginationOptions } from '../../common/types'; import { EnvironmentConfigService } from '../../common/config/env-config.service'; import { NetworkConfigService } from '../../common/config/network-config.service'; import { @@ -28,9 +28,10 @@ import { REPUTATION_PLACEHOLDER, type ChainId, } from '../../common/constants'; -import * as httpUtils from '../../common/utils/http'; import { OperatorsOrderBy } from '../../common/enums/operator'; import { ReputationLevel } from '../../common/enums/reputation'; +import { GetOperatorsPaginationOptions } from '../../common/types'; +import * as httpUtils from '../../common/utils/http'; import logger from '../../logger'; import { KVStoreDataDto } from './dto/details-response.dto'; import { EscrowDto, EscrowPaginationDto } from './dto/escrow.dto'; @@ -41,6 +42,13 @@ import { WalletDto } from './dto/wallet.dto'; @Injectable() export class DetailsService { private readonly logger = logger.child({ context: DetailsService.name }); + private readonly tokenData = new Map< + string, + { + decimals: number; + symbol: string; + } + >(); constructor( @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, @@ -53,11 +61,7 @@ export class DetailsService { chainId: ChainId, address: string, ): Promise { - const network = this.networkConfig.networks.find( - (network) => network.chainId === chainId, - ); - if (!network) throw new BadRequestException('Invalid chainId provided'); - const provider = new ethers.JsonRpcProvider(network.rpcUrl); + const provider = this.getProvider(chainId); const escrowData = await EscrowUtils.getEscrow(chainId, address); if (escrowData) { @@ -66,9 +70,9 @@ export class DetailsService { }); const { decimals, symbol } = await this.getTokenData( + provider, chainId, escrowData.token, - provider, ); escrowDto.balance = ethers.formatUnits(escrowData.balance, decimals); @@ -139,11 +143,7 @@ export class DetailsService { chainId: ChainId, hmtAddress: string, ): Promise { - const network = this.networkConfig.networks.find( - (network) => network.chainId === chainId, - ); - if (!network) throw new BadRequestException('Invalid chainId provided'); - const provider = new ethers.JsonRpcProvider(network.rpcUrl); + const provider = this.getProvider(chainId); const hmtContract = HMToken__factory.connect( NETWORKS[chainId].hmtAddress, provider, @@ -157,6 +157,7 @@ export class DetailsService { first: number, skip: number, ): Promise { + const provider = this.getProvider(chainId); const transactions = await TransactionUtils.getTransactions({ chainId, fromAddress: address, @@ -164,15 +165,23 @@ export class DetailsService { first, skip, }); - const result = transactions.map((transaction) => { - const transactionPaginationObject: TransactionPaginationDto = - plainToInstance( - TransactionPaginationDto, - { ...transaction, currentAddress: address }, - { excludeExtraneousValues: true }, + + const result = await Promise.all( + transactions.map(async (transaction) => { + const formattedTransaction = await this.formatTransactionValues( + chainId, + provider, + transaction, ); - return transactionPaginationObject; - }); + const transactionPaginationObject: TransactionPaginationDto = + plainToInstance( + TransactionPaginationDto, + { ...formattedTransaction, currentAddress: address }, + { excludeExtraneousValues: true }, + ); + return transactionPaginationObject; + }), + ); return result; } @@ -439,24 +448,107 @@ export class DetailsService { } private async getTokenData( - chainId: ChainId, - tokenAddress: string, provider: ethers.JsonRpcProvider, - ): Promise<{ decimals: number; symbol: string }> { - const tokenCacheKey = `token:${chainId}:${tokenAddress.toLowerCase()}`; - let data = await this.cacheManager.get<{ + chainId: ChainId, + tokenAddress?: string, + ): Promise<{ decimals: number; symbol: string | null }> { + if (!tokenAddress || !ethers.isAddress(tokenAddress)) { + return { decimals: 18, symbol: null }; + } + + const normalizedTokenAddress = tokenAddress.toLowerCase(); + const tokenCacheKey = `token:${chainId}:${normalizedTokenAddress}`; + + const tokenDataInMemory = this.tokenData.get(tokenCacheKey); + if (tokenDataInMemory) { + return { + decimals: tokenDataInMemory.decimals, + symbol: tokenDataInMemory.symbol, + }; + } + + const cachedData = await this.cacheManager.get<{ decimals: number; symbol: string; }>(tokenCacheKey); - if (!data) { + if (cachedData) { + this.tokenData.set(tokenCacheKey, { + decimals: cachedData.decimals, + symbol: cachedData.symbol, + }); + return cachedData; + } + + try { const erc20Contract = HMToken__factory.connect(tokenAddress, provider); const [decimals, symbol] = await Promise.all([ erc20Contract.decimals(), erc20Contract.symbol(), ]); - data = { decimals: Number(decimals), symbol }; - await this.cacheManager.set(tokenCacheKey, data); + const resolvedTokenData = { decimals: Number(decimals), symbol }; + + this.tokenData.set(tokenCacheKey, { + decimals: resolvedTokenData.decimals, + symbol: resolvedTokenData.symbol, + }); + await this.cacheManager.set(tokenCacheKey, resolvedTokenData); + + return resolvedTokenData; + } catch (error) { + this.logger.warn('Failed to fetch token metadata.', { + chainId, + tokenAddress, + error: error instanceof Error ? error.message : String(error), + }); + return { decimals: 18, symbol: null }; } - return data; + } + + private getProvider(chainId: ChainId): ethers.JsonRpcProvider { + const network = this.networkConfig.networks.find( + (network) => network.chainId === chainId, + ); + if (!network?.rpcUrl) { + throw new BadRequestException('Invalid chainId provided'); + } + + return new ethers.JsonRpcProvider(network.rpcUrl); + } + + private async formatTransactionValues( + chainId: ChainId, + provider: ethers.JsonRpcProvider, + transaction: ITransaction, + ): Promise> { + const tokenData = await this.getTokenData( + provider, + chainId, + transaction.token, + ); + const internalTransactions = await Promise.all( + transaction.internalTransactions.map(async (internalTransaction) => { + const tokenData = await this.getTokenData( + provider, + chainId, + internalTransaction.token, + ); + + return { + ...internalTransaction, + value: ethers.formatUnits( + internalTransaction.value, + tokenData.decimals, + ), + tokenSymbol: tokenData.symbol, + }; + }), + ); + + return { + ...transaction, + value: ethers.formatUnits(transaction.value, tokenData.decimals), + tokenSymbol: tokenData.symbol, + internalTransactions, + }; } } diff --git a/packages/apps/dashboard/server/src/modules/details/details.spec.ts b/packages/apps/dashboard/server/src/modules/details/details.spec.ts index 6cbc1dae10..35c48909cd 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.spec.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.spec.ts @@ -3,6 +3,7 @@ import { KVStoreUtils, OperatorUtils, OrderDirection, + TransactionUtils, } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; @@ -55,6 +56,12 @@ describe('DetailsService', () => { getAvailableNetworks: jest .fn() .mockResolvedValue([DevelopmentChainId.SEPOLIA]), + networks: [ + { + chainId: DevelopmentChainId.SEPOLIA, + rpcUrl: 'http://localhost:8545', + }, + ], }, }, { @@ -193,4 +200,57 @@ describe('DetailsService', () => { expect.objectContaining({ key: 'key2', value: 'value2' }), ]); }); + + it('should format transactions using token decimals and symbol', async () => { + jest.spyOn(TransactionUtils, 'getTransactions').mockResolvedValue([ + { + block: 123n, + txHash: '0x', + from: '0x1230000000000000000000000000000000000000', + to: '0x9990000000000000000000000000000000000000', + timestamp: Date.now(), + value: 1234567n, + method: 'bulkTransfer', + receiver: null, + escrow: null, + token: '0x1111111111111111111111111111111111111111', + internalTransactions: [ + { + from: '0x1230000000000000000000000000000000000000', + to: '0x4560000000000000000000000000000000000000', + value: 345678n, + method: 'transfer', + receiver: null, + escrow: null, + token: '0x1111111111111111111111111111111111111111', + }, + ], + }, + ]); + + jest.spyOn(service as any, 'getTokenData').mockResolvedValue({ + decimals: 6, + symbol: 'USDC', + }); + + const result = await service.getTransactions( + DevelopmentChainId.SEPOLIA, + '0x9990000000000000000000000000000000000000', + 10, + 0, + ); + + expect(result).toEqual([ + expect.objectContaining({ + value: '1.234567', + tokenSymbol: 'USDC', + internalTransactions: [ + expect.objectContaining({ + value: '0.345678', + tokenSymbol: 'USDC', + }), + ], + }), + ]); + }); }); diff --git a/packages/apps/dashboard/server/src/modules/details/dto/transaction.dto.ts b/packages/apps/dashboard/server/src/modules/details/dto/transaction.dto.ts index d9e8688177..a8635cf6f1 100644 --- a/packages/apps/dashboard/server/src/modules/details/dto/transaction.dto.ts +++ b/packages/apps/dashboard/server/src/modules/details/dto/transaction.dto.ts @@ -24,6 +24,9 @@ export class InternalTransaction { @ApiProperty() @Expose() token: string | null; + @ApiProperty({ required: false, nullable: true, example: 'USDC' }) + @Expose() + tokenSymbol: string | null; } export class TransactionPaginationDto { @@ -65,6 +68,9 @@ export class TransactionPaginationDto { }) @Expose() value: string; + @ApiProperty({ required: false, nullable: true, example: 'HMT' }) + @Expose() + tokenSymbol: string | null; @ApiProperty({ type: [Object], From d1eb88feeb41dc212f854969d01246f2288500a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Thu, 5 Mar 2026 13:44:20 +0100 Subject: [PATCH 2/7] enhance transaction formatting with mock data for testing --- .../src/modules/details/details.spec.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/apps/dashboard/server/src/modules/details/details.spec.ts b/packages/apps/dashboard/server/src/modules/details/details.spec.ts index 35c48909cd..f22fd031f3 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.spec.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.spec.ts @@ -202,31 +202,41 @@ describe('DetailsService', () => { }); it('should format transactions using token decimals and symbol', async () => { - jest.spyOn(TransactionUtils, 'getTransactions').mockResolvedValue([ + const walletAddress = '0xA'; + const senderAddress = '0xB'; + const receiverAddress = '0xC'; + const tokenAddress = '0xD'; + const txHash = '0x1'; + + const mockTransactions = [ { block: 123n, - txHash: '0x', - from: '0x1230000000000000000000000000000000000000', - to: '0x9990000000000000000000000000000000000000', + txHash, + from: senderAddress, + to: walletAddress, timestamp: Date.now(), value: 1234567n, method: 'bulkTransfer', receiver: null, escrow: null, - token: '0x1111111111111111111111111111111111111111', + token: tokenAddress, internalTransactions: [ { - from: '0x1230000000000000000000000000000000000000', - to: '0x4560000000000000000000000000000000000000', + from: senderAddress, + to: receiverAddress, value: 345678n, method: 'transfer', receiver: null, escrow: null, - token: '0x1111111111111111111111111111111111111111', + token: tokenAddress, }, ], }, - ]); + ]; + + jest + .spyOn(TransactionUtils, 'getTransactions') + .mockResolvedValue(mockTransactions); jest.spyOn(service as any, 'getTokenData').mockResolvedValue({ decimals: 6, @@ -235,7 +245,7 @@ describe('DetailsService', () => { const result = await service.getTransactions( DevelopmentChainId.SEPOLIA, - '0x9990000000000000000000000000000000000000', + walletAddress, 10, 0, ); From 228660f7918b0096f149b9fa10addcde3af77645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Fri, 6 Mar 2026 18:03:36 +0100 Subject: [PATCH 3/7] - Replace formatTokenAmount with formatTokenDecimals and update related components - Add in-flight token logic to avoid duplicated calls on cold starts --- .../TransactionsTableCellValue.tsx | 4 +- ...tTokenAmount.ts => formatTokenDecimals.ts} | 4 +- .../server/src/common/constants/chains.ts | 2 + .../src/modules/details/details.service.ts | 98 ++++++++++++------- .../src/modules/details/details.spec.ts | 59 ++++++++++- 5 files changed, 120 insertions(+), 47 deletions(-) rename packages/apps/dashboard/client/src/shared/lib/{formatTokenAmount.ts => formatTokenDecimals.ts} (87%) diff --git a/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableCellValue.tsx b/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableCellValue.tsx index 9dcbe63123..8622ee7572 100644 --- a/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableCellValue.tsx +++ b/packages/apps/dashboard/client/src/features/searchResults/ui/WalletTransactions/TransactionsTableCellValue.tsx @@ -1,7 +1,7 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import Typography from '@mui/material/Typography'; -import formatTokenAmount from '@/shared/lib/formatTokenAmount'; +import formatTokenDecimals from '@/shared/lib/formatTokenDecimals'; import CustomTooltip from '@/shared/ui/CustomTooltip'; const InfoTooltip = ({ title }: { title: string }) => ( @@ -30,7 +30,7 @@ const TransactionsTableCellValue = ({ '-' ) : ( <> - {formatTokenAmount(value)} + {formatTokenDecimals(value)} {tokenSymbol} diff --git a/packages/apps/dashboard/client/src/shared/lib/formatTokenAmount.ts b/packages/apps/dashboard/client/src/shared/lib/formatTokenDecimals.ts similarity index 87% rename from packages/apps/dashboard/client/src/shared/lib/formatTokenAmount.ts rename to packages/apps/dashboard/client/src/shared/lib/formatTokenDecimals.ts index 2698afa7a3..b18d169155 100644 --- a/packages/apps/dashboard/client/src/shared/lib/formatTokenAmount.ts +++ b/packages/apps/dashboard/client/src/shared/lib/formatTokenDecimals.ts @@ -1,4 +1,4 @@ -const formatTokenAmount = (value: string) => { +const formatTokenDecimals = (value: string) => { const formattedValue = Number(value); if (Number.isNaN(formattedValue)) { @@ -25,4 +25,4 @@ const formatTokenAmount = (value: string) => { : formattedValue.toString(); }; -export default formatTokenAmount; +export default formatTokenDecimals; diff --git a/packages/apps/dashboard/server/src/common/constants/chains.ts b/packages/apps/dashboard/server/src/common/constants/chains.ts index 74043bf6bc..5def586326 100644 --- a/packages/apps/dashboard/server/src/common/constants/chains.ts +++ b/packages/apps/dashboard/server/src/common/constants/chains.ts @@ -26,3 +26,5 @@ export const ChainIds = Object.values( ).filter((value): value is ChainId => typeof value === 'number'); export type ChainId = ProductionChainId | DevelopmentChainId; + +export const TOKEN_CACHE_PREFIX = 'token'; diff --git a/packages/apps/dashboard/server/src/modules/details/details.service.ts b/packages/apps/dashboard/server/src/modules/details/details.service.ts index 94a034ef26..0f756968d3 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.service.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.service.ts @@ -26,6 +26,7 @@ import { MAX_LEADERS_COUNT, MIN_STAKED_AMOUNT, REPUTATION_PLACEHOLDER, + TOKEN_CACHE_PREFIX, type ChainId, } from '../../common/constants'; import { OperatorsOrderBy } from '../../common/enums/operator'; @@ -49,6 +50,10 @@ export class DetailsService { symbol: string; } >(); + private readonly inFlightTokenData = new Map< + string, + Promise<{ decimals: number; symbol: string | null }> + >(); constructor( @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, @@ -69,7 +74,7 @@ export class DetailsService { excludeExtraneousValues: true, }); - const { decimals, symbol } = await this.getTokenData( + const { decimals, symbol } = await this.getTokenDataOrDefault( provider, chainId, escrowData.token, @@ -447,17 +452,17 @@ export class DetailsService { return data; } - private async getTokenData( + private async getTokenDataOrDefault( provider: ethers.JsonRpcProvider, chainId: ChainId, - tokenAddress?: string, + tokenAddress: string | null, ): Promise<{ decimals: number; symbol: string | null }> { if (!tokenAddress || !ethers.isAddress(tokenAddress)) { return { decimals: 18, symbol: null }; } const normalizedTokenAddress = tokenAddress.toLowerCase(); - const tokenCacheKey = `token:${chainId}:${normalizedTokenAddress}`; + const tokenCacheKey = `${TOKEN_CACHE_PREFIX}:${chainId}:${normalizedTokenAddress}`; const tokenDataInMemory = this.tokenData.get(tokenCacheKey); if (tokenDataInMemory) { @@ -467,41 +472,58 @@ export class DetailsService { }; } - const cachedData = await this.cacheManager.get<{ - decimals: number; - symbol: string; - }>(tokenCacheKey); - if (cachedData) { - this.tokenData.set(tokenCacheKey, { - decimals: cachedData.decimals, - symbol: cachedData.symbol, - }); - return cachedData; + const inFlightTokenData = this.inFlightTokenData.get(tokenCacheKey); + if (inFlightTokenData) { + return inFlightTokenData; } - try { - const erc20Contract = HMToken__factory.connect(tokenAddress, provider); - const [decimals, symbol] = await Promise.all([ - erc20Contract.decimals(), - erc20Contract.symbol(), - ]); - const resolvedTokenData = { decimals: Number(decimals), symbol }; - - this.tokenData.set(tokenCacheKey, { - decimals: resolvedTokenData.decimals, - symbol: resolvedTokenData.symbol, - }); - await this.cacheManager.set(tokenCacheKey, resolvedTokenData); + const tokenDataPromise = (async () => { + try { + const cachedData = await this.cacheManager.get<{ + decimals: number; + symbol: string; + }>(tokenCacheKey); + if (cachedData) { + this.tokenData.set(tokenCacheKey, { + decimals: cachedData.decimals, + symbol: cachedData.symbol, + }); + return cachedData; + } + + try { + const erc20Contract = HMToken__factory.connect( + tokenAddress, + provider, + ); + const [decimals, symbol] = await Promise.all([ + erc20Contract.decimals(), + erc20Contract.symbol(), + ]); + const resolvedTokenData = { decimals: Number(decimals), symbol }; + + this.tokenData.set(tokenCacheKey, { + decimals: resolvedTokenData.decimals, + symbol: resolvedTokenData.symbol, + }); + await this.cacheManager.set(tokenCacheKey, resolvedTokenData); + + return resolvedTokenData; + } catch (error) { + this.logger.warn('Failed to fetch token metadata.', { + chainId, + tokenAddress, + error: error instanceof Error ? error.message : String(error), + }); + return { decimals: 18, symbol: null }; + } + } finally { + this.inFlightTokenData.delete(tokenCacheKey); + } + })(); - return resolvedTokenData; - } catch (error) { - this.logger.warn('Failed to fetch token metadata.', { - chainId, - tokenAddress, - error: error instanceof Error ? error.message : String(error), - }); - return { decimals: 18, symbol: null }; - } + this.inFlightTokenData.set(tokenCacheKey, tokenDataPromise); + return tokenDataPromise; } private getProvider(chainId: ChainId): ethers.JsonRpcProvider { @@ -520,14 +542,14 @@ export class DetailsService { provider: ethers.JsonRpcProvider, transaction: ITransaction, ): Promise> { - const tokenData = await this.getTokenData( + const tokenData = await this.getTokenDataOrDefault( provider, chainId, transaction.token, ); const internalTransactions = await Promise.all( transaction.internalTransactions.map(async (internalTransaction) => { - const tokenData = await this.getTokenData( + const tokenData = await this.getTokenDataOrDefault( provider, chainId, internalTransaction.token, diff --git a/packages/apps/dashboard/server/src/modules/details/details.spec.ts b/packages/apps/dashboard/server/src/modules/details/details.spec.ts index f22fd031f3..9fad0cc670 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.spec.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.spec.ts @@ -1,3 +1,4 @@ +import { HMToken__factory } from '@human-protocol/core/typechain-types'; import { IOperator, KVStoreUtils, @@ -33,8 +34,14 @@ jest.mock('../../common/constants/operator', () => ({ describe('DetailsService', () => { let service: DetailsService; let httpService: HttpService; + let cacheManager: { get: jest.Mock; set: jest.Mock }; beforeEach(async () => { + cacheManager = { + get: jest.fn(), + set: jest.fn(), + }; + const module: TestingModule = await Test.createTestingModule({ providers: [ DetailsService, @@ -66,10 +73,7 @@ describe('DetailsService', () => { }, { provide: CACHE_MANAGER, - useValue: { - get: jest.fn(), - set: jest.fn(), - }, + useValue: cacheManager, }, ], }).compile(); @@ -238,7 +242,7 @@ describe('DetailsService', () => { .spyOn(TransactionUtils, 'getTransactions') .mockResolvedValue(mockTransactions); - jest.spyOn(service as any, 'getTokenData').mockResolvedValue({ + jest.spyOn(service as any, 'getTokenDataOrDefault').mockResolvedValue({ decimals: 6, symbol: 'USDC', }); @@ -263,4 +267,49 @@ describe('DetailsService', () => { }), ]); }); + + it('should deduplicate concurrent in-flight token metadata fetches', async () => { + const tokenAddress = '0x000000000000000000000000000000000000000d'; + const provider = (service as any).getProvider(DevelopmentChainId.SEPOLIA); + const tokenCacheKey = `token:${DevelopmentChainId.SEPOLIA}:${tokenAddress.toLowerCase()}`; + + cacheManager.get.mockResolvedValue(null); + cacheManager.set.mockResolvedValue(undefined); + + const decimals = jest.fn().mockImplementation( + async () => + await new Promise((resolve) => { + setTimeout(() => resolve(6n), 5); + }), + ); + const symbol = jest.fn().mockResolvedValue('USDC'); + const connectSpy = jest + .spyOn(HMToken__factory, 'connect') + .mockReturnValue({ decimals, symbol } as any); + + const first = (service as any).getTokenDataOrDefault( + provider, + DevelopmentChainId.SEPOLIA, + tokenAddress, + ); + const second = (service as any).getTokenDataOrDefault( + provider, + DevelopmentChainId.SEPOLIA, + tokenAddress, + ); + + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(firstResult).toEqual({ decimals: 6, symbol: 'USDC' }); + expect(secondResult).toEqual({ decimals: 6, symbol: 'USDC' }); + expect(connectSpy).toHaveBeenCalledTimes(1); + expect(decimals).toHaveBeenCalledTimes(1); + expect(symbol).toHaveBeenCalledTimes(1); + expect(cacheManager.get).toHaveBeenCalledTimes(1); + expect(cacheManager.set).toHaveBeenCalledWith(tokenCacheKey, { + decimals: 6, + symbol: 'USDC', + }); + expect((service as any).inFlightTokenData.size).toBe(0); + }); }); From 909b56760a78ddd620d0e17f68b3e2b6d6060413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 9 Mar 2026 15:13:41 +0100 Subject: [PATCH 4/7] refactor getTokenData to merge tokenData and inFlightTokenData and improve token metadata fetching logic --- .../src/modules/details/details.service.ts | 102 +++++++----------- .../src/modules/details/details.spec.ts | 17 +-- 2 files changed, 50 insertions(+), 69 deletions(-) diff --git a/packages/apps/dashboard/server/src/modules/details/details.service.ts b/packages/apps/dashboard/server/src/modules/details/details.service.ts index 0f756968d3..cd6c840124 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.service.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.service.ts @@ -44,13 +44,6 @@ import { WalletDto } from './dto/wallet.dto'; export class DetailsService { private readonly logger = logger.child({ context: DetailsService.name }); private readonly tokenData = new Map< - string, - { - decimals: number; - symbol: string; - } - >(); - private readonly inFlightTokenData = new Map< string, Promise<{ decimals: number; symbol: string | null }> >(); @@ -74,7 +67,7 @@ export class DetailsService { excludeExtraneousValues: true, }); - const { decimals, symbol } = await this.getTokenDataOrDefault( + const { decimals, symbol } = await this.getTokenData( provider, chainId, escrowData.token, @@ -452,13 +445,13 @@ export class DetailsService { return data; } - private async getTokenDataOrDefault( + private async getTokenData( provider: ethers.JsonRpcProvider, chainId: ChainId, - tokenAddress: string | null, + tokenAddress: string, ): Promise<{ decimals: number; symbol: string | null }> { - if (!tokenAddress || !ethers.isAddress(tokenAddress)) { - return { decimals: 18, symbol: null }; + if (!ethers.isAddress(tokenAddress)) { + throw new Error(`Invalid token address: ${tokenAddress}`); } const normalizedTokenAddress = tokenAddress.toLowerCase(); @@ -466,15 +459,7 @@ export class DetailsService { const tokenDataInMemory = this.tokenData.get(tokenCacheKey); if (tokenDataInMemory) { - return { - decimals: tokenDataInMemory.decimals, - symbol: tokenDataInMemory.symbol, - }; - } - - const inFlightTokenData = this.inFlightTokenData.get(tokenCacheKey); - if (inFlightTokenData) { - return inFlightTokenData; + return tokenDataInMemory; } const tokenDataPromise = (async () => { @@ -484,45 +469,25 @@ export class DetailsService { symbol: string; }>(tokenCacheKey); if (cachedData) { - this.tokenData.set(tokenCacheKey, { - decimals: cachedData.decimals, - symbol: cachedData.symbol, - }); return cachedData; } - try { - const erc20Contract = HMToken__factory.connect( - tokenAddress, - provider, - ); - const [decimals, symbol] = await Promise.all([ - erc20Contract.decimals(), - erc20Contract.symbol(), - ]); - const resolvedTokenData = { decimals: Number(decimals), symbol }; - - this.tokenData.set(tokenCacheKey, { - decimals: resolvedTokenData.decimals, - symbol: resolvedTokenData.symbol, - }); - await this.cacheManager.set(tokenCacheKey, resolvedTokenData); - - return resolvedTokenData; - } catch (error) { - this.logger.warn('Failed to fetch token metadata.', { - chainId, - tokenAddress, - error: error instanceof Error ? error.message : String(error), - }); - return { decimals: 18, symbol: null }; - } - } finally { - this.inFlightTokenData.delete(tokenCacheKey); + const erc20Contract = HMToken__factory.connect(tokenAddress, provider); + const [decimals, symbol] = await Promise.all([ + erc20Contract.decimals(), + erc20Contract.symbol(), + ]); + const resolvedTokenData = { decimals: Number(decimals), symbol }; + await this.cacheManager.set(tokenCacheKey, resolvedTokenData); + + return resolvedTokenData; + } catch (error) { + this.tokenData.delete(tokenCacheKey); + throw error; } })(); - this.inFlightTokenData.set(tokenCacheKey, tokenDataPromise); + this.tokenData.set(tokenCacheKey, tokenDataPromise); return tokenDataPromise; } @@ -542,19 +507,29 @@ export class DetailsService { provider: ethers.JsonRpcProvider, transaction: ITransaction, ): Promise> { - const tokenData = await this.getTokenDataOrDefault( - provider, - chainId, - transaction.token, - ); + const getFormattedTokenData = async (tokenAddress: string | null) => { + if (!tokenAddress) { + return { decimals: 18, symbol: null }; + } + + try { + return await this.getTokenData(provider, chainId, tokenAddress); + } catch (error) { + this.logger.warn('Failed to resolve token metadata.', { + chainId, + tokenAddress, + txHash: transaction.txHash, + error: error instanceof Error ? error.message : String(error), + }); + return { decimals: 18, symbol: null }; + } + }; + const internalTransactions = await Promise.all( transaction.internalTransactions.map(async (internalTransaction) => { - const tokenData = await this.getTokenDataOrDefault( - provider, - chainId, + const tokenData = await getFormattedTokenData( internalTransaction.token, ); - return { ...internalTransaction, value: ethers.formatUnits( @@ -565,6 +540,7 @@ export class DetailsService { }; }), ); + const tokenData = await getFormattedTokenData(transaction.token); return { ...transaction, diff --git a/packages/apps/dashboard/server/src/modules/details/details.spec.ts b/packages/apps/dashboard/server/src/modules/details/details.spec.ts index 9fad0cc670..693e893870 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.spec.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.spec.ts @@ -242,7 +242,7 @@ describe('DetailsService', () => { .spyOn(TransactionUtils, 'getTransactions') .mockResolvedValue(mockTransactions); - jest.spyOn(service as any, 'getTokenDataOrDefault').mockResolvedValue({ + jest.spyOn(service as any, 'getTokenData').mockResolvedValue({ decimals: 6, symbol: 'USDC', }); @@ -268,13 +268,12 @@ describe('DetailsService', () => { ]); }); - it('should deduplicate concurrent in-flight token metadata fetches', async () => { + it('should deduplicate concurrent token metadata fetches and reuse resolved promises', async () => { const tokenAddress = '0x000000000000000000000000000000000000000d'; const provider = (service as any).getProvider(DevelopmentChainId.SEPOLIA); const tokenCacheKey = `token:${DevelopmentChainId.SEPOLIA}:${tokenAddress.toLowerCase()}`; cacheManager.get.mockResolvedValue(null); - cacheManager.set.mockResolvedValue(undefined); const decimals = jest.fn().mockImplementation( async () => @@ -287,21 +286,27 @@ describe('DetailsService', () => { .spyOn(HMToken__factory, 'connect') .mockReturnValue({ decimals, symbol } as any); - const first = (service as any).getTokenDataOrDefault( + const first = (service as any).getTokenData( provider, DevelopmentChainId.SEPOLIA, tokenAddress, ); - const second = (service as any).getTokenDataOrDefault( + const second = (service as any).getTokenData( provider, DevelopmentChainId.SEPOLIA, tokenAddress, ); const [firstResult, secondResult] = await Promise.all([first, second]); + const thirdResult = await (service as any).getTokenData( + provider, + DevelopmentChainId.SEPOLIA, + tokenAddress, + ); expect(firstResult).toEqual({ decimals: 6, symbol: 'USDC' }); expect(secondResult).toEqual({ decimals: 6, symbol: 'USDC' }); + expect(thirdResult).toEqual({ decimals: 6, symbol: 'USDC' }); expect(connectSpy).toHaveBeenCalledTimes(1); expect(decimals).toHaveBeenCalledTimes(1); expect(symbol).toHaveBeenCalledTimes(1); @@ -310,6 +315,6 @@ describe('DetailsService', () => { decimals: 6, symbol: 'USDC', }); - expect((service as any).inFlightTokenData.size).toBe(0); + expect((service as any).tokenData.size).toBe(1); }); }); From b261e6387a38ed6a741c3f9c3dcb0347b541968e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Mon, 9 Mar 2026 16:05:04 +0100 Subject: [PATCH 5/7] refactor token metadata fetching and improve error handling --- .../src/modules/details/details.service.ts | 71 +++++++-------- .../src/modules/details/details.spec.ts | 90 +++++++++++++++++++ 2 files changed, 126 insertions(+), 35 deletions(-) diff --git a/packages/apps/dashboard/server/src/modules/details/details.service.ts b/packages/apps/dashboard/server/src/modules/details/details.service.ts index cd6c840124..6f05e5f45c 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.service.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.service.ts @@ -457,38 +457,39 @@ export class DetailsService { const normalizedTokenAddress = tokenAddress.toLowerCase(); const tokenCacheKey = `${TOKEN_CACHE_PREFIX}:${chainId}:${normalizedTokenAddress}`; - const tokenDataInMemory = this.tokenData.get(tokenCacheKey); - if (tokenDataInMemory) { - return tokenDataInMemory; - } - - const tokenDataPromise = (async () => { - try { - const cachedData = await this.cacheManager.get<{ - decimals: number; - symbol: string; - }>(tokenCacheKey); - if (cachedData) { - return cachedData; + if (!this.tokenData.has(tokenCacheKey)) { + const tokenDataPromise = (async () => { + try { + const cachedData = await this.cacheManager.get<{ + decimals: number; + symbol: string; + }>(tokenCacheKey); + if (cachedData) { + return cachedData; + } + + const erc20Contract = HMToken__factory.connect( + tokenAddress, + provider, + ); + const [decimals, symbol] = await Promise.all([ + erc20Contract.decimals(), + erc20Contract.symbol(), + ]); + const resolvedTokenData = { decimals: Number(decimals), symbol }; + await this.cacheManager.set(tokenCacheKey, resolvedTokenData); + + return resolvedTokenData; + } catch (error) { + this.tokenData.delete(tokenCacheKey); + throw error; } + })(); - const erc20Contract = HMToken__factory.connect(tokenAddress, provider); - const [decimals, symbol] = await Promise.all([ - erc20Contract.decimals(), - erc20Contract.symbol(), - ]); - const resolvedTokenData = { decimals: Number(decimals), symbol }; - await this.cacheManager.set(tokenCacheKey, resolvedTokenData); - - return resolvedTokenData; - } catch (error) { - this.tokenData.delete(tokenCacheKey); - throw error; - } - })(); + this.tokenData.set(tokenCacheKey, tokenDataPromise); + } - this.tokenData.set(tokenCacheKey, tokenDataPromise); - return tokenDataPromise; + return this.tokenData.get(tokenCacheKey)!; } private getProvider(chainId: ChainId): ethers.JsonRpcProvider { @@ -509,7 +510,7 @@ export class DetailsService { ): Promise> { const getFormattedTokenData = async (tokenAddress: string | null) => { if (!tokenAddress) { - return { decimals: 18, symbol: null }; + return null; } try { @@ -521,7 +522,7 @@ export class DetailsService { txHash: transaction.txHash, error: error instanceof Error ? error.message : String(error), }); - return { decimals: 18, symbol: null }; + throw error; } }; @@ -534,9 +535,9 @@ export class DetailsService { ...internalTransaction, value: ethers.formatUnits( internalTransaction.value, - tokenData.decimals, + tokenData?.decimals ?? 18, ), - tokenSymbol: tokenData.symbol, + ...(tokenData ? { tokenSymbol: tokenData.symbol } : {}), }; }), ); @@ -544,8 +545,8 @@ export class DetailsService { return { ...transaction, - value: ethers.formatUnits(transaction.value, tokenData.decimals), - tokenSymbol: tokenData.symbol, + value: ethers.formatUnits(transaction.value, tokenData?.decimals ?? 18), + ...(tokenData ? { tokenSymbol: tokenData.symbol } : {}), internalTransactions, }; } diff --git a/packages/apps/dashboard/server/src/modules/details/details.spec.ts b/packages/apps/dashboard/server/src/modules/details/details.spec.ts index 693e893870..e468d74ff2 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.spec.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.spec.ts @@ -268,6 +268,96 @@ describe('DetailsService', () => { ]); }); + it('should omit tokenSymbol when transaction has no token', async () => { + const walletAddress = '0xA'; + const senderAddress = '0xB'; + const receiverAddress = '0xC'; + + const mockTransactions = [ + { + block: 123n, + txHash: '0x1', + from: senderAddress, + to: walletAddress, + timestamp: Date.now(), + value: 1234567n, + method: 'bulkTransfer', + receiver: null, + escrow: null, + token: null, + internalTransactions: [ + { + from: senderAddress, + to: receiverAddress, + value: 345678n, + method: 'transfer', + receiver: null, + escrow: null, + token: null, + }, + ], + }, + ]; + + jest + .spyOn(TransactionUtils, 'getTransactions') + .mockResolvedValue(mockTransactions); + + const result = await service.getTransactions( + DevelopmentChainId.SEPOLIA, + walletAddress, + 10, + 0, + ); + + expect(result[0].value).toBe('0.000000000001234567'); + expect(result[0].tokenSymbol).toBeUndefined(); + expect(JSON.parse(JSON.stringify(result[0]))).not.toHaveProperty( + 'tokenSymbol', + ); + expect(result[0].internalTransactions[0].value).toBe( + '0.000000000000345678', + ); + expect(result[0].internalTransactions[0].tokenSymbol).toBeUndefined(); + expect( + JSON.parse(JSON.stringify(result[0].internalTransactions[0])), + ).not.toHaveProperty('tokenSymbol'); + }); + + it('should throw when token metadata cannot be resolved for a tokenized transaction', async () => { + const walletAddress = '0xA'; + const senderAddress = '0xB'; + const tokenAddress = '0x000000000000000000000000000000000000000d'; + + const mockTransactions = [ + { + block: 123n, + txHash: '0x1', + from: senderAddress, + to: walletAddress, + timestamp: Date.now(), + value: 1234567n, + method: 'bulkTransfer', + receiver: null, + escrow: null, + token: tokenAddress, + internalTransactions: [], + }, + ]; + + jest + .spyOn(TransactionUtils, 'getTransactions') + .mockResolvedValue(mockTransactions); + + jest + .spyOn(service as any, 'getTokenData') + .mockRejectedValue(new Error('Failed to fetch token metadata')); + + await expect( + service.getTransactions(DevelopmentChainId.SEPOLIA, walletAddress, 10, 0), + ).rejects.toThrow('Failed to fetch token metadata'); + }); + it('should deduplicate concurrent token metadata fetches and reuse resolved promises', async () => { const tokenAddress = '0x000000000000000000000000000000000000000d'; const provider = (service as any).getProvider(DevelopmentChainId.SEPOLIA); From 3e71fe7a79e8c5b6a4959594639e24bbcd234b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= <50665615+flopez7@users.noreply.github.com> Date: Mon, 9 Mar 2026 17:18:03 +0100 Subject: [PATCH 6/7] Apply suggestions from code review Co-authored-by: Dmitry Nechay --- .../dashboard/server/src/modules/details/details.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/dashboard/server/src/modules/details/details.service.ts b/packages/apps/dashboard/server/src/modules/details/details.service.ts index 6f05e5f45c..0b40757ced 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.service.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.service.ts @@ -522,7 +522,7 @@ export class DetailsService { txHash: transaction.txHash, error: error instanceof Error ? error.message : String(error), }); - throw error; + throw new Error('Failed to resolve token metadata'); } }; From cc320b9a03427d888fd8764f4ae9dcff0f7a81ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20L=C3=B3pez?= Date: Tue, 10 Mar 2026 10:27:04 +0100 Subject: [PATCH 7/7] refactor error handling in getTransactions to improve token metadata resolution --- .../dashboard/server/src/modules/details/details.spec.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/apps/dashboard/server/src/modules/details/details.spec.ts b/packages/apps/dashboard/server/src/modules/details/details.spec.ts index e468d74ff2..2d31b20cc0 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.spec.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.spec.ts @@ -349,13 +349,11 @@ describe('DetailsService', () => { .spyOn(TransactionUtils, 'getTransactions') .mockResolvedValue(mockTransactions); - jest - .spyOn(service as any, 'getTokenData') - .mockRejectedValue(new Error('Failed to fetch token metadata')); + jest.spyOn(service as any, 'getTokenData').mockRejectedValue(new Error()); await expect( service.getTransactions(DevelopmentChainId.SEPOLIA, walletAddress, 10, 0), - ).rejects.toThrow('Failed to fetch token metadata'); + ).rejects.toThrow('Failed to resolve token metadata'); }); it('should deduplicate concurrent token metadata fetches and reuse resolved promises', async () => {