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..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,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 formatTokenDecimals from '@/shared/lib/formatTokenDecimals'; 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 ? ( + '-' + ) : ( + <> + {formatTokenDecimals(value)} + + {tokenSymbol} + + + )} {method === 'approve' && ( )} diff --git a/packages/apps/dashboard/client/src/shared/lib/formatHmtDecimals.ts b/packages/apps/dashboard/client/src/shared/lib/formatTokenDecimals.ts similarity index 73% rename from packages/apps/dashboard/client/src/shared/lib/formatHmtDecimals.ts rename to packages/apps/dashboard/client/src/shared/lib/formatTokenDecimals.ts index c0610ad83b..b18d169155 100644 --- a/packages/apps/dashboard/client/src/shared/lib/formatHmtDecimals.ts +++ b/packages/apps/dashboard/client/src/shared/lib/formatTokenDecimals.ts @@ -1,7 +1,9 @@ -import { formatEther } from 'ethers'; +const formatTokenDecimals = (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 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 63905022ea..0b40757ced 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,18 +20,19 @@ 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 { MAX_LEADERS_COUNT, MIN_STAKED_AMOUNT, REPUTATION_PLACEHOLDER, + TOKEN_CACHE_PREFIX, 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 +43,10 @@ import { WalletDto } from './dto/wallet.dto'; @Injectable() export class DetailsService { private readonly logger = logger.child({ context: DetailsService.name }); + private readonly tokenData = new Map< + string, + Promise<{ decimals: number; symbol: string | null }> + >(); constructor( @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, @@ -53,11 +59,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 +68,9 @@ export class DetailsService { }); const { decimals, symbol } = await this.getTokenData( + provider, chainId, escrowData.token, - provider, ); escrowDto.balance = ethers.formatUnits(escrowData.balance, decimals); @@ -139,11 +141,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 +155,7 @@ export class DetailsService { first: number, skip: number, ): Promise { + const provider = this.getProvider(chainId); const transactions = await TransactionUtils.getTransactions({ chainId, fromAddress: address, @@ -164,15 +163,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 +446,108 @@ export class DetailsService { } private async getTokenData( + provider: ethers.JsonRpcProvider, chainId: ChainId, tokenAddress: string, - provider: ethers.JsonRpcProvider, - ): Promise<{ decimals: number; symbol: string }> { - const tokenCacheKey = `token:${chainId}:${tokenAddress.toLowerCase()}`; - let data = await this.cacheManager.get<{ - decimals: number; - symbol: string; - }>(tokenCacheKey); - if (!data) { - 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); + ): Promise<{ decimals: number; symbol: string | null }> { + if (!ethers.isAddress(tokenAddress)) { + throw new Error(`Invalid token address: ${tokenAddress}`); } - return data; + + const normalizedTokenAddress = tokenAddress.toLowerCase(); + const tokenCacheKey = `${TOKEN_CACHE_PREFIX}:${chainId}:${normalizedTokenAddress}`; + + 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; + } + })(); + + this.tokenData.set(tokenCacheKey, tokenDataPromise); + } + + return this.tokenData.get(tokenCacheKey)!; + } + + 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 getFormattedTokenData = async (tokenAddress: string | null) => { + if (!tokenAddress) { + return 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), + }); + throw new Error('Failed to resolve token metadata'); + } + }; + + const internalTransactions = await Promise.all( + transaction.internalTransactions.map(async (internalTransaction) => { + const tokenData = await getFormattedTokenData( + internalTransaction.token, + ); + return { + ...internalTransaction, + value: ethers.formatUnits( + internalTransaction.value, + tokenData?.decimals ?? 18, + ), + ...(tokenData ? { tokenSymbol: tokenData.symbol } : {}), + }; + }), + ); + const tokenData = await getFormattedTokenData(transaction.token); + + return { + ...transaction, + 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 6cbc1dae10..2d31b20cc0 100644 --- a/packages/apps/dashboard/server/src/modules/details/details.spec.ts +++ b/packages/apps/dashboard/server/src/modules/details/details.spec.ts @@ -1,8 +1,10 @@ +import { HMToken__factory } from '@human-protocol/core/typechain-types'; import { IOperator, KVStoreUtils, OperatorUtils, OrderDirection, + TransactionUtils, } from '@human-protocol/sdk'; import { HttpService } from '@nestjs/axios'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; @@ -32,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, @@ -55,14 +63,17 @@ describe('DetailsService', () => { getAvailableNetworks: jest .fn() .mockResolvedValue([DevelopmentChainId.SEPOLIA]), + networks: [ + { + chainId: DevelopmentChainId.SEPOLIA, + rpcUrl: 'http://localhost:8545', + }, + ], }, }, { provide: CACHE_MANAGER, - useValue: { - get: jest.fn(), - set: jest.fn(), - }, + useValue: cacheManager, }, ], }).compile(); @@ -193,4 +204,205 @@ describe('DetailsService', () => { expect.objectContaining({ key: 'key2', value: 'value2' }), ]); }); + + it('should format transactions using token decimals and symbol', async () => { + const walletAddress = '0xA'; + const senderAddress = '0xB'; + const receiverAddress = '0xC'; + const tokenAddress = '0xD'; + const txHash = '0x1'; + + const mockTransactions = [ + { + block: 123n, + txHash, + from: senderAddress, + to: walletAddress, + timestamp: Date.now(), + value: 1234567n, + method: 'bulkTransfer', + receiver: null, + escrow: null, + token: tokenAddress, + internalTransactions: [ + { + from: senderAddress, + to: receiverAddress, + value: 345678n, + method: 'transfer', + receiver: null, + escrow: null, + token: tokenAddress, + }, + ], + }, + ]; + + jest + .spyOn(TransactionUtils, 'getTransactions') + .mockResolvedValue(mockTransactions); + + jest.spyOn(service as any, 'getTokenData').mockResolvedValue({ + decimals: 6, + symbol: 'USDC', + }); + + const result = await service.getTransactions( + DevelopmentChainId.SEPOLIA, + walletAddress, + 10, + 0, + ); + + expect(result).toEqual([ + expect.objectContaining({ + value: '1.234567', + tokenSymbol: 'USDC', + internalTransactions: [ + expect.objectContaining({ + value: '0.345678', + tokenSymbol: 'USDC', + }), + ], + }), + ]); + }); + + 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()); + + await expect( + service.getTransactions(DevelopmentChainId.SEPOLIA, walletAddress, 10, 0), + ).rejects.toThrow('Failed to resolve 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); + const tokenCacheKey = `token:${DevelopmentChainId.SEPOLIA}:${tokenAddress.toLowerCase()}`; + + cacheManager.get.mockResolvedValue(null); + + 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).getTokenData( + provider, + DevelopmentChainId.SEPOLIA, + tokenAddress, + ); + 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); + expect(cacheManager.get).toHaveBeenCalledTimes(1); + expect(cacheManager.set).toHaveBeenCalledWith(tokenCacheKey, { + decimals: 6, + symbol: 'USDC', + }); + expect((service as any).tokenData.size).toBe(1); + }); }); 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],