Skip to content

Commit b5147cf

Browse files
n3pscursoragent
andcommitted
fix(client-utils): harden native fee metadata and address review feedback
Keep wei decimals on network fees, skip chainlist testnet slip44:1, restore guarded POL alias, mock chainlist in unit tests, cover inbound STANDARD synthesis, and trim eth-chainlist typings. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4bf8111 commit b5147cf

9 files changed

Lines changed: 178 additions & 33 deletions

File tree

packages/client-utils/src/mappers/api-transaction-mapper.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1006,6 +1006,28 @@ describe('mapApiTransaction', () => {
10061006
});
10071007
});
10081008

1009+
it('maps a STANDARD receive with empty valueTransfers to a native receive', () => {
1010+
const item = mapApiTransaction(
1011+
apiTransactionFixtures.mapArgs.mapsAStandardInboundWithoutTransfers,
1012+
);
1013+
1014+
expect(item).toMatchObject({
1015+
type: 'receive',
1016+
chainId: 'eip155:1',
1017+
data: {
1018+
to: subjectAddress,
1019+
token: {
1020+
direction: 'in',
1021+
amount: '1000000000000000000',
1022+
decimals: 18,
1023+
symbol: 'ETH',
1024+
assetType: 'native',
1025+
assetId: 'eip155:1/slip44:60',
1026+
},
1027+
},
1028+
});
1029+
});
1030+
10091031
it('maps an Across USDT exchange with no native valueTransfers to a swap with native fees', () => {
10101032
const item = mapApiTransaction(
10111033
apiTransactionFixtures.mapArgs.mapsAnAcrossUsdtExchangeWithNativeFee,

packages/client-utils/src/mappers/api-transaction-mapper.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,9 @@ export function mapApiTransaction({
224224
const nativeAsset = getNativeAsset(chainId);
225225
if (nativeAsset) {
226226
token = {
227-
...nativeAsset,
227+
symbol: nativeAsset.symbol,
228+
decimals: nativeAsset.decimals,
229+
assetId: nativeAsset.assetId,
228230
amount: transaction.value,
229231
direction,
230232
assetType: 'native',

packages/client-utils/src/mappers/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export const tokenTransferLogTopicHash =
3232

3333
export const nativeTokenAddress = '0x0000000000000000000000000000000000000000';
3434

35+
export const nativeTokenDecimals = 18;
36+
3537
export const swapsWrappedTokensAddresses = {
3638
'0x1': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
3739
'0x539': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',

packages/client-utils/src/mappers/helpers/caip.test.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,14 @@ import {
88
} from './caip.js';
99

1010
jest.mock('eth-chainlist', () => ({
11-
...jest.requireActual('eth-chainlist'),
1211
getChainById: jest.fn(),
1312
}));
1413

1514
const mockGetChainById = jest.mocked(getChainById);
1615

1716
describe('caip helpers', () => {
1817
beforeEach(() => {
19-
mockGetChainById.mockImplementation(
20-
jest.requireActual('eth-chainlist').getChainById,
21-
);
18+
mockGetChainById.mockReset();
2219
});
2320

2421
describe('formatChainIdToCaip', () => {
@@ -131,15 +128,24 @@ describe('caip helpers', () => {
131128
});
132129

133130
describe('getNativeAsset', () => {
134-
it('resolves mainnet native asset from chainlist slip44', () => {
131+
it('resolves native asset from chainlist slip44', () => {
132+
mockGetChainById.mockReturnValue({
133+
slip44: 60,
134+
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
135+
} as ReturnType<typeof getChainById>);
136+
135137
expect(getNativeAsset('eip155:1')).toStrictEqual({
136138
symbol: 'ETH',
137139
decimals: 18,
138140
assetId: 'eip155:1/slip44:60',
139141
});
140142
});
141143

142-
it('resolves arbitrum native asset via symbol fallback when chainlist omits slip44', () => {
144+
it('falls back to symbol lookup when chainlist omits slip44', () => {
145+
mockGetChainById.mockReturnValue({
146+
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
147+
} as ReturnType<typeof getChainById>);
148+
143149
expect(getNativeAsset('eip155:42161')).toStrictEqual({
144150
symbol: 'ETH',
145151
decimals: 18,
@@ -148,6 +154,11 @@ describe('caip helpers', () => {
148154
});
149155

150156
it('ignores chainlist testnet slip44:1 and uses the native symbol coin type', () => {
157+
mockGetChainById.mockReturnValue({
158+
slip44: 1,
159+
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
160+
} as ReturnType<typeof getChainById>);
161+
151162
expect(getNativeAsset('eip155:11155111')).toStrictEqual({
152163
symbol: 'ETH',
153164
decimals: 18,
@@ -156,21 +167,29 @@ describe('caip helpers', () => {
156167
});
157168

158169
it('prefers chainlist slip44 over the slip44 registry symbol mapping', () => {
170+
mockGetChainById.mockReturnValue({
171+
slip44: 9005,
172+
nativeCurrency: { name: 'Avalanche', symbol: 'AVAX', decimals: 18 },
173+
} as ReturnType<typeof getChainById>);
174+
159175
expect(getNativeAsset('eip155:43114')).toStrictEqual({
160176
symbol: 'AVAX',
161177
decimals: 18,
162178
assetId: 'eip155:43114/slip44:9005',
163179
});
164180
});
165181

166-
it('returns undefined for unknown chains', () => {
182+
it('returns undefined when the chain is unknown', () => {
183+
mockGetChainById.mockReturnValue(undefined);
184+
167185
expect(getNativeAsset('eip155:999999991')).toBeUndefined();
168186
});
169187

170188
it('returns undefined for non-eip155 chain ids', () => {
171189
expect(
172190
getNativeAsset('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp'),
173191
).toBeUndefined();
192+
expect(mockGetChainById).not.toHaveBeenCalled();
174193
});
175194

176195
it('returns undefined when chainlist omits native currency symbol', () => {
@@ -182,7 +201,28 @@ describe('caip helpers', () => {
182201
expect(getNativeAsset('eip155:1')).toBeUndefined();
183202
});
184203

204+
it('defaults decimals when chainlist omits native currency decimals', () => {
205+
mockGetChainById.mockReturnValue({
206+
slip44: 60,
207+
nativeCurrency: { name: 'Ether', symbol: 'ETH' },
208+
} as ReturnType<typeof getChainById>);
209+
210+
expect(getNativeAsset('eip155:1')).toStrictEqual({
211+
symbol: 'ETH',
212+
decimals: 18,
213+
assetId: 'eip155:1/slip44:60',
214+
});
215+
});
216+
185217
it('returns undefined when slip44 and symbol lookup both fail', () => {
218+
mockGetChainById.mockReturnValue({
219+
nativeCurrency: {
220+
name: 'Unknown',
221+
symbol: 'NOTACOIN',
222+
decimals: 18,
223+
},
224+
} as ReturnType<typeof getChainById>);
225+
186226
expect(getNativeAsset('eip155:1088')).toBeUndefined();
187227
});
188228
});

packages/client-utils/src/mappers/helpers/caip.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from '@metamask/utils';
1212
import { getChainById } from 'eth-chainlist';
1313

14-
import { nativeTokenAddress } from '../constants.js';
14+
import { nativeTokenAddress, nativeTokenDecimals } from '../constants.js';
1515

1616
const slip44BySymbol = ((): Map<string, string> => {
1717
const coinTypeBySymbol = new Map<string, string>();
@@ -26,7 +26,10 @@ const slip44BySymbol = ((): Map<string, string> => {
2626
}
2727
}
2828

29-
coinTypeBySymbol.set('POL', coinTypeBySymbol.get('MATIC') as string);
29+
const maticCoinType = coinTypeBySymbol.get('MATIC');
30+
if (maticCoinType && !coinTypeBySymbol.has('POL')) {
31+
coinTypeBySymbol.set('POL', maticCoinType);
32+
}
3033

3134
return coinTypeBySymbol;
3235
})();
@@ -91,6 +94,14 @@ export function resolveNativeAssetId(
9194
return toCaipAssetType(namespace, reference, 'slip44', assetReference);
9295
}
9396

97+
/**
98+
* Resolves EVM native symbol, decimals, and slip44 asset id for a chain.
99+
* Prefers eth-chainlist slip44 except testnet coin type 1, then falls back to
100+
* `@metamask/slip44` by native symbol.
101+
*
102+
* @param chainId - CAIP-2 chain id (eip155 only).
103+
* @returns Native asset metadata, or undefined when it cannot be resolved.
104+
*/
94105
export function getNativeAsset(chainId: CaipChainId):
95106
| {
96107
symbol: string;
@@ -125,7 +136,7 @@ export function getNativeAsset(chainId: CaipChainId):
125136

126137
return {
127138
symbol: nativeCurrency.symbol,
128-
decimals: nativeCurrency.decimals,
139+
decimals: nativeCurrency.decimals ?? nativeTokenDecimals,
129140
assetId: toCaipAssetType(namespace, reference, 'slip44', assetReference),
130141
};
131142
}

packages/client-utils/src/mappers/helpers/transactions.test.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ describe('transaction helpers', () => {
252252
});
253253

254254
describe('getLocalTransactionFees', () => {
255-
it('resolves native fee metadata from chain id when nativeAssetSymbol is omitted', () => {
255+
it('resolves fee assetId via ETH symbol when chainlist only has testnet slip44:1', () => {
256+
// 0x539 = Geth Testnet (1337); chainlist slip44 is 1, which we skip.
256257
expect(
257258
getLocalTransactionFees({
258259
primaryTransaction: {
@@ -326,6 +327,55 @@ describe('transaction helpers', () => {
326327
} as Parameters<typeof getLocalTransactionFees>[0]),
327328
).toBeUndefined();
328329
});
330+
331+
it('resolves fee assetId from nativeAssetSymbol when the chain is unknown', () => {
332+
expect(
333+
getLocalTransactionFees({
334+
nativeAssetSymbol: 'ETH',
335+
primaryTransaction: {
336+
chainId: '0x3b9ac9f7',
337+
txParams: {},
338+
txReceipt: {
339+
gasUsed: '0x1',
340+
effectiveGasPrice: '0x2',
341+
},
342+
},
343+
} as Parameters<typeof getLocalTransactionFees>[0]),
344+
).toStrictEqual([
345+
{
346+
type: 'base',
347+
amount: '2',
348+
decimals: 18,
349+
assetType: 'native',
350+
symbol: 'ETH',
351+
assetId: 'eip155:999999991/slip44:60',
352+
},
353+
]);
354+
});
355+
356+
it('keeps the nativeAssetSymbol on fees when it cannot be mapped to an assetId', () => {
357+
expect(
358+
getLocalTransactionFees({
359+
nativeAssetSymbol: 'NOTACOIN',
360+
primaryTransaction: {
361+
chainId: '0x3b9ac9f7',
362+
txParams: {},
363+
txReceipt: {
364+
gasUsed: '0x1',
365+
effectiveGasPrice: '0x2',
366+
},
367+
},
368+
} as Parameters<typeof getLocalTransactionFees>[0]),
369+
).toStrictEqual([
370+
{
371+
type: 'base',
372+
amount: '2',
373+
decimals: 18,
374+
assetType: 'native',
375+
symbol: 'NOTACOIN',
376+
},
377+
]);
378+
});
329379
});
330380

331381
describe('getFees', () => {
@@ -443,6 +493,22 @@ describe('transaction helpers', () => {
443493
},
444494
]);
445495
});
496+
497+
it('keeps wei decimals for fees when chainlist nativeCurrency.decimals is not 18', () => {
498+
expect(
499+
getFees({
500+
chainId: 4160,
501+
gasUsed: '21000',
502+
effectiveGasPrice: '1000000000',
503+
} as Parameters<typeof getFees>[0]),
504+
).toMatchObject({
505+
0: {
506+
amount: '21000000000000',
507+
decimals: 18,
508+
symbol: 'ALGO',
509+
},
510+
});
511+
});
446512
});
447513

448514
describe('getLocalTransactionStatus', () => {

packages/client-utils/src/mappers/helpers/transactions.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import type {
1818
TokenAmount,
1919
ValueTransfer,
2020
} from '../../types.js';
21+
import { nativeTokenDecimals } from '../constants.js';
2122
import {
2223
formatAddressToAssetId,
2324
formatChainIdToCaip,
@@ -36,8 +37,6 @@ export type TransactionGroup = {
3637
transactions: TransactionMeta[];
3738
};
3839

39-
const nativeTokenDecimals = 18;
40-
4140
function calculateNetworkFee(
4241
gasUsed: string | number | undefined,
4342
gasPrice: string | number | undefined,
@@ -64,16 +63,14 @@ function toNetworkFee(
6463
return {
6564
type: 'base',
6665
amount,
67-
decimals: nativeAsset.decimals,
66+
decimals: nativeTokenDecimals,
6867
assetType: 'native',
6968
symbol: symbol ?? nativeAsset.symbol,
7069
assetId: nativeAsset.assetId,
7170
};
7271
}
7372

74-
const assetId = symbol
75-
? resolveNativeAssetId(chainId, symbol)
76-
: undefined;
73+
const assetId = symbol ? resolveNativeAssetId(chainId, symbol) : undefined;
7774

7875
return {
7976
type: 'base',

packages/client-utils/test/fixtures/api-transactions.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,19 @@ const transactions = {
10441044
readable: 'Sent',
10451045
readableExtended: 'Sent',
10461046
},
1047+
mapsAStandardInboundWithoutTransfers: {
1048+
hash: '0xstandardinboundwithouttransfers',
1049+
timestamp: '2026-05-12T13:37:47.000Z',
1050+
chainId: 1,
1051+
from: '0x1111111111111111111111111111111111111111',
1052+
to: addresses.subjectAddress,
1053+
transactionCategory: 'STANDARD',
1054+
value: '1000000000000000000',
1055+
valueTransfers: [],
1056+
isError: false,
1057+
gasUsed: 21000,
1058+
effectiveGasPrice: '1',
1059+
},
10471060
mapsAnAcrossUsdtExchangeWithNativeFee: {
10481061
hash: '0x34bbaa01262f2e9221913316f4548a4b5981e05ee338ea586fc267a6868f9526',
10491062
timestamp: '2026-07-28T10:29:49.000Z',
@@ -1243,6 +1256,10 @@ const mapArgs = {
12431256
subjectAddress: addresses.subjectAddress,
12441257
transaction: transactions.mapsAZeroValueStandardSendWithoutTransfers,
12451258
},
1259+
mapsAStandardInboundWithoutTransfers: {
1260+
subjectAddress: addresses.subjectAddress,
1261+
transaction: transactions.mapsAStandardInboundWithoutTransfers,
1262+
},
12461263
mapsAnAcrossUsdtExchangeWithNativeFee: {
12471264
subjectAddress: addresses.subjectAddress,
12481265
transaction: transactions.mapsAnAcrossUsdtExchangeWithNativeFee,

types/eth-chainlist.d.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,11 @@ declare module 'eth-chainlist' {
55
networkId?: number;
66
slip44?: number;
77
nativeCurrency?: {
8-
name: string;
9-
symbol: string;
10-
decimals: number;
8+
name?: string;
9+
symbol?: string;
10+
decimals?: number;
1111
};
1212
};
1313

1414
export function getChainById(chainId: number): ChainListEntry | undefined;
15-
16-
export function getChainByNetworkId(
17-
networkId: number,
18-
): ChainListEntry | undefined;
19-
20-
export function getChainByName(name: string): ChainListEntry | undefined;
21-
22-
export function getChainByShortName(
23-
shortName: string,
24-
): ChainListEntry | undefined;
25-
26-
export function rawChainData(): ChainListEntry[];
2715
}

0 commit comments

Comments
 (0)