diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index 500ea36ffa3..90f69657b4f 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -7,8 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `eth-chainlist` dependency for chain-native slip44/symbol lookup ([#9729](https://github.com/MetaMask/core/pull/9729)) + ### Changed +- Resolve native fee/token metadata from `eth-chainlist` (chainId → slip44/symbol), falling back to `@metamask/slip44` by symbol when chainlist omits `slip44` ([#9729](https://github.com/MetaMask/core/pull/9729)) + - API network fees no longer scrape native symbol from `valueTransfers` + - STANDARD sends with empty `valueTransfers` synthesize a native token from `tx.value` - Bump `@metamask/core-backend` from `^8.0.0` to `^8.1.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) - Bump `@metamask/transaction-controller` from `^69.3.0` to `^69.4.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) diff --git a/packages/client-utils/package.json b/packages/client-utils/package.json index 38193c06d84..229ac6303e4 100644 --- a/packages/client-utils/package.json +++ b/packages/client-utils/package.json @@ -61,7 +61,8 @@ "@metamask/keyring-api": "^23.7.0", "@metamask/slip44": "^4.3.0", "@metamask/transaction-controller": "^69.4.0", - "@metamask/utils": "^11.11.0" + "@metamask/utils": "^11.11.0", + "eth-chainlist": "^0.0.795" }, "devDependencies": { "@ethersproject/abi": "^5.7.0", diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.test.ts b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts index 9dca66309e9..e1a85dea10e 100644 --- a/packages/client-utils/src/mappers/api-transaction-mapper.test.ts +++ b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts @@ -863,24 +863,6 @@ describe('mapApiTransaction', () => { expect(item.chainId).toBe('eip155:4657'); }); - it('maps a Standard transaction without value transfers to a send without inventing a native token', () => { - const item = mapApiTransaction( - apiTransactionFixtures.mapArgs.mapsAStandardTransactionWithA, - ); - - expect(item).toMatchObject({ - type: 'send', - chainId: 'eip155:1', - data: { - from: subjectAddress, - to: baseRecipientAddress, - }, - }); - expect( - (item as { data?: { token?: unknown } }).data?.token, - ).toBeUndefined(); - }); - it('maps an APPROVE with only an inbound transfer (revoke) to an inbound spending cap', () => { const item = mapApiTransaction( apiTransactionFixtures.mapArgs.mapsAnApproveWithOnlyAn, @@ -916,23 +898,6 @@ describe('mapApiTransaction', () => { }); }); - it('maps a Standard inbound transfer without value transfers to a receive without inventing a native token', () => { - const item = mapApiTransaction( - apiTransactionFixtures.mapArgs.mapsAStandardInboundNativeTransfer, - ); - - expect(item).toMatchObject({ - type: 'receive', - chainId: 'eip155:1', - data: { - to: subjectAddress, - }, - }); - expect( - (item as { data?: { token?: unknown } }).data?.token, - ).toBeUndefined(); - }); - it('does not map a withdraw without a known method id to a lending withdrawal', () => { const { transaction, subjectAddress: fixtureSubjectAddress } = apiTransactionFixtures.mapArgs.mapsAnAaveWithdrawWithA; @@ -1005,4 +970,208 @@ describe('mapApiTransaction', () => { }, }); }); + + it('maps a zero-value STANDARD send with empty valueTransfers to a native send with native fees', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAZeroValueStandardSendWithoutTransfers, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1', + status: 'success', + hash: '0x062497f6874582f5d14e65510606a849d6fe8d0ea468c12907452e235c6b5201', + data: { + from: subjectAddress, + to: apiTransactionFixtures.addresses.zeroValueSendRecipient, + token: { + direction: 'out', + amount: '0', + decimals: 18, + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + fees: [ + { + type: 'base', + amount: '45127371870000', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + }, + ], + }, + }); + }); + + it('maps a STANDARD receive with empty valueTransfers to a native receive', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAStandardInboundWithoutTransfers, + ); + + expect(item).toMatchObject({ + type: 'receive', + chainId: 'eip155:1', + data: { + to: subjectAddress, + token: { + direction: 'in', + amount: '1000000000000000000', + decimals: 18, + symbol: 'ETH', + assetType: 'native', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + + it('maps an Across USDT exchange with no native valueTransfers to a swap with native fees', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnAcrossUsdtExchangeWithNativeFee, + ); + + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:42161', + status: 'success', + hash: '0x34bbaa01262f2e9221913316f4548a4b5981e05ee338ea586fc267a6868f9526', + data: { + sourceToken: { + direction: 'out', + amount: '1199957', + decimals: 6, + symbol: 'USDT', + assetId: formatAddressToAssetId( + apiTransactionFixtures.addresses.arbitrumUsdt, + 'eip155:42161', + ), + assetType: 'erc20', + }, + fees: [ + { + type: 'base', + amount: '8570086182000', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:42161/slip44:60', + }, + ], + }, + }); + }); + + it('maps a TRANSFER with empty valueTransfers without synthesizing a native token', () => { + const item = mapApiTransaction({ + subjectAddress, + transaction: { + hash: '0xtransferwithouttransfers', + chainId: 1, + timestamp: '2026-07-29T22:19:25.000Z', + isError: false, + transactionCategory: 'TRANSFER', + from: subjectAddress, + to: baseRecipientAddress, + value: '0', + valueTransfers: [], + gasUsed: 21000, + effectiveGasPrice: '1', + }, + }); + + expect(item).toMatchObject({ + type: 'send', + data: { + token: undefined, + }, + }); + }); + + it('keeps an ERC-20 transfer without assetId when tx.to cannot be encoded', () => { + const item = mapApiTransaction({ + subjectAddress, + transaction: { + hash: '0xerc20withoutassetid', + chainId: 1, + timestamp: '2026-07-29T22:19:25.000Z', + isError: false, + transactionCategory: 'TRANSFER', + from: subjectAddress, + to: zeroAddress, + value: '0', + valueTransfers: [ + { + from: subjectAddress, + to: baseRecipientAddress, + transferType: 'erc20', + symbol: 'USDC', + amount: '1', + decimal: 6, + }, + ], + gasUsed: 21000, + effectiveGasPrice: '1', + }, + }); + + expect(item).toMatchObject({ + type: 'send', + data: { + token: { + direction: 'out', + symbol: 'USDC', + amount: '1', + assetType: 'erc20', + }, + }, + }); + expect(item).toMatchObject({ + data: { + token: expect.not.objectContaining({ assetId: expect.anything() }), + }, + }); + }); + + it('classifies a receive when tx.to is the subject even if a sent transfer exists', () => { + const item = mapApiTransaction({ + subjectAddress, + transaction: { + hash: '0xreceivethroughto', + chainId: 1, + timestamp: '2026-07-29T22:19:25.000Z', + isError: false, + transactionCategory: 'TRANSFER', + from: lineaSenderAddress, + to: subjectAddress, + value: '0', + valueTransfers: [ + { + from: subjectAddress, + to: baseRecipientAddress, + transferType: 'erc20', + symbol: 'USDC', + amount: '1', + decimal: 6, + contractAddress: mainnetUsdc, + }, + { + from: lineaSenderAddress, + to: subjectAddress, + transferType: 'erc20', + symbol: 'USDT', + amount: '2', + decimal: 6, + contractAddress: mainnetUsdc, + }, + ], + gasUsed: 21000, + effectiveGasPrice: '1', + }, + }); + + expect(item.type).toBe('receive'); + }); }); diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.ts b/packages/client-utils/src/mappers/api-transaction-mapper.ts index 2ee4b62d2c9..7169eb363e5 100644 --- a/packages/client-utils/src/mappers/api-transaction-mapper.ts +++ b/packages/client-utils/src/mappers/api-transaction-mapper.ts @@ -18,14 +18,13 @@ import { withdrawMethodIds, wrapMethodIds, } from './constants.js'; -import { formatAddressToAssetId } from './helpers/caip.js'; +import { formatAddressToAssetId, getNativeAsset } from './helpers/caip.js'; import { getFees, getNftPaymentTransfer, getTokenAmountFromTransfer, getTokenMetadataFromKnownToken, parseValueTransfers, - withFallbackTokenAssetId, } from './helpers/transactions.js'; /** @@ -216,7 +215,35 @@ export function mapApiTransaction({ !equalsIgnoreCase(from, subjectAddress)); const transfer = isReceive ? receivedTransfer : sentTransfer; - const direction = isReceive ? 'in' : 'out'; + const direction: TokenAmount['direction'] = isReceive ? 'in' : 'out'; + let token = getToken(transfer, direction); + + if (!token) { + // Zero-value sends can omit valueTransfers + if (transactionCategory === 'STANDARD') { + const nativeAsset = getNativeAsset(chainId); + if (nativeAsset) { + token = { + symbol: nativeAsset.symbol, + decimals: nativeAsset.decimals, + assetId: nativeAsset.assetId, + amount: transaction.value, + direction, + assetType: 'native', + }; + } + } + } else if ( + !token.assetId && + transfer?.transferType !== 'normal' && + transfer?.transferType !== 'internal' + ) { + // ERC-20 transfer missing contractAddress — fall back to tx.to. + const assetId = formatAddressToAssetId(transaction.to, chainId); + if (assetId) { + token = { ...token, assetId }; + } + } return { type: isReceive ? 'receive' : 'send', @@ -224,12 +251,7 @@ export function mapApiTransaction({ data: { from: transfer?.from ?? from, to: transfer?.to ?? transaction.to, - token: withFallbackTokenAssetId( - getToken(transfer, direction), - transaction.to, - transfer?.transferType, - chainId, - ), + token, fees: getFees(transaction), }, }; diff --git a/packages/client-utils/src/mappers/constants.ts b/packages/client-utils/src/mappers/constants.ts index ba9aea7ecb2..fa8e7409fe8 100644 --- a/packages/client-utils/src/mappers/constants.ts +++ b/packages/client-utils/src/mappers/constants.ts @@ -32,6 +32,8 @@ export const tokenTransferLogTopicHash = export const nativeTokenAddress = '0x0000000000000000000000000000000000000000'; +export const nativeTokenDecimals = 18; + export const swapsWrappedTokensAddresses = { '0x1': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', '0x539': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', diff --git a/packages/client-utils/src/mappers/helpers/caip.test.ts b/packages/client-utils/src/mappers/helpers/caip.test.ts index e807979834d..3727537c04a 100644 --- a/packages/client-utils/src/mappers/helpers/caip.test.ts +++ b/packages/client-utils/src/mappers/helpers/caip.test.ts @@ -1,10 +1,23 @@ +import { getChainById } from 'eth-chainlist'; + import { formatAddressToAssetId, formatChainIdToCaip, + getNativeAsset, resolveNativeAssetId, } from './caip.js'; +jest.mock('eth-chainlist', () => ({ + getChainById: jest.fn(), +})); + +const mockGetChainById = jest.mocked(getChainById); + describe('caip helpers', () => { + beforeEach(() => { + mockGetChainById.mockReset(); + }); + describe('formatChainIdToCaip', () => { it('formats numeric chain ids', () => { expect(formatChainIdToCaip(1)).toBe('eip155:1'); @@ -113,4 +126,104 @@ describe('caip helpers', () => { expect(resolveNativeAssetId('0xzzzz', 'ETH')).toBeUndefined(); }); }); + + describe('getNativeAsset', () => { + it('resolves native asset from chainlist slip44', () => { + mockGetChainById.mockReturnValue({ + slip44: 60, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:1')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }); + }); + + it('falls back to symbol lookup when chainlist omits slip44', () => { + mockGetChainById.mockReturnValue({ + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:42161')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:42161/slip44:60', + }); + }); + + it('ignores chainlist testnet slip44:1 and uses the native symbol coin type', () => { + mockGetChainById.mockReturnValue({ + slip44: 1, + nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:11155111')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:11155111/slip44:60', + }); + }); + + it('prefers chainlist slip44 over the slip44 registry symbol mapping', () => { + mockGetChainById.mockReturnValue({ + slip44: 9005, + nativeCurrency: { name: 'Avalanche', symbol: 'AVAX', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:43114')).toStrictEqual({ + symbol: 'AVAX', + decimals: 18, + assetId: 'eip155:43114/slip44:9005', + }); + }); + + it('returns undefined when the chain is unknown', () => { + mockGetChainById.mockReturnValue(undefined); + + expect(getNativeAsset('eip155:999999991')).toBeUndefined(); + }); + + it('returns undefined for non-eip155 chain ids', () => { + expect( + getNativeAsset('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'), + ).toBeUndefined(); + expect(mockGetChainById).not.toHaveBeenCalled(); + }); + + it('returns undefined when chainlist omits native currency symbol', () => { + mockGetChainById.mockReturnValue({ + slip44: 60, + nativeCurrency: { name: 'Ether', decimals: 18 }, + } as ReturnType); + + expect(getNativeAsset('eip155:1')).toBeUndefined(); + }); + + it('defaults decimals when chainlist omits native currency decimals', () => { + mockGetChainById.mockReturnValue({ + slip44: 60, + nativeCurrency: { name: 'Ether', symbol: 'ETH' }, + } as ReturnType); + + expect(getNativeAsset('eip155:1')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }); + }); + + it('returns undefined when slip44 and symbol lookup both fail', () => { + mockGetChainById.mockReturnValue({ + nativeCurrency: { + name: 'Unknown', + symbol: 'NOTACOIN', + decimals: 18, + }, + } as ReturnType); + + expect(getNativeAsset('eip155:1088')).toBeUndefined(); + }); + }); }); diff --git a/packages/client-utils/src/mappers/helpers/caip.ts b/packages/client-utils/src/mappers/helpers/caip.ts index bae7ece7faa..ebe43cac952 100644 --- a/packages/client-utils/src/mappers/helpers/caip.ts +++ b/packages/client-utils/src/mappers/helpers/caip.ts @@ -1,21 +1,23 @@ import { toChecksumHexAddress } from '@metamask/controller-utils'; // @ts-expect-error: No type definitions for '@metamask/slip44' -import slip44 from '@metamask/slip44'; +import slip44data from '@metamask/slip44'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; import { isCaipAssetType, isStrictHexString, + KnownCaipNamespace, parseCaipChainId, toCaipAssetType, } from '@metamask/utils'; +import { getChainById } from 'eth-chainlist'; -import { nativeTokenAddress } from '../constants.js'; +import { nativeTokenAddress, nativeTokenDecimals } from '../constants.js'; -const slip44CoinTypeBySymbol = ((): Map => { +const slip44BySymbol = ((): Map => { const coinTypeBySymbol = new Map(); for (const [coinType, entry] of Object.entries( - slip44 as Record, + slip44data as Record, )) { const normalizedSymbol = entry.symbol.toUpperCase(); @@ -25,16 +27,15 @@ const slip44CoinTypeBySymbol = ((): Map => { } const maticCoinType = coinTypeBySymbol.get('MATIC'); - - if (maticCoinType) { + if (maticCoinType && !coinTypeBySymbol.has('POL')) { coinTypeBySymbol.set('POL', maticCoinType); } return coinTypeBySymbol; })(); -function slip44CoinTypeForSymbol(symbol: string): string | undefined { - return slip44CoinTypeBySymbol.get(symbol.toUpperCase()); +function getCoinType(symbol: string): string | undefined { + return slip44BySymbol.get(symbol.toUpperCase()); } /** @@ -82,15 +83,62 @@ export function resolveNativeAssetId( return undefined; } - const coinType = slip44CoinTypeForSymbol(symbol); + const assetReference = getCoinType(symbol); - if (!coinType) { + if (!assetReference) { return undefined; } const { namespace, reference } = parseCaipChainId(caipChainId); - return toCaipAssetType(namespace, reference, 'slip44', coinType); + return toCaipAssetType(namespace, reference, 'slip44', assetReference); +} + +/** + * Resolves EVM native symbol, decimals, and slip44 asset id for a chain. + * Prefers eth-chainlist slip44 except testnet coin type 1, then falls back to + * `@metamask/slip44` by native symbol. + * + * @param chainId - CAIP-2 chain id (eip155 only). + * @returns Native asset metadata, or undefined when it cannot be resolved. + */ +export function getNativeAsset(chainId: CaipChainId): + | { + symbol: string; + decimals: number; + assetId: CaipAssetType; + } + | undefined { + const { namespace, reference } = parseCaipChainId(chainId); + if (namespace !== KnownCaipNamespace.Eip155) { + return undefined; + } + + const chain = getChainById(Number(reference)); + if (!chain) { + return undefined; + } + + const { nativeCurrency, slip44 } = chain; + if (!nativeCurrency?.symbol) { + return undefined; + } + + const slip44TestnetCoinType = 1; + const assetReference = + typeof slip44 === 'number' && slip44 !== slip44TestnetCoinType + ? String(slip44) + : getCoinType(nativeCurrency.symbol); + + if (!assetReference) { + return undefined; + } + + return { + symbol: nativeCurrency.symbol, + decimals: nativeCurrency.decimals ?? nativeTokenDecimals, + assetId: toCaipAssetType(namespace, reference, 'slip44', assetReference), + }; } function isNativeAddress(address: string): boolean { diff --git a/packages/client-utils/src/mappers/helpers/transactions.test.ts b/packages/client-utils/src/mappers/helpers/transactions.test.ts index 98c5cd28522..9960abf9ca9 100644 --- a/packages/client-utils/src/mappers/helpers/transactions.test.ts +++ b/packages/client-utils/src/mappers/helpers/transactions.test.ts @@ -8,7 +8,6 @@ import { getTokenMetadataFromKnownToken, isNftStandard, parseValueTransfers, - withFallbackTokenAssetId, } from './transactions.js'; describe('transaction helpers', () => { @@ -252,18 +251,9 @@ describe('transaction helpers', () => { }); }); - describe('withFallbackTokenAssetId', () => { - it('returns the token unchanged when the fallback address cannot be encoded', () => { - const token = { direction: 'out' as const, symbol: 'USDC' }; - - expect( - withFallbackTokenAssetId(token, 'not-an-address', 'erc20', 'eip155:1'), - ).toBe(token); - }); - }); - describe('getLocalTransactionFees', () => { - it('returns fallback native fee metadata for unsupported chains', () => { + it('resolves fee assetId via ETH symbol when chainlist only has testnet slip44:1', () => { + // 0x539 = Geth Testnet (1337); chainlist slip44 is 1, which we skip. expect( getLocalTransactionFees({ primaryTransaction: { @@ -281,6 +271,8 @@ describe('transaction helpers', () => { amount: '2', decimals: 18, assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:1337/slip44:60', }, ]); }); @@ -335,10 +327,59 @@ describe('transaction helpers', () => { } as Parameters[0]), ).toBeUndefined(); }); + + it('resolves fee assetId from nativeAssetSymbol when the chain is unknown', () => { + expect( + getLocalTransactionFees({ + nativeAssetSymbol: 'ETH', + primaryTransaction: { + chainId: '0x3b9ac9f7', + txParams: {}, + txReceipt: { + gasUsed: '0x1', + effectiveGasPrice: '0x2', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '2', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:999999991/slip44:60', + }, + ]); + }); + + it('keeps the nativeAssetSymbol on fees when it cannot be mapped to an assetId', () => { + expect( + getLocalTransactionFees({ + nativeAssetSymbol: 'NOTACOIN', + primaryTransaction: { + chainId: '0x3b9ac9f7', + txParams: {}, + txReceipt: { + gasUsed: '0x1', + effectiveGasPrice: '0x2', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '2', + decimals: 18, + assetType: 'native', + symbol: 'NOTACOIN', + }, + ]); + }); }); describe('getFees', () => { - it('returns network fees with amount and decimals only', () => { + it('returns network fees with native symbol and assetId from the chain id', () => { expect( getFees({ chainId: 1, @@ -351,6 +392,8 @@ describe('transaction helpers', () => { amount: '6', decimals: 18, assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', }, ]); }); @@ -395,6 +438,77 @@ describe('transaction helpers', () => { } as Parameters[0]), ).toBeUndefined(); }); + + it('returns fee amount without symbol or assetId when the chain is unknown', () => { + expect( + getFees({ + chainId: 999999991, + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '6', + decimals: 18, + assetType: 'native', + }, + ]); + }); + + it('uses ETH slip44:60 for Sepolia fees instead of chainlist testnet coin type 1', () => { + expect( + getFees({ + chainId: 11155111, + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '6', + decimals: 18, + assetType: 'native', + symbol: 'ETH', + assetId: 'eip155:11155111/slip44:60', + }, + ]); + }); + + it('uses chainlist Avalanche slip44:9005 for fees instead of registry AVAX 9000', () => { + expect( + getFees({ + chainId: 43114, + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '6', + decimals: 18, + assetType: 'native', + symbol: 'AVAX', + assetId: 'eip155:43114/slip44:9005', + }, + ]); + }); + + it('keeps wei decimals for fees when chainlist nativeCurrency.decimals is not 18', () => { + expect( + getFees({ + chainId: 4160, + gasUsed: '21000', + effectiveGasPrice: '1000000000', + } as Parameters[0]), + ).toMatchObject({ + 0: { + amount: '21000000000000', + decimals: 18, + symbol: 'ALGO', + }, + }); + }); }); describe('getLocalTransactionStatus', () => { diff --git a/packages/client-utils/src/mappers/helpers/transactions.ts b/packages/client-utils/src/mappers/helpers/transactions.ts index 6f958e896ad..e44aba7ca2d 100644 --- a/packages/client-utils/src/mappers/helpers/transactions.ts +++ b/packages/client-utils/src/mappers/helpers/transactions.ts @@ -18,9 +18,11 @@ import type { TokenAmount, ValueTransfer, } from '../../types.js'; +import { nativeTokenDecimals } from '../constants.js'; import { formatAddressToAssetId, formatChainIdToCaip, + getNativeAsset, resolveNativeAssetId, } from './caip.js'; import { getKnownTokenMetadata } from './token-metadata.js'; @@ -35,9 +37,7 @@ export type TransactionGroup = { transactions: TransactionMeta[]; }; -const nativeTokenDecimals = 18; - -function toNetworkFeeAmount( +function calculateNetworkFee( gasUsed: string | number | undefined, gasPrice: string | number | undefined, ): string | undefined { @@ -52,12 +52,25 @@ function toNetworkFeeAmount( } } -function buildBaseNetworkFee( +function toNetworkFee( amount: string, chainId: CaipChainId, symbol?: string, ): Fee { - const assetId = resolveNativeAssetId(chainId, symbol); + const nativeAsset = getNativeAsset(chainId); + + if (nativeAsset) { + return { + type: 'base', + amount, + decimals: nativeTokenDecimals, + assetType: 'native', + symbol: symbol ?? nativeAsset.symbol, + assetId: nativeAsset.assetId, + }; + } + + const assetId = symbol ? resolveNativeAssetId(chainId, symbol) : undefined; return { type: 'base', @@ -91,23 +104,6 @@ function getAssetTypeFromTransferType( return undefined; } -function getNativeSymbolFromValueTransfers( - valueTransfers: V1TransactionByHashResponse['valueTransfers'], -): string | undefined { - for (const transfer of valueTransfers ?? []) { - const transferType = transfer.transferType?.toLowerCase(); - - if ( - (transferType === 'normal' || transferType === 'internal') && - transfer.symbol - ) { - return transfer.symbol; - } - } - - return undefined; -} - function getNetworkFee( transaction: V1TransactionByHashResponse, ): Fee | undefined { @@ -117,7 +113,7 @@ function getNetworkFee( return undefined; } - const amount = toNetworkFeeAmount( + const amount = calculateNetworkFee( transaction.gasUsed, transaction.effectiveGasPrice, ); @@ -126,9 +122,7 @@ function getNetworkFee( return undefined; } - const symbol = getNativeSymbolFromValueTransfers(transaction.valueTransfers); - - return buildBaseNetworkFee(amount, chainId, symbol); + return toNetworkFee(amount, chainId); } export function getFees( @@ -151,7 +145,7 @@ export function getLocalTransactionFees( return undefined; } - const amount = toNetworkFeeAmount( + const amount = calculateNetworkFee( primaryTransaction.txReceipt?.gasUsed, primaryTransaction.txReceipt?.effectiveGasPrice ?? primaryTransaction.txParams?.gasPrice, @@ -161,7 +155,7 @@ export function getLocalTransactionFees( return undefined; } - return [buildBaseNetworkFee(amount, chainId, nativeAssetSymbol)]; + return [toNetworkFee(amount, chainId, nativeAssetSymbol)]; } const inProgressTransactionStatuses = [ @@ -436,40 +430,3 @@ export function getTokenMetadataFromKnownToken( ...(tokenMetadata.assetId ? { assetId: tokenMetadata.assetId } : {}), }; } - -/** - * When the transfer omits contractAddress, fall back to the indexed tx `to` field. - * - * @param token - Parsed token amount from the value transfer. - * @param fallbackContractAddress - Indexed transaction `to` address used as ERC-20 fallback. - * @param transferType - Value transfer type; native (`normal`) transfers skip the fallback. - * @param chainId - CAIP-2 chain id for asset id encoding. - * @returns Token amount with `assetId` set when a fallback address applies. - */ -export function withFallbackTokenAssetId( - token: TokenAmount | undefined, - fallbackContractAddress: string | undefined, - transferType: string | undefined, - chainId: CaipChainId, -): TokenAmount | undefined { - if ( - !token || - token.assetId || - transferType === 'normal' || - transferType === 'internal' || - !fallbackContractAddress - ) { - return token; - } - - const assetId = formatAddressToAssetId(fallbackContractAddress, chainId); - if (!assetId) { - return token; - } - - return { - ...token, - assetId, - assetType: token.assetType, - }; -} diff --git a/packages/client-utils/test/fixtures/api-transactions.ts b/packages/client-utils/test/fixtures/api-transactions.ts index 72e2f24bbe3..7162d89e6a0 100644 --- a/packages/client-utils/test/fixtures/api-transactions.ts +++ b/packages/client-utils/test/fixtures/api-transactions.ts @@ -28,6 +28,9 @@ const addresses = { nftContractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', stakingContractAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', lidoStEth: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + zeroValueSendRecipient: '0x0c54fccd2e384b4bb6f2e405bf5cbc15a017aafb', + arbitrumUsdt: '0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9', + acrossExchangeRecipient: '0x7a70cb77a12fa2dc3fa1bc1dcbca4c79db71a289', } as const; const transactions = { @@ -959,15 +962,6 @@ const transactions = { value: '1000000000000000000', valueTransfers: [], }, - mapsAStandardTransactionWithA: { - timestamp: '2026-05-12T13:37:47.000Z', - chainId: 1, - from: addresses.subjectAddress, - to: addresses.baseRecipientAddress, - transactionCategory: 'STANDARD', - value: '1000000000000000000', - valueTransfers: [], - }, mapsAnApproveWithOnlyAn: { hash: '0xrevoke', timestamp: '2026-05-12T13:37:47.000Z', @@ -994,15 +988,6 @@ const transactions = { transactionCategory: 'APPROVE', valueTransfers: [], }, - mapsAStandardInboundNativeTransfer: { - timestamp: '2026-05-12T13:37:47.000Z', - chainId: 1, - from: '0x1111111111111111111111111111111111111111', - to: addresses.subjectAddress, - transactionCategory: 'STANDARD', - value: '1000000000000000000', - valueTransfers: [], - }, mapsAnUnrecognizedCategoryWithOnly: { hash: '0xmystery', timestamp: '2026-05-12T13:37:47.000Z', @@ -1033,6 +1018,83 @@ const transactions = { transactionCategory: 'CONTRACT_CALL', valueTransfers: [], }, + mapsAZeroValueStandardSendWithoutTransfers: { + hash: '0x062497f6874582f5d14e65510606a849d6fe8d0ea468c12907452e235c6b5201', + timestamp: '2026-07-29T01:22:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x9bed78535d6a03a955f1504aadba974d9a29e292', + blockNumber: 25635173, + blockHash: + '0x58761bcd4278bd5ddf326648b294101b6e6e00c3a7e7ca4235212cb4ef4b00cb', + gas: 21000, + gasUsed: 21000, + gasPrice: '2148922470', + effectiveGasPrice: '2148922470', + nonce: 244, + cumulativeGasUsed: 1003926, + methodId: null, + value: '0', + to: addresses.zeroValueSendRecipient, + from: addresses.subjectAddress, + isError: false, + valueTransfers: [], + logs: [], + transactionType: 'STANDARD', + transactionCategory: 'STANDARD', + readable: 'Sent', + readableExtended: 'Sent', + }, + mapsAStandardInboundWithoutTransfers: { + hash: '0xstandardinboundwithouttransfers', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: '0x1111111111111111111111111111111111111111', + to: addresses.subjectAddress, + transactionCategory: 'STANDARD', + value: '1000000000000000000', + valueTransfers: [], + isError: false, + gasUsed: 21000, + effectiveGasPrice: '1', + }, + mapsAnAcrossUsdtExchangeWithNativeFee: { + hash: '0x34bbaa01262f2e9221913316f4548a4b5981e05ee338ea586fc267a6868f9526', + timestamp: '2026-07-28T10:29:49.000Z', + chainId: 42161, + accountId: 'eip155:42161:0x9bed78535d6a03a955f1504aadba974d9a29e292', + blockNumber: 488572152, + blockHash: + '0xf9742fb12fa16b42fb0dbc043bfe3bc0437448cab7da0419d425e29cf01f6dfd', + gas: 649231, + gasUsed: 424977, + gasPrice: '20166000', + effectiveGasPrice: '20166000', + nonce: 108, + cumulativeGasUsed: 424977, + methodId: '0xe9ae5c53', + value: '0', + to: addresses.subjectAddress, + from: addresses.subjectAddress, + isError: false, + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.acrossExchangeRecipient, + amount: '1199957', + decimal: 6, + contractAddress: addresses.arbitrumUsdt, + symbol: 'USDT', + name: 'Tether USD', + transferType: 'erc20', + }, + ], + logs: [], + transactionCategory: 'EXCHANGE', + transactionProtocol: 'ACROSS', + transactionType: 'ACROSS_EXCHANGE', + readable: 'Swapped USDT for', + readableExtended: 'Swapped 1.2 USDT for', + }, } as const satisfies Record; const mapArgs = { @@ -1174,10 +1236,6 @@ const mapArgs = { subjectAddress: addresses.subjectAddress, transaction: transactions.mapsAStandardTransactionOnA, }, - mapsAStandardTransactionWithA: { - subjectAddress: addresses.subjectAddress, - transaction: transactions.mapsAStandardTransactionWithA, - }, mapsAnApproveWithOnlyAn: { subjectAddress: addresses.subjectAddress, transaction: transactions.mapsAnApproveWithOnlyAn, @@ -1186,10 +1244,6 @@ const mapArgs = { subjectAddress: addresses.subjectAddress, transaction: transactions.mapsAnApproveForAKnown, }, - mapsAStandardInboundNativeTransfer: { - subjectAddress: addresses.subjectAddress, - transaction: transactions.mapsAStandardInboundNativeTransfer, - }, mapsAnUnrecognizedCategoryWithOnly: { subjectAddress: addresses.subjectAddress, transaction: transactions.mapsAnUnrecognizedCategoryWithOnly, @@ -1198,6 +1252,18 @@ const mapArgs = { subjectAddress: addresses.subjectAddress, transaction: transactions.mapsAContractCallWithNoTransfers, }, + mapsAZeroValueStandardSendWithoutTransfers: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAZeroValueStandardSendWithoutTransfers, + }, + mapsAStandardInboundWithoutTransfers: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAStandardInboundWithoutTransfers, + }, + mapsAnAcrossUsdtExchangeWithNativeFee: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnAcrossUsdtExchangeWithNativeFee, + }, } as const; export const apiTransactionFixtures = { diff --git a/types/eth-chainlist.d.ts b/types/eth-chainlist.d.ts new file mode 100644 index 00000000000..21e2669300a --- /dev/null +++ b/types/eth-chainlist.d.ts @@ -0,0 +1,15 @@ +declare module 'eth-chainlist' { + export type ChainListEntry = { + name: string; + chainId: number; + networkId?: number; + slip44?: number; + nativeCurrency?: { + name?: string; + symbol?: string; + decimals?: number; + }; + }; + + export function getChainById(chainId: number): ChainListEntry | undefined; +} diff --git a/yarn.lock b/yarn.lock index 003e8338657..789c81d1323 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6468,6 +6468,7 @@ __metadata: "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" + eth-chainlist: "npm:^0.0.795" jest: "npm:^30.4.2" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" @@ -17102,6 +17103,13 @@ __metadata: languageName: node linkType: hard +"eth-chainlist@npm:^0.0.795": + version: 0.0.795 + resolution: "eth-chainlist@npm:0.0.795" + checksum: 10/1bdc634d107fd114c4b5567fba2003acc740c3e4e877dcc56db2b00071842003a2b918da1601c07451ae497291b63c923c89429d4f77dc41faf04cd186725a5e + languageName: node + linkType: hard + "eth-ens-namehash@npm:^2.0.8": version: 2.0.8 resolution: "eth-ens-namehash@npm:2.0.8"