Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/client-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
3 changes: 2 additions & 1 deletion packages/client-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
239 changes: 204 additions & 35 deletions packages/client-utils/src/mappers/api-transaction-mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zero value sends

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');
});
});
40 changes: 31 additions & 9 deletions packages/client-utils/src/mappers/api-transaction-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -216,20 +215,43 @@ 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',
...common,
data: {
from: transfer?.from ?? from,
to: transfer?.to ?? transaction.to,
token: withFallbackTokenAssetId(
getToken(transfer, direction),
transaction.to,
transfer?.transferType,
chainId,
),
token,
fees: getFees(transaction),
},
};
Expand Down
2 changes: 2 additions & 0 deletions packages/client-utils/src/mappers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export const tokenTransferLogTopicHash =

export const nativeTokenAddress = '0x0000000000000000000000000000000000000000';

export const nativeTokenDecimals = 18;

export const swapsWrappedTokensAddresses = {
'0x1': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
'0x539': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2',
Expand Down
Loading