From 9649c4603d55913565eb09508769f7ad041a5ddb Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 2 Jul 2026 10:51:49 -0400 Subject: [PATCH 1/7] feat: add transaction activity mappers to `@metamask/client-utils` Add the transaction activity mappers and shared activity types on top of the `@metamask/client-utils` package scaffold: - `mapApiTransaction` for mapping EVM API transactions to activity items - `mapKeyringTransaction` for mapping keyring transactions to activity items - `mapLocalTransaction` for mapping local transaction groups to activity items - Shared activity types (`ActivityItem`, `ActivityKind`, `Status`, etc.) Also adds the runtime dependencies these mappers require and comprehensive unit tests with fixtures. Co-authored-by: Cursor --- README.md | 3 + packages/client-utils/CHANGELOG.md | 5 + packages/client-utils/package.json | 9 + packages/client-utils/src/index.test.ts | 9 - packages/client-utils/src/index.ts | 8 +- .../mappers/api-transaction-mapper.test.ts | 949 ++++++ .../src/mappers/api-transaction-mapper.ts | 388 +++ .../client-utils/src/mappers/constants.ts | 49 + .../src/mappers/helpers/caip.test.ts | 96 + .../client-utils/src/mappers/helpers/caip.ts | 141 + .../mappers/helpers/token-metadata.test.ts | 38 + .../src/mappers/helpers/token-metadata.ts | 49 + .../src/mappers/helpers/transactions.test.ts | 306 ++ .../src/mappers/helpers/transactions.ts | 402 +++ .../keyring-transaction-mapper.test.ts | 217 ++ .../src/mappers/keyring-transaction-mapper.ts | 189 ++ .../mappers/local-transaction-mapper.test.ts | 1107 +++++++ .../src/mappers/local-transaction-mapper.ts | 542 ++++ packages/client-utils/src/types.ts | 159 + .../test/fixtures/api-transactions.ts | 1162 +++++++ .../test/fixtures/keyring-transactions.ts | 293 ++ .../test/fixtures/local-transactions.ts | 2756 +++++++++++++++++ packages/client-utils/test/test-helpers.ts | 125 + yarn.lock | 7 + 24 files changed, 8997 insertions(+), 12 deletions(-) delete mode 100644 packages/client-utils/src/index.test.ts create mode 100644 packages/client-utils/src/mappers/api-transaction-mapper.test.ts create mode 100644 packages/client-utils/src/mappers/api-transaction-mapper.ts create mode 100644 packages/client-utils/src/mappers/constants.ts create mode 100644 packages/client-utils/src/mappers/helpers/caip.test.ts create mode 100644 packages/client-utils/src/mappers/helpers/caip.ts create mode 100644 packages/client-utils/src/mappers/helpers/token-metadata.test.ts create mode 100644 packages/client-utils/src/mappers/helpers/token-metadata.ts create mode 100644 packages/client-utils/src/mappers/helpers/transactions.test.ts create mode 100644 packages/client-utils/src/mappers/helpers/transactions.ts create mode 100644 packages/client-utils/src/mappers/keyring-transaction-mapper.test.ts create mode 100644 packages/client-utils/src/mappers/keyring-transaction-mapper.ts create mode 100644 packages/client-utils/src/mappers/local-transaction-mapper.test.ts create mode 100644 packages/client-utils/src/mappers/local-transaction-mapper.ts create mode 100644 packages/client-utils/src/types.ts create mode 100644 packages/client-utils/test/fixtures/api-transactions.ts create mode 100644 packages/client-utils/test/fixtures/keyring-transactions.ts create mode 100644 packages/client-utils/test/fixtures/local-transactions.ts create mode 100644 packages/client-utils/test/test-helpers.ts diff --git a/README.md b/README.md index 1b8c734bbb..ab0624698c 100644 --- a/README.md +++ b/README.md @@ -340,6 +340,9 @@ linkStyle default opacity:0.5 claims_controller --> profile_sync_controller; client_controller --> base_controller; client_controller --> messenger; + client_utils --> controller_utils; + client_utils --> core_backend; + client_utils --> transaction_controller; compliance_controller --> base_controller; compliance_controller --> controller_utils; compliance_controller --> messenger; diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index ac357048b8..fcf590480c 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -10,5 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release of the `@metamask/client-utils` package for functions and utilities shared across MetaMask clients (extension and mobile) ([#9375](https://github.com/MetaMask/core/pull/9375)) +- Add transaction activity mappers and shared activity types ([#9376](https://github.com/MetaMask/core/pull/9376)) + - `mapApiTransaction` for mapping EVM API transactions to activity items + - `mapKeyringTransaction` for mapping keyring transactions to activity items + - `mapLocalTransaction` for mapping local transaction groups to activity items + - Shared activity types (`ActivityItem`, `ActivityKind`, `Status`, etc.) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/client-utils/package.json b/packages/client-utils/package.json index b020dd5b3a..62e22bed77 100644 --- a/packages/client-utils/package.json +++ b/packages/client-utils/package.json @@ -52,7 +52,16 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "dependencies": { + "@metamask/contract-metadata": "^2.4.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/core-backend": "^6.5.0", + "@metamask/keyring-api": "^23.3.0", + "@metamask/transaction-controller": "^68.2.2", + "@metamask/utils": "^11.11.0" + }, "devDependencies": { + "@ethersproject/abi": "^5.7.0", "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^29.5.14", diff --git a/packages/client-utils/src/index.test.ts b/packages/client-utils/src/index.test.ts deleted file mode 100644 index bc062d3694..0000000000 --- a/packages/client-utils/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from '.'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/client-utils/src/index.ts b/packages/client-utils/src/index.ts index 2394a09105..fb31f08249 100644 --- a/packages/client-utils/src/index.ts +++ b/packages/client-utils/src/index.ts @@ -1,3 +1,5 @@ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export { mapApiTransaction } from './mappers/api-transaction-mapper'; +export { mapKeyringTransaction } from './mappers/keyring-transaction-mapper'; +export { mapLocalTransaction } from './mappers/local-transaction-mapper'; + +export type * from './types'; diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.test.ts b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts new file mode 100644 index 0000000000..47a7e186fb --- /dev/null +++ b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts @@ -0,0 +1,949 @@ +import { apiTransactionFixtures } from '../../test/fixtures/api-transactions'; +import { mapApiTransaction } from './api-transaction-mapper'; +import { formatAddressToAssetId } from './helpers/caip'; + +// Mock known-token lookup with the deterministic test table in `test/`. +jest.mock('./helpers/token-metadata', () => ({ + getKnownTokenMetadata: jest.requireActual('../../test/test-helpers') + .getKnownTokenMetadata, +})); + +const { + subjectAddress, + baseUsdc, + mainnetUsdc, + baseAaveUsdc, + baseRecipientAddress, + lineaMusd, + lineaSenderAddress, + bscContractCallerAddress, + bscUniversalRouter, + polygonRecipientAddress, + wethContractAddress, + zeroAddress, + nftRecipientAddress, + nftBuyerAddress, + nftSellerAddress, +} = apiTransactionFixtures.addresses; + +describe('mapApiTransaction', () => { + it('maps an ERC-20 transfer sent by the account to a Send activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnErc20TransferSent, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1778593067000, + data: { + from: subjectAddress, + to: baseRecipientAddress, + token: { + direction: 'out', + symbol: 'USDC', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps an ERC-20 transfer with an incidental receive transfer to a Send activity', () => { + const { transaction } = + apiTransactionFixtures.mapArgs.mapsAnErc20TransferWith; + const aaveLineaUsdc = transaction.to; + const senderAddress = transaction.from; + const recipientAddress = transaction.valueTransfers?.[1]?.to; + + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnErc20TransferWith, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778074371000, + hash: transaction.hash, + data: { + from: senderAddress, + to: recipientAddress, + token: { + direction: 'out', + amount: '419402', + decimals: 6, + symbol: 'aLinUSDC', + assetId: formatAddressToAssetId(aaveLineaUsdc, 'eip155:59144'), + }, + }, + }); + }); + + it('maps a native value contract call without method data to a Send activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsANativeValueContractCall, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:137', + status: 'success', + timestamp: 1779218832000, + hash: '0x64d2f26c261178252fcad9dbb665cf40337b827a582066553dd6634eaeea9f0a', + data: { + from: subjectAddress, + to: polygonRecipientAddress, + token: { + amount: '100000000000000000', + assetId: 'eip155:137/slip44:966', + decimals: 18, + direction: 'out', + symbol: 'MATIC', + }, + }, + }); + }); + + it('maps an approval without value transfers to an Approve spending cap activity with token metadata', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnApprovalWithoutValueTransfers, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779888027000, + hash: '0x91f89897197afcc09ad98ec4282366fd7938d8a9609e4fc2a0aa2d070664bc27', + data: { + token: { + direction: 'out', + symbol: 'USDC', + decimals: 6, + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('falls back to value transfer contract address when approval to is invalid', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.fallsBackToValueTransferContract, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + chainId: 'eip155:59144', + data: { + token: { + direction: 'out', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + + it('maps an approval with neither a valid to nor a contract transfer to an assetId-less spending cap', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnApprovalWithNeitherA, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + chainId: 'eip155:59144', + data: { + token: undefined, + }, + }); + }); + + it('maps an ERC-20 transfer received by the account to a Receive activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnErc20TransferReceived, + ); + + expect(item).toMatchObject({ + type: 'receive', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1777983327000, + data: { + from: lineaSenderAddress, + to: subjectAddress, + token: { + direction: 'in', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + + it('maps an exchange transaction without a received token to a Swap activity with no destination', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnExchangeTransactionWithoutA, + ); + + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778003873000, + data: { + sourceToken: { + direction: 'out', + symbol: 'mUSD', + }, + }, + }); + }); + + it('maps an exchange transaction with an internal ETH receive transfer to a Swap activity with native destination assetId', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnExchangeTransactionWithAn, + ); + + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1779930229000, + hash: '0x80b974d5834e1047a78332369de3d4b988f0237ff8a418c9464217e55c542f2f', + data: { + sourceToken: { + amount: '10000', + decimals: 6, + direction: 'out', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + symbol: 'mUSD', + }, + destinationToken: { + amount: '4894004361763', + decimals: 18, + direction: 'in', + assetId: formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + 'eip155:59144', + ), + symbol: 'ETH', + }, + }, + }); + }); + + it('maps the LiFi Linea USDC to ETH exchange to a Swap activity', () => { + const lineaUsdc = '0x176211869ca2b568f2a7d4ee941e073a821ee1ff'; + + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsTheLifiLineaUsdcTo, + ); + + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:59144', + status: 'success', + timestamp: new Date('2026-01-16T21:09:00.000Z').getTime(), + hash: '0x3ac43e7c4a1a4421304ada43b41acec4d71ad90abfa418e97e92540a26eef0a2', + data: { + sourceToken: { + amount: '7934205', + decimals: 6, + direction: 'out', + assetId: formatAddressToAssetId(lineaUsdc, 'eip155:59144'), + symbol: 'USDC', + }, + destinationToken: { + amount: '2388594176642019', + decimals: 18, + direction: 'in', + assetId: formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + 'eip155:59144', + ), + symbol: 'ETH', + }, + }, + }); + }); + + it('maps an NFT sale with received native ETH to a Sell activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftSaleWithReceived, + ); + + expect(item).toMatchObject({ + type: 'nftSell', + chainId: 'eip155:1', + status: 'success', + timestamp: 1771884263000, + data: { + from: subjectAddress, + to: nftRecipientAddress, + token: { + direction: 'out', + symbol: 'BAE', + }, + paymentToken: { + direction: 'in', + symbol: 'ETH', + }, + }, + }); + }); + + it('maps an OpenSea NFT sale paid in WETH to a Sell activity', () => { + const sellerAddress = apiTransactionFixtures.addresses.openseaSellerAddress; + + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnOpenseaNftSalePaid, + ); + + expect(item).toMatchObject({ + type: 'nftSell', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1768427429000, + hash: '0x0e7f29fa4af73f3708a7383a2fa8d0e09f6c6bf8a176bccf3a6b3259e2886bae', + data: { + from: sellerAddress, + to: '0xbaf3ad6542f932cc0e0b54983e82e0cfb7c5a5a1', + token: { + direction: 'out', + // name takes precedence over symbol for NFTs + symbol: 'The Warplets', + }, + paymentToken: { + direction: 'in', + symbol: 'WETH', + }, + }, + }); + }); + + it('maps a plain NFT send with no payment to a Send activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAPlainNftSendWith, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1', + data: { + token: { + direction: 'out', + symbol: 'BAE', + }, + }, + }); + }); + + it('maps an NFT purchase', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftPurchase, + ); + + expect(item).toMatchObject({ + type: 'nftBuy', + chainId: 'eip155:1', + status: 'success', + timestamp: 1780601507000, + hash: '0x8719dadd883779624845106e61fd94af234411c30d73184a72f4daf1425c4595', + data: { + from: '0x107b2e855528f344556f8c766a6187326a2c2fa6', + to: nftBuyerAddress, + token: { + direction: 'in', + symbol: 'FLUF World: Scenes and Sounds', + }, + paymentToken: { + direction: 'out', + symbol: 'ETH', + }, + }, + }); + }); + + it('maps an NFT purchase paid in WETH (ERC-20) to an nftBuy activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftPurchasePaidIn, + ); + + expect(item).toMatchObject({ + type: 'nftBuy', + data: { + token: { + direction: 'in', + symbol: 'FLUF World: Scenes and Sounds', + }, + paymentToken: { + direction: 'out', + symbol: 'WETH', + }, + }, + }); + }); + + it('maps an NFT transfer received without payment to a Receive activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftTransferReceivedWithout, + ); + + expect(item).toMatchObject({ + type: 'receive', + data: { + from: nftSellerAddress, + to: subjectAddress, + token: { direction: 'in', symbol: 'FLUF World' }, + }, + }); + }); + + it('maps a plain NFT send (no NFT exchange, no payment) to a Send activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAPlainNftSendNo, + ); + + expect(item).toMatchObject({ + type: 'send', + data: { + from: subjectAddress, + to: nftRecipientAddress, + token: { direction: 'out', symbol: 'BAE' }, + }, + }); + }); + + it('maps an NFT mint transfer to an nftMint activity without assetId', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftMintTransferTo, + ); + + expect(item).toMatchObject({ + type: 'nftMint', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778682863000, + hash: '0x25805d4ae16935e6fa92add9dcee97db0127749d4244032a79489098a880210c', + data: { + from: zeroAddress, + to: subjectAddress, + token: { + direction: 'in', + symbol: 'TDN', + }, + }, + }); + }); + + it('maps an Aave supply contract call to a Lending deposit activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnAaveSupplyContractCall, + ); + + expect(item).toMatchObject({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1778643089000, + hash: '0x08d14578168f22001e95503469c63613bd9f3d3f60e81dbbf204fbd21f484bd9', + data: { + sourceToken: { + amount: '100000', + decimals: 6, + direction: 'out', + symbol: 'USDC', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + destinationToken: { + amount: '99999', + decimals: 6, + direction: 'in', + symbol: 'aBasUSDC', + assetId: formatAddressToAssetId(baseAaveUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps an Aave withdraw with a known method id to a Lending withdrawal activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnAaveWithdrawWithA, + ); + + expect(item).toMatchObject({ + type: 'lendingWithdrawal', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779893234000, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + data: { + sourceToken: { + amount: '100000', + decimals: 6, + direction: 'out', + symbol: 'aBasUSDC', + assetId: formatAddressToAssetId(baseAaveUsdc, 'eip155:8453'), + }, + destinationToken: { + amount: '200000', + decimals: 6, + direction: 'in', + symbol: 'USDC', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps a DEPOSIT without an inbound transfer to a deposit activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsADepositWithoutAnInbound, + ); + + expect(item).toMatchObject({ + type: 'deposit', + chainId: 'eip155:1', + status: 'success', + timestamp: 1778593067000, + hash: '0xabc123deposit00000000000000000000000000000000000000000000000001', + data: { + token: { + amount: '1000000000000000000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + assetId: formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + 'eip155:1', + ), + }, + }, + }); + }); + + // Captures the real Lido stETH stake response. NOTE: the backend returns this + // as `CONTRACT_CALL` with the Lido submit method id (part of `supplyMethodIds`), + // so `mapApiTransaction` currently returns `lendingDeposit`. `mapLocalTransaction` + // maps the same stake (TransactionType.stakingDeposit) to `deposit`, so the two + // mappers disagree on this transaction. + it('maps a real Lido stake (CONTRACT_CALL + supply method id) to a lendingDeposit activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsALidoStakeToA, + ); + + expect(item).toMatchObject({ + type: 'lendingDeposit', + chainId: 'eip155:1', + status: 'success', + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + data: { + from: subjectAddress, + sourceToken: { + direction: 'out', + symbol: 'ETH', + amount: '1000000000000', + }, + destinationToken: { + direction: 'in', + symbol: 'stETH', + amount: '999999999999', + }, + }, + }); + }); + + it('maps a WETH deposit to a Wrap activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAWethDepositToA, + ); + + expect(item).toMatchObject({ + type: 'wrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779975743000, + hash: '0x6e448f5b8cf55534507770c1cb90ba14e723d03b4a46b4919a5847eb8d13b7b5', + data: { + sourceToken: { + amount: '1000000000000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + assetId: formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + 'eip155:1', + ), + }, + destinationToken: { + amount: '1000000000000', + decimals: 18, + direction: 'in', + symbol: 'WETH', + assetId: formatAddressToAssetId(wethContractAddress, 'eip155:1'), + }, + }, + }); + }); + + it('maps an Aave supply contract call with an uppercase method id to a Lending deposit activity', () => { + const { transaction, ...rest } = + apiTransactionFixtures.mapArgs.mapsAnAaveSupplyContractCall; + const item = mapApiTransaction({ + ...rest, + transaction: { + ...transaction, + methodId: transaction.methodId?.toUpperCase(), + }, + }); + + expect(item.type).toBe('lendingDeposit'); + }); + + it('maps an Aave withdraw with an uppercase method id to a Lending withdrawal activity', () => { + const { transaction, ...rest } = + apiTransactionFixtures.mapArgs.mapsAnAaveWithdrawWithA; + const item = mapApiTransaction({ + ...rest, + transaction: { + ...transaction, + methodId: transaction.methodId?.toUpperCase(), + }, + }); + + expect(item.type).toBe('lendingWithdrawal'); + }); + + it('maps a WETH deposit with an uppercase method id to a Wrap activity', () => { + const { transaction, ...rest } = + apiTransactionFixtures.mapArgs.mapsAWethDepositToA; + const item = mapApiTransaction({ + ...rest, + transaction: { + ...transaction, + methodId: transaction.methodId?.toUpperCase(), + }, + }); + + expect(item.type).toBe('wrap'); + }); + + it('maps a WETH withdrawal to an Unwrap activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAWethWithdrawalToAn, + ); + + expect(item).toMatchObject({ + type: 'unwrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779977700000, + hash: '0x8f2a1c9e4b7d30651234567890abcdef1234567890abcdef1234567890abcdef', + data: { + sourceToken: { + amount: '1000000000000', + decimals: 18, + direction: 'out', + symbol: 'WETH', + assetId: formatAddressToAssetId(wethContractAddress, 'eip155:1'), + }, + destinationToken: { + amount: '1000000000000', + decimals: 18, + direction: 'in', + symbol: 'ETH', + assetId: formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + 'eip155:1', + ), + }, + }, + }); + }); + + it('maps a MetaMask mUSD bonus claim to a Claim mUSD bonus activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAMetamaskMusdBonusClaim, + ); + + expect(item).toMatchObject({ + type: 'claimMusdBonus', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1778633325000, + hash: '0x875ded271a40278391fca5d71892231afd0cb9592f31bdf3b7c949906cb982c4', + data: { + from: subjectAddress, + token: { + direction: 'in', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + + it('maps a generic CLAIM with a received token to a claim activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAGenericClaimWithA, + ); + + expect(item).toMatchObject({ + type: 'claim', + data: { + from: subjectAddress, + token: { direction: 'in', symbol: 'mUSD', amount: '5' }, + }, + }); + }); + + it('maps a generic CLAIM with only a sent token to an outbound claim activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAGenericClaimWithOnly, + ); + + expect(item).toMatchObject({ + type: 'claim', + data: { token: { direction: 'out', symbol: 'mUSD' } }, + }); + }); + + it('maps a bridge withdraw to a Bridge activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsABridgeWithdrawToA, + ); + + expect(item).toMatchObject({ + type: 'bridge', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779941611000, + hash: '0x9f81163d00374094411f44732738c6dea194551e4500bde9fd7ee60319aac766', + data: { + fees: [ + { + amount: String(BigInt('0x24405') * BigInt('0x6fc23ac1d')), + assetId: formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + 'eip155:8453', + ), + decimals: 18, + symbol: 'ETH', + type: 'base', + }, + ], + sourceToken: { + amount: '100000', + decimals: 6, + direction: 'out', + symbol: 'USDC', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + + it('maps an unrecognized transaction category to a contract interaction activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnUnrecognizedTransactionCategoryTo, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + chainId: 'eip155:56', + status: 'success', + timestamp: 1778601880000, + data: { + from: bscContractCallerAddress, + methodId: '0x174dea71', + to: bscUniversalRouter, + transactionCategory: 'CONTRACT_CALL', + transactionProtocol: 'GENERIC', + }, + }); + }); + + it('maps the reported generic contract call to a contract interaction with its token amount', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsTheReportedGenericContractCall, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + chainId: 'eip155:1', + status: 'success', + timestamp: 1777642787000, + hash: '0xd206cc6c16974409bae072ce4cd1559743041af40c2bae84775a0bbb4dff5fee', + data: { + from: subjectAddress, + methodId: '0xe9ae5c53', + to: subjectAddress, + transactionCategory: 'CONTRACT_CALL', + transactionProtocol: undefined, + token: { + amount: '580060', + assetId: formatAddressToAssetId(mainnetUsdc, 'eip155:1'), + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a contract call CONTRACT_CALL swap (differing symbols) to a Swap activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAContractCallContractCall, + ); + + expect(item).toMatchObject({ + type: 'swap', + data: { + sourceToken: { direction: 'out', symbol: 'USDC' }, + destinationToken: { direction: 'in', symbol: 'DAI' }, + }, + }); + }); + + it('maps a failed transaction to a failed activity item', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAFailedTransactionToA, + ); + + expect(item.status).toBe('failed'); + }); + + it('maps a Standard transaction on a chain outside the swaps registry without throwing', () => { + expect(() => + mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAStandardTransactionOnA, + ), + ).not.toThrow(); + + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAStandardTransactionOnA, + ); + + expect(item.type).toBe('send'); + expect(item.chainId).toBe('eip155:4657'); + }); + + it('maps a Standard transaction with a native asset to a send with native token', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAStandardTransactionWithA, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1', + data: { + token: { + amount: '1000000000000000000', + symbol: 'ETH', + direction: 'out', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + + it('maps an APPROVE with only an inbound transfer (revoke) to an inbound spending cap', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnApproveWithOnlyAn, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'in', + assetId: formatAddressToAssetId(mainnetUsdc, 'eip155:1'), + }, + }, + }); + }); + + it('maps an APPROVE for a known token to a spending cap with token metadata', () => { + const mainnetUsdt = '0xdac17f958d2ee523a2206206994597c13d831ec7'; + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnApproveForAKnown, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + symbol: 'USDT', + decimals: 6, + assetId: formatAddressToAssetId(mainnetUsdt, 'eip155:1'), + }, + }, + }); + }); + + it('maps a Standard inbound native transfer (no value transfers) to a Receive activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAStandardInboundNativeTransfer, + ); + + expect(item).toMatchObject({ + type: 'receive', + chainId: 'eip155:1', + data: { + token: { + amount: '1000000000000000000', + symbol: 'ETH', + direction: 'in', + assetId: 'eip155:1/slip44:60', + }, + }, + }); + }); + + it('does not map a withdraw without a known method id to a lending withdrawal', () => { + const { transaction, subjectAddress: fixtureSubjectAddress } = + apiTransactionFixtures.mapArgs.mapsAnAaveWithdrawWithA; + const item = mapApiTransaction({ + subjectAddress: fixtureSubjectAddress, + transaction: { + ...transaction, + methodId: undefined, + }, + }); + + expect(item.type).not.toBe('lendingWithdrawal'); + }); + + it('does not map a deposit without a wrap method id to a wrap activity', () => { + const { transaction, subjectAddress: fixtureSubjectAddress } = + apiTransactionFixtures.mapArgs.mapsAWethDepositToA; + const item = mapApiTransaction({ + subjectAddress: fixtureSubjectAddress, + transaction: { + ...transaction, + methodId: undefined, + }, + }); + + expect(item.type).not.toBe('wrap'); + }); + + it('maps an unrecognized category with only an inbound transfer to a contract interaction with an inbound token', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnUnrecognizedCategoryWithOnly, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + data: { + token: { + direction: 'in', + amount: '12345', + symbol: 'USDC', + }, + }, + }); + }); +}); diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.ts b/packages/client-utils/src/mappers/api-transaction-mapper.ts new file mode 100644 index 0000000000..5d60a768d2 --- /dev/null +++ b/packages/client-utils/src/mappers/api-transaction-mapper.ts @@ -0,0 +1,388 @@ +import { + isEqualCaseInsensitive as equalsIgnoreCase, + isValidHexAddress, +} from '@metamask/controller-utils'; +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils'; + +import type { + ActivityItem, + Status, + TokenAmount, + ValueTransfer, +} from '../types'; +import { + nativeTokenAddress, + supplyMethodIds, + withdrawMethodIds, + wrapMethodIds, +} from './constants'; +import { formatAddressToAssetId, getNativeAsset } from './helpers/caip'; +import { + getFees, + getNftPaymentTransfer, + getTokenAmountFromTransfer, + getTokenMetadataFromKnownToken, + parseValueTransfers, + withFallbackTokenAssetId, +} from './helpers/transactions'; + +/** + * Maps an indexed API transaction into the shared activity item shape. + * + * @param options - The mapping options. + * @param options.transaction - The indexed API transaction to map. + * @param options.subjectAddress - The account the activity is being mapped for. + * @returns The normalized activity item. + */ +export function mapApiTransaction({ + transaction, + subjectAddress, +}: { + transaction: V1TransactionByHashResponse; + subjectAddress: string; +}): ActivityItem { + const { hash, transactionCategory, valueTransfers, from, methodId } = + transaction; + const normalizedMethodId = methodId?.toLowerCase() ?? ''; + const status: Status = transaction.isError ? 'failed' : 'success'; + const timestamp = new Date(transaction.timestamp).getTime(); + const chainId = toCaipChainId( + KnownCaipNamespace.Eip155, + transaction.chainId.toString(), + ); + const getToken = ( + transfer: ValueTransfer | undefined, + direction: TokenAmount['direction'], + ): TokenAmount | undefined => + getTokenAmountFromTransfer(transfer, direction, chainId); + + const { + sentTransfer, + receivedTransfer, + sentNativeTransfer, + sentNftTransfer, + receivedNftTransfer, + } = parseValueTransfers(valueTransfers, subjectAddress); + + const common = { chainId, status, timestamp, hash }; + + if (transactionCategory === 'SWAP' || transactionCategory === 'EXCHANGE') { + return { + type: 'swap', + ...common, + data: { + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction, chainId), + from, + }, + }; + } + + if (transactionCategory === 'APPROVE') { + // Note: Categorize REVOKE in the backend + const direction = receivedTransfer && !sentTransfer ? 'in' : 'out'; + const valueTransferContractAddress = valueTransfers?.find( + ({ contractAddress, transferType }) => + contractAddress && + transferType !== 'normal' && + transferType !== 'internal', + )?.contractAddress; + const contractAddress = + (isValidHexAddress(transaction.to, { allowNonPrefixed: false }) + ? transaction.to + : undefined) ?? + (valueTransferContractAddress && + isValidHexAddress(valueTransferContractAddress, { + allowNonPrefixed: false, + }) + ? valueTransferContractAddress + : undefined); + const assetId = contractAddress + ? formatAddressToAssetId(contractAddress, chainId) + : undefined; + const token = + getTokenMetadataFromKnownToken(contractAddress, direction, chainId) ?? + (assetId ? { direction, assetId } : undefined); + + return { + type: 'approveSpendingCap', + ...common, + data: { + from, + token, + fees: getFees(transaction, chainId), + }, + }; + } + + // Note: Categorize NFT in the backend + if (sentNftTransfer || receivedNftTransfer) { + const isNftExchange = transactionCategory === 'NFT_EXCHANGE'; + + if (receivedNftTransfer) { + if (receivedNftTransfer.from === nativeTokenAddress) { + return { + type: 'nftMint', + ...common, + data: { + from: receivedNftTransfer.from, + to: receivedNftTransfer.to, + token: getToken(receivedNftTransfer, 'in'), + }, + }; + } + + const purchasePaymentTransfer = getNftPaymentTransfer({ + side: 'buy', + sentTransfer, + sentNativeTransfer, + nftCounterparty: receivedNftTransfer.from, + subjectAddress, + }); + + if (isNftExchange || purchasePaymentTransfer) { + return { + type: 'nftBuy', + ...common, + data: { + from: receivedNftTransfer.from, + to: receivedNftTransfer.to, + token: getToken(receivedNftTransfer, 'in'), + paymentToken: getToken(purchasePaymentTransfer, 'out'), + }, + }; + } + + return { + type: 'receive', + ...common, + data: { + from: receivedNftTransfer.from, + to: receivedNftTransfer.to, + token: getToken(receivedNftTransfer, 'in'), + }, + }; + } + + if (sentNftTransfer) { + const saleProceedsTransfer = getNftPaymentTransfer({ + side: 'sell', + receivedTransfer, + nftCounterparty: sentNftTransfer.to, + transactionFrom: from, + subjectAddress, + }); + + if (isNftExchange || saleProceedsTransfer) { + return { + type: 'nftSell', + ...common, + data: { + from: sentNftTransfer.from, + to: sentNftTransfer.to, + token: getToken(sentNftTransfer, 'out'), + paymentToken: getToken(saleProceedsTransfer, 'in'), + }, + }; + } + + return { + type: 'send', + ...common, + data: { + from: sentNftTransfer.from, + to: sentNftTransfer.to, + token: getToken(sentNftTransfer, 'out'), + }, + }; + } + } + + const hasNativeTransferWithoutMethod = + transactionCategory === 'CONTRACT_CALL' && + !methodId && + valueTransfers?.some(({ transferType }) => transferType === 'normal'); + + if ( + transactionCategory === 'TRANSFER' || + transactionCategory === 'STANDARD' || + hasNativeTransferWithoutMethod + ) { + const isReceive = + Boolean(receivedTransfer && !sentTransfer) || + (equalsIgnoreCase(transaction.to, subjectAddress) && + !equalsIgnoreCase(from, subjectAddress)); + + const transfer = isReceive ? receivedTransfer : sentTransfer; + const direction = isReceive ? 'in' : 'out'; + const nativeAsset = getNativeAsset(chainId); + const nativeToken = + transactionCategory === 'STANDARD' && nativeAsset + ? ({ + ...nativeAsset, + amount: transaction.value, + direction, + } as TokenAmount) + : undefined; + + 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, + ) ?? nativeToken, + fees: getFees(transaction, chainId), + }, + }; + } + + if (transactionCategory === 'CLAIM_BONUS') { + return { + type: 'claimMusdBonus', + ...common, + data: { + from, + token: getToken(receivedTransfer, 'in'), + }, + }; + } + + if (transactionCategory === 'CLAIM') { + return { + type: 'claim', + ...common, + data: { + from, + token: getToken( + receivedTransfer ?? sentTransfer, + receivedTransfer ? 'in' : 'out', + ), + }, + }; + } + + if (transactionCategory === 'BRIDGE_WITHDRAW') { + return { + type: 'bridge', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + fees: getFees(transaction, chainId), + }, + }; + } + + if ( + transactionCategory === 'WITHDRAW' && + withdrawMethodIds.has(normalizedMethodId) + ) { + return { + type: 'lendingWithdrawal', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction, chainId), + }, + }; + } + + // Note: Categorize Deposit/Stake in the backend + if (sentTransfer && supplyMethodIds.has(normalizedMethodId)) { + return { + type: 'lendingDeposit', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction, chainId), + }, + }; + } + + if (receivedTransfer && wrapMethodIds.has(normalizedMethodId)) { + return { + type: 'wrap', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction, chainId), + }, + }; + } + + if (transactionCategory === 'UNWRAP') { + return { + type: 'unwrap', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction, chainId), + }, + }; + } + + // Note: Categorize these Swaps in the backend + if ( + transactionCategory === 'CONTRACT_CALL' && + sentTransfer?.symbol && + receivedTransfer?.symbol && + sentTransfer.symbol !== receivedTransfer.symbol + ) { + return { + type: 'swap', + ...common, + data: { + from, + sourceToken: getToken(sentTransfer, 'out'), + destinationToken: getToken(receivedTransfer, 'in'), + fees: getFees(transaction, chainId), + }, + }; + } + + if (transactionCategory === 'DEPOSIT') { + return { + type: 'deposit', + ...common, + data: { + from, + token: getToken(sentTransfer, 'out'), + }, + }; + } + + const token = getToken( + sentTransfer ?? receivedTransfer, + sentTransfer ? 'out' : 'in', + ); + + return { + type: 'contractInteraction', + ...common, + data: { + methodId, + from, + to: transaction.to, + transactionCategory, + transactionProtocol: transaction.transactionProtocol, + ...(token?.amount ? { token } : {}), + }, + }; +} diff --git a/packages/client-utils/src/mappers/constants.ts b/packages/client-utils/src/mappers/constants.ts new file mode 100644 index 0000000000..3124319cbd --- /dev/null +++ b/packages/client-utils/src/mappers/constants.ts @@ -0,0 +1,49 @@ +// Known method IDs for supply/deposit calls +const aaveSupplyMethodId = '0x617ba037'; +const lidoSubmitMethodId = '0xa1903eab'; +const lidoDepositMethodId = '0x8a99b4f2'; // MM staking contract Lido deposit +const rocketPoolDepositMethodId = '0xfa4bbb71'; // MM staking contract RP deposit + +export const supplyMethodIds = new Set([ + aaveSupplyMethodId, + lidoSubmitMethodId, + lidoDepositMethodId, + rocketPoolDepositMethodId, +]); + +// Known method IDs for withdraw calls +const aaveWithdrawMethodId = '0x69328dec'; +const lidoClaimWithdrawMethodId = '0xf8444436'; +const rocketPoolBurnMethodId = '0x42966c68'; + +export const withdrawMethodIds = new Set([ + aaveWithdrawMethodId, + lidoClaimWithdrawMethodId, + rocketPoolBurnMethodId, +]); + +export const wrapMethodIds = new Set(['0xd0e30db0']); +export const unwrapMethodIds = new Set(['0x2e1a7d4d']); + +export const tokenTransferLogTopicHash = + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +export const nativeTokenAddress = '0x0000000000000000000000000000000000000000'; + +export const swapsWrappedTokensAddresses = { + '0x1': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + '0x539': '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + '0x38': '0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c', + '0x89': '0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270', + '0x5': '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6', + '0xa86a': '0xb31f66aa3c1e785363f0875a1b74e27b85fd66c7', + '0xa': '0x4200000000000000000000000000000000000006', + '0xa4b1': '0x82af49447d8a07e3bd95bd0d56f35241523fbab1', + '0x144': '0x5AEa5775959fBC2557Cc8789bC1bf90A239D9a91', + '0xe708': '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f', + '0x2105': '0x4200000000000000000000000000000000000006', + '0x531': '0xe30fedd158a2e3b13e9badaeabafc5516e95e8c7', + '0x8f': '0x3bd359c1119da7da1d913d1c4d2b7c461115433a', + '0x3e7': '0x5555555555555555555555555555555555555555', + '0x10e6': '0x4200000000000000000000000000000000000006', +} as const; diff --git a/packages/client-utils/src/mappers/helpers/caip.test.ts b/packages/client-utils/src/mappers/helpers/caip.test.ts new file mode 100644 index 0000000000..2020951070 --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/caip.test.ts @@ -0,0 +1,96 @@ +import { + formatAddressToAssetId, + formatChainIdToCaip, + getNativeAsset, +} from './caip'; + +describe('caip helpers', () => { + describe('formatChainIdToCaip', () => { + it('formats numeric chain ids', () => { + expect(formatChainIdToCaip(1)).toBe('eip155:1'); + }); + + it('returns caip chain ids unchanged', () => { + expect(formatChainIdToCaip('eip155:8453')).toBe('eip155:8453'); + }); + + it('formats hex chain ids', () => { + expect(formatChainIdToCaip('0x1')).toBe('eip155:1'); + }); + + it('returns undefined for invalid hex chain ids', () => { + expect(formatChainIdToCaip('0xzzzz')).toBeUndefined(); + }); + + it('formats decimal string chain ids', () => { + expect(formatChainIdToCaip('8453')).toBe('eip155:8453'); + }); + + it('returns undefined for invalid decimal chain ids', () => { + expect(formatChainIdToCaip('not-a-number')).toBeUndefined(); + }); + }); + + describe('getNativeAsset', () => { + it('returns native asset metadata for supported chains', () => { + expect(getNativeAsset('0x1')).toStrictEqual({ + symbol: 'ETH', + decimals: 18, + assetId: 'eip155:1/slip44:60', + }); + }); + + it('returns undefined when the chain id cannot be normalized', () => { + expect(getNativeAsset('0xzzzz')).toBeUndefined(); + }); + }); + + describe('formatAddressToAssetId', () => { + it('returns caip asset ids unchanged', () => { + expect( + formatAddressToAssetId( + 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ), + ).toBe('eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); + }); + + it('encodes erc20 contract addresses', () => { + expect( + formatAddressToAssetId( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + 'eip155:1', + ), + ).toBe('eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); + }); + + it('encodes native token addresses on supported chains', () => { + expect( + formatAddressToAssetId( + '0x0000000000000000000000000000000000000000', + 'eip155:1', + ), + ).toBe('eip155:1/slip44:60'); + }); + + it('returns undefined when chain id is omitted', () => { + expect( + formatAddressToAssetId('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'), + ).toBeUndefined(); + }); + + it('returns undefined for invalid addresses', () => { + expect( + formatAddressToAssetId('not-an-address', 'eip155:1'), + ).toBeUndefined(); + }); + + it('returns undefined when the chain id cannot be normalized', () => { + expect( + formatAddressToAssetId( + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + '0xzzzz', + ), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/client-utils/src/mappers/helpers/caip.ts b/packages/client-utils/src/mappers/helpers/caip.ts new file mode 100644 index 0000000000..1f8a1c6d3e --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/caip.ts @@ -0,0 +1,141 @@ +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; +import { + isCaipAssetType, + isStrictHexString, + parseCaipChainId, + toCaipAssetType, +} from '@metamask/utils'; + +import { nativeTokenAddress } from '../constants'; + +export type NativeAssetMetadata = { + symbol: string; + decimals: number; + assetId: CaipChainId | string; +}; + +type NativeAssetEntry = { + symbol: string; + decimals: number; + slip44: number; +}; + +const nativeAssetsByCaipChainId: Record = { + 'eip155:1': { symbol: 'ETH', decimals: 18, slip44: 60 }, + 'eip155:10': { symbol: 'ETH', decimals: 18, slip44: 60 }, + 'eip155:56': { symbol: 'BNB', decimals: 18, slip44: 714 }, + 'eip155:137': { symbol: 'POL', decimals: 18, slip44: 966 }, + 'eip155:324': { symbol: 'ETH', decimals: 18, slip44: 60 }, + 'eip155:1329': { symbol: 'SEI', decimals: 18, slip44: 19000118 }, + 'eip155:8453': { symbol: 'ETH', decimals: 18, slip44: 60 }, + 'eip155:42161': { symbol: 'ETH', decimals: 18, slip44: 60 }, + 'eip155:43114': { symbol: 'AVAX', decimals: 18, slip44: 9005 }, + 'eip155:59144': { symbol: 'ETH', decimals: 18, slip44: 60 }, +}; + +/** + * Normalizes a hex, decimal, numeric, or CAIP chain id to its CAIP-2 form. + * Only EVM (eip155) chains are normalized here; CAIP ids are returned as-is. + * + * @param chainId - Hex (`0x1`), numeric, decimal string, or CAIP chain id. + * @returns The CAIP-2 chain id, or `undefined` when it can't be normalized. + */ +export function formatChainIdToCaip( + chainId: string | number, +): CaipChainId | undefined { + if (typeof chainId === 'number') { + return `eip155:${chainId}`; + } + + if (chainId.includes(':')) { + return chainId as CaipChainId; + } + + if (chainId.startsWith('0x')) { + const reference = Number.parseInt(chainId, 16); + return Number.isNaN(reference) ? undefined : `eip155:${reference}`; + } + + const reference = Number(chainId); + return Number.isNaN(reference) ? undefined : `eip155:${reference}`; +} + +/** + * Looks up the native asset metadata for a chain from the canonical table. + * Returns `undefined` (never throws) for chains outside the table — callers + * degrade gracefully, matching the previous bridge-controller behaviour. + * + * @param chainId - Hex, numeric, decimal, or CAIP chain id. + * @returns Native asset symbol/decimals/assetId, or `undefined` if unsupported. + */ +export function getNativeAsset( + chainId: string | number, +): NativeAssetMetadata | undefined { + const caipChainId = formatChainIdToCaip(chainId); + + if (!caipChainId) { + return undefined; + } + + const entry = nativeAssetsByCaipChainId[caipChainId]; + + if (!entry) { + return undefined; + } + + return { + symbol: entry.symbol, + decimals: entry.decimals, + assetId: `${caipChainId}/slip44:${entry.slip44}`, + }; +} + +function isNativeAddress(address: string): boolean { + const normalized = address.toLowerCase(); + return ( + normalized === nativeTokenAddress || + normalized === '0x0' || + /^0x0+$/u.test(normalized) + ); +} + +/** + * Encodes an EVM token address + chain id into a CAIP-19 asset id. + * + * @param address - Hex contract address, native sentinel, or CAIP asset id. + * @param chainId - CAIP-2 or hex chain id. + * @returns The CAIP-19 asset id, or `undefined` when it can't be encoded. + */ +export function formatAddressToAssetId( + address: Hex | CaipAssetType | string, + chainId?: CaipChainId | Hex, +): CaipAssetType | undefined { + if (isCaipAssetType(address)) { + return address; + } + + const caipChainId = chainId ? formatChainIdToCaip(chainId) : undefined; + + if (!caipChainId) { + return undefined; + } + + if (isNativeAddress(address)) { + const nativeAssetId = getNativeAsset(caipChainId)?.assetId; + + if (nativeAssetId) { + return nativeAssetId as CaipAssetType; + } + } + + const checksummedAddress = toChecksumHexAddress(address); + + if (!isStrictHexString(checksummedAddress)) { + return undefined; + } + + const { namespace, reference } = parseCaipChainId(caipChainId); + + return toCaipAssetType(namespace, reference, 'erc20', checksummedAddress); +} diff --git a/packages/client-utils/src/mappers/helpers/token-metadata.test.ts b/packages/client-utils/src/mappers/helpers/token-metadata.test.ts new file mode 100644 index 0000000000..0b32457577 --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/token-metadata.test.ts @@ -0,0 +1,38 @@ +import { getKnownTokenMetadata } from './token-metadata'; + +describe('getKnownTokenMetadata', () => { + it('returns undefined when the contract address is missing', () => { + expect(getKnownTokenMetadata('eip155:1')).toBeUndefined(); + }); + + it('returns undefined for non-mainnet chains', () => { + expect( + getKnownTokenMetadata( + 'eip155:8453', + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + ), + ).toBeUndefined(); + }); + + it('returns undefined for unknown mainnet tokens', () => { + expect( + getKnownTokenMetadata( + 'eip155:1', + '0x1111111111111111111111111111111111111111', + ), + ).toBeUndefined(); + }); + + it('returns metadata for a known mainnet token', () => { + expect( + getKnownTokenMetadata( + 'eip155:1', + '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + ), + ).toMatchObject({ + symbol: 'USDC', + decimals: 6, + assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }); + }); +}); diff --git a/packages/client-utils/src/mappers/helpers/token-metadata.ts b/packages/client-utils/src/mappers/helpers/token-metadata.ts new file mode 100644 index 0000000000..f495996739 --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/token-metadata.ts @@ -0,0 +1,49 @@ +import contractMap from '@metamask/contract-metadata'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import type { CaipChainId, Hex } from '@metamask/utils'; + +import { formatAddressToAssetId } from './caip'; + +export type KnownTokenMetadata = { + symbol?: string; + decimals?: number; + assetId?: string; +}; + +type ContractMetadataEntry = { + name?: string; + symbol?: string; + decimals?: number; + erc20?: boolean; +}; + +const mainnetTokens = contractMap as Record; + +const mainnetAssetIdPrefix = 'eip155:1/'; + +export function getKnownTokenMetadata( + chainId: CaipChainId | Hex, + contractAddress?: string, +): KnownTokenMetadata | undefined { + if (!contractAddress) { + return undefined; + } + + const assetId = formatAddressToAssetId(contractAddress, chainId); + + if (!assetId?.startsWith(mainnetAssetIdPrefix)) { + return undefined; + } + + const entry = mainnetTokens[toChecksumHexAddress(contractAddress)]; + + if (!entry) { + return undefined; + } + + return { + symbol: entry.symbol, + decimals: entry.decimals, + assetId, + }; +} diff --git a/packages/client-utils/src/mappers/helpers/transactions.test.ts b/packages/client-utils/src/mappers/helpers/transactions.test.ts new file mode 100644 index 0000000000..3127f19459 --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/transactions.test.ts @@ -0,0 +1,306 @@ +import * as tokenMetadata from './token-metadata'; +import { + getFees, + getLocalTransactionFees, + getLocalTransactionStatus, + getNftPaymentTransfer, + getTokenAmountFromTransfer, + getTokenMetadataFromKnownToken, + isNftStandard, + parseValueTransfers, + withFallbackTokenAssetId, +} from './transactions'; + +describe('transaction helpers', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('isNftStandard', () => { + it('detects nft transfer types', () => { + expect(isNftStandard('erc721')).toBe(true); + expect(isNftStandard('erc1155')).toBe(true); + expect(isNftStandard('erc20')).toBe(false); + }); + }); + + describe('getNftPaymentTransfer', () => { + it('returns a buy-side native payment transfer', () => { + expect( + getNftPaymentTransfer({ + side: 'buy', + sentNativeTransfer: { + from: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000002', + transferType: 'normal', + symbol: 'ETH', + amount: 1, + }, + nftCounterparty: '0x0000000000000000000000000000000000000002', + subjectAddress: '0x0000000000000000000000000000000000000001', + }), + ).toMatchObject({ symbol: 'ETH' }); + }); + + it('returns undefined for sell-side transfers that do not match the counterparty', () => { + expect( + getNftPaymentTransfer({ + side: 'sell', + receivedTransfer: { + from: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000002', + transferType: 'erc20', + symbol: 'USDC', + amount: 1, + }, + nftCounterparty: '0x0000000000000000000000000000000000000003', + subjectAddress: '0x0000000000000000000000000000000000000004', + }), + ).toBeUndefined(); + }); + + it('returns sell-side payment when the sender matches the transaction from address', () => { + expect( + getNftPaymentTransfer({ + side: 'sell', + receivedTransfer: { + from: '0x0000000000000000000000000000000000000003', + to: '0x0000000000000000000000000000000000000004', + transferType: 'erc20', + symbol: 'USDC', + amount: 1, + }, + nftCounterparty: '0x0000000000000000000000000000000000000005', + transactionFrom: '0x0000000000000000000000000000000000000003', + subjectAddress: '0x0000000000000000000000000000000000000004', + }), + ).toMatchObject({ symbol: 'USDC' }); + }); + }); + + describe('parseValueTransfers', () => { + it('prefers a received transfer with a different symbol', () => { + const result = parseValueTransfers( + [ + { + from: '0x0000000000000000000000000000000000000001', + to: '0x0000000000000000000000000000000000000002', + symbol: 'ETH', + transferType: 'normal', + }, + { + from: '0x0000000000000000000000000000000000000002', + to: '0x0000000000000000000000000000000000000001', + symbol: 'USDC', + transferType: 'erc20', + }, + ], + '0x0000000000000000000000000000000000000001', + ); + + expect(result.receivedTransfer?.symbol).toBe('USDC'); + }); + }); + + describe('getTokenAmountFromTransfer', () => { + it('returns token metadata without a symbol when only the amount is present', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'erc20', + amount: 1, + }, + 'out', + 'eip155:1', + ), + ).toStrictEqual({ + direction: 'out', + amount: '1', + }); + }); + + it('returns token metadata with decimals when they are present', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'erc20', + symbol: 'USDC', + amount: 1, + decimal: 6, + }, + 'out', + 'eip155:1', + ), + ).toStrictEqual({ + direction: 'out', + amount: '1', + symbol: 'USDC', + decimals: 6, + }); + }); + + it('returns token metadata without decimals when they are omitted', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'erc20', + symbol: 'USDC', + amount: 1, + }, + 'out', + 'eip155:1', + ), + ).toStrictEqual({ + direction: 'out', + amount: '1', + symbol: 'USDC', + }); + }); + + it('returns undefined when the transfer has no symbol or amount', () => { + expect( + getTokenAmountFromTransfer( + { + from: '0x1', + to: '0x2', + transferType: 'erc20', + }, + 'out', + 'eip155:1', + ), + ).toBeUndefined(); + }); + }); + + describe('getTokenMetadataFromKnownToken', () => { + it('returns metadata without a symbol when it is missing', () => { + jest.spyOn(tokenMetadata, 'getKnownTokenMetadata').mockReturnValue({ + decimals: 18, + assetId: 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + }); + + expect( + getTokenMetadataFromKnownToken( + '0x1111111111111111111111111111111111111111', + 'out', + 'eip155:1', + ), + ).toStrictEqual({ + direction: 'out', + decimals: 18, + assetId: 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + }); + }); + + it('returns partial metadata when some fields are missing', () => { + jest.spyOn(tokenMetadata, 'getKnownTokenMetadata').mockReturnValue({ + symbol: 'TKN', + }); + + expect( + getTokenMetadataFromKnownToken( + '0x1111111111111111111111111111111111111111', + 'out', + 'eip155:1', + ), + ).toStrictEqual({ direction: 'out', symbol: 'TKN' }); + }); + + it('returns undefined for unknown tokens', () => { + expect( + getTokenMetadataFromKnownToken( + '0x1111111111111111111111111111111111111111', + 'out', + 'eip155:1', + ), + ).toBeUndefined(); + }); + }); + + 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', () => { + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0x539', + txParams: {}, + txReceipt: { + gasUsed: '0x1', + effectiveGasPrice: '0x2', + }, + }, + } as Parameters[0]), + ).toStrictEqual([ + { + type: 'base', + amount: '2', + decimals: 18, + }, + ]); + }); + + it('returns undefined when gas fields are missing', () => { + expect( + getLocalTransactionFees({ + primaryTransaction: { + chainId: '0x1', + txParams: {}, + }, + } as Parameters[0]), + ).toBeUndefined(); + }); + }); + + describe('getFees', () => { + it('returns network fees for supported chains', () => { + expect( + getFees( + { + gasUsed: '0x2', + effectiveGasPrice: '0x3', + } as Parameters[0], + 'eip155:1', + ), + ).toStrictEqual([ + { + type: 'base', + amount: '6', + decimals: 18, + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + }, + ]); + }); + }); + + describe('getLocalTransactionStatus', () => { + it('maps cancelled transaction groups to failed', () => { + expect( + getLocalTransactionStatus({ + primaryTransaction: { + status: 'cancelled', + }, + initialTransaction: { + status: 'cancelled', + }, + } as Parameters[0]), + ).toBe('failed'); + }); + }); +}); diff --git a/packages/client-utils/src/mappers/helpers/transactions.ts b/packages/client-utils/src/mappers/helpers/transactions.ts new file mode 100644 index 0000000000..6e686bdcc6 --- /dev/null +++ b/packages/client-utils/src/mappers/helpers/transactions.ts @@ -0,0 +1,402 @@ +import { + ERC721, + ERC1155, + isEqualCaseInsensitive as equalsIgnoreCase, +} from '@metamask/controller-utils'; +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { CaipChainId, Hex } from '@metamask/utils'; + +import type { Fee, Status, TokenAmount, ValueTransfer } from '../../types'; +import { nativeTokenAddress } from '../constants'; +import { formatAddressToAssetId, getNativeAsset } from './caip'; +import { getKnownTokenMetadata } from './token-metadata'; + +// Adds optional `isSmartTransaction` to `TransactionMeta`. +export type TransactionGroup = { + hasCancelled: boolean; + hasRetried: boolean; + initialTransaction: TransactionMeta & { isSmartTransaction?: boolean }; + nonce: Hex; + primaryTransaction: TransactionMeta; + transactions: TransactionMeta[]; +}; + +const nativeTokenDecimals = 18; + +function toNetworkFeeAmount( + gasUsed: string | number | undefined, + gasPrice: string | number | undefined, +): string | undefined { + if (gasUsed === undefined || gasPrice === undefined) { + return undefined; + } + + try { + return String(BigInt(gasUsed) * BigInt(gasPrice)); + } catch { + return undefined; + } +} + +function buildBaseNetworkFee(amount: string, chainId: string | number): Fee { + const nativeAsset = getNativeAsset(chainId); + + return { + type: 'base', + amount, + ...(nativeAsset?.decimals === undefined + ? { decimals: nativeTokenDecimals } + : { decimals: nativeAsset.decimals }), + ...(nativeAsset?.symbol ? { symbol: nativeAsset.symbol } : {}), + ...(nativeAsset?.assetId ? { assetId: nativeAsset.assetId } : {}), + }; +} + +function getNetworkFee( + transaction: V1TransactionByHashResponse, + chainId: string, +): Fee | undefined { + const amount = toNetworkFeeAmount( + transaction.gasUsed, + transaction.effectiveGasPrice, + ); + + return amount ? buildBaseNetworkFee(amount, chainId) : undefined; +} + +export function getFees( + transaction: V1TransactionByHashResponse, + chainId: string, +): Fee[] | undefined { + const networkFee = getNetworkFee(transaction, chainId); + + return networkFee ? [networkFee] : undefined; +} + +export function getLocalTransactionFees( + transactionGroup: Pick, +): Fee[] | undefined { + const { primaryTransaction } = transactionGroup; + const amount = toNetworkFeeAmount( + primaryTransaction.txReceipt?.gasUsed, + primaryTransaction.txReceipt?.effectiveGasPrice ?? + primaryTransaction.txParams?.gasPrice, + ); + + return amount + ? [buildBaseNetworkFee(amount, primaryTransaction.chainId)] + : undefined; +} + +const inProgressTransactionStatuses = [ + TransactionStatus.unapproved, + TransactionStatus.approved, + TransactionStatus.signed, + TransactionStatus.submitted, +]; + +const transactionGroupCancelledStatus = 'cancelled'; + +const smartTransactionStatus = { + cancelled: 'cancelled', + pending: 'pending', + success: 'success', +} as const; + +function getTransactionStatusKey( + transaction: TransactionGroup['primaryTransaction'], +): string { + const { type, status } = transaction; + const receiptStatus = transaction.txReceipt?.status; + + if (receiptStatus === '0x0') { + return TransactionStatus.failed; + } + + if ( + status === TransactionStatus.confirmed && + type === TransactionType.cancel + ) { + return transactionGroupCancelledStatus; + } + + return transaction.status; +} + +export function getLocalTransactionStatus({ + primaryTransaction, + initialTransaction, +}: { + primaryTransaction: TransactionGroup['primaryTransaction']; + initialTransaction: TransactionGroup['initialTransaction']; +}): Status { + if (initialTransaction.isSmartTransaction) { + const smartStatus = initialTransaction.status as string | undefined; + + if (smartStatus === smartTransactionStatus.pending) { + return 'pending'; + } + + if (smartStatus === smartTransactionStatus.success) { + return 'success'; + } + + if (smartStatus === smartTransactionStatus.cancelled) { + return 'failed'; + } + + return 'pending'; + } + + const statusKey = getTransactionStatusKey(primaryTransaction); + + if (statusKey === TransactionStatus.confirmed) { + return 'success'; + } + + if ( + statusKey === TransactionStatus.cancelled || + statusKey === transactionGroupCancelledStatus || + statusKey === TransactionStatus.dropped || + statusKey === TransactionStatus.failed || + statusKey === TransactionStatus.rejected + ) { + return 'failed'; + } + + if ( + inProgressTransactionStatuses.includes( + statusKey as (typeof inProgressTransactionStatuses)[number], + ) + ) { + return 'pending'; + } + + return 'pending'; +} + +export function isNftStandard(value?: string): boolean { + return value === ERC721.toLowerCase() || value === ERC1155.toLowerCase(); +} + +export function getNftPaymentTransfer({ + side, + sentTransfer, + receivedTransfer, + sentNativeTransfer, + nftCounterparty, + transactionFrom, + subjectAddress, +}: { + side: 'buy' | 'sell'; + sentTransfer?: ValueTransfer; + receivedTransfer?: ValueTransfer; + sentNativeTransfer?: ValueTransfer; + nftCounterparty: string; + transactionFrom?: string; + subjectAddress: string; +}): ValueTransfer | undefined { + const isFungible = (transfer?: ValueTransfer): boolean => + Boolean(transfer && !isNftStandard(transfer.transferType)); + + if (side === 'buy') { + for (const transfer of [sentNativeTransfer, sentTransfer]) { + if (!transfer || !isFungible(transfer)) { + continue; + } + + if ( + equalsIgnoreCase(transfer.to, nftCounterparty) || + transfer.transferType === 'normal' + ) { + return transfer; + } + } + + return undefined; + } + + if (!receivedTransfer || !isFungible(receivedTransfer)) { + return undefined; + } + + if ( + equalsIgnoreCase(receivedTransfer.from, nftCounterparty) || + (transactionFrom && + !equalsIgnoreCase(transactionFrom, subjectAddress) && + equalsIgnoreCase(receivedTransfer.from, transactionFrom)) + ) { + return receivedTransfer; + } + + return undefined; +} + +const resolveAssetId = ( + chainId: CaipChainId, + { + contractAddress, + transferType, + }: { + contractAddress?: string; + transferType?: string; + }, +): string | undefined => { + if (contractAddress) { + return formatAddressToAssetId(contractAddress, chainId); + } + + if (transferType === 'normal' || transferType === 'internal') { + return formatAddressToAssetId(nativeTokenAddress, chainId); + } + + return undefined; +}; + +/** + * Resolves the user's primary send and receive legs from indexed value transfers. + * Prefers a receive whose symbol differs from the sent leg so dust does not win. + * + * @param valueTransfers - Indexed value transfers from the Accounts API. + * @param subjectAddress - The account address to match transfers against. + * @returns The primary sent and received transfers for the account. + */ +export function parseValueTransfers( + valueTransfers: ValueTransfer[] | undefined, + subjectAddress: string, +): { + sentTransfer: ValueTransfer | undefined; + receivedTransfer: ValueTransfer | undefined; + sentNativeTransfer: ValueTransfer | undefined; + sentNftTransfer: ValueTransfer | undefined; + receivedNftTransfer: ValueTransfer | undefined; +} { + const sent = valueTransfers?.filter(({ from }) => + equalsIgnoreCase(from, subjectAddress), + ); + const received = valueTransfers?.filter(({ to }) => + equalsIgnoreCase(to, subjectAddress), + ); + + const sentTransfer = sent?.[0]; + + const receivedTransfer = + received?.find(({ symbol }) => symbol !== sentTransfer?.symbol) ?? + received?.[0]; + + const sentNativeTransfer = sent?.find( + ({ transferType }) => transferType === 'normal', + ); + + const sentNftTransfer = sent?.find(({ transferType }) => + isNftStandard(transferType), + ); + const receivedNftTransfer = received?.find(({ transferType }) => + isNftStandard(transferType), + ); + + return { + sentTransfer, + receivedTransfer, + sentNativeTransfer, + sentNftTransfer, + receivedNftTransfer, + }; +} + +export function getTokenAmountFromTransfer( + transfer: ValueTransfer | undefined, + direction: TokenAmount['direction'], + chainId: CaipChainId, +): TokenAmount | undefined { + if (!transfer) { + return undefined; + } + + const { transferType, amount } = transfer; + const isNftTransfer = isNftStandard(transferType); + const symbol = isNftTransfer + ? transfer.name || transfer.symbol + : transfer.symbol; + + if (!symbol && amount === undefined) { + return undefined; + } + + const assetId = + transfer && !isNftTransfer + ? resolveAssetId(chainId, { + contractAddress: transfer.contractAddress, + transferType: transfer.transferType, + }) + : undefined; + + const hasTransferAmount = + !isNftTransfer && amount !== null && amount !== undefined; + + return { + direction, + ...(hasTransferAmount ? { amount: String(amount) } : {}), + ...(transfer.decimal === undefined ? {} : { decimals: transfer.decimal }), + ...(symbol ? { symbol } : {}), + ...(assetId ? { assetId } : {}), + }; +} + +export function getTokenMetadataFromKnownToken( + contractAddress: string | undefined, + direction: TokenAmount['direction'], + chainId: CaipChainId, +): TokenAmount | undefined { + const tokenMetadata = getKnownTokenMetadata(chainId, contractAddress); + + if (!tokenMetadata) { + return undefined; + } + + return { + direction, + ...(tokenMetadata.symbol ? { symbol: tokenMetadata.symbol } : {}), + ...(tokenMetadata.decimals === undefined + ? {} + : { decimals: tokenMetadata.decimals }), + ...(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' || + !fallbackContractAddress + ) { + return token; + } + + const assetId = formatAddressToAssetId(fallbackContractAddress, chainId); + if (!assetId) { + return token; + } + + return { ...token, assetId }; +} diff --git a/packages/client-utils/src/mappers/keyring-transaction-mapper.test.ts b/packages/client-utils/src/mappers/keyring-transaction-mapper.test.ts new file mode 100644 index 0000000000..79a2678e66 --- /dev/null +++ b/packages/client-utils/src/mappers/keyring-transaction-mapper.test.ts @@ -0,0 +1,217 @@ +import { SolScope } from '@metamask/keyring-api'; + +import { keyringTransactionFixtures } from '../../test/fixtures/keyring-transactions'; +import { mapKeyringTransaction } from './keyring-transaction-mapper'; + +describe('mapKeyringTransaction', () => { + it('maps keyring send transactions with token amount data', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.sendWithToken, + ); + + expect(item).toMatchObject({ + type: 'send', + chainId: SolScope.Mainnet, + status: 'success', + timestamp: 1716367781000, + hash: 'send-id', + data: { + from: 'from-address', + to: 'to-address', + token: { + amount: '2.5', + assetId: `${SolScope.Mainnet}/token:usdc`, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps keyring swap transactions with source and destination token amounts', () => { + const item = mapKeyringTransaction(keyringTransactionFixtures.mapArgs.swap); + + expect(item).toMatchObject({ + type: 'swap', + chainId: SolScope.Mainnet, + status: 'pending', + timestamp: 1716367781000, + hash: 'swap-id', + data: { + sourceToken: { + amount: '1', + assetId: `${SolScope.Mainnet}/slip44:501`, + direction: 'out', + symbol: 'SOL', + }, + destinationToken: { + amount: '100', + assetId: `${SolScope.Mainnet}/token:usdc`, + direction: 'in', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a keyring receive transaction to a receive activity item', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.receive, + ); + + expect(item).toMatchObject({ + type: 'receive', + chainId: SolScope.Mainnet, + status: 'success', + hash: 'receive-id', + data: { + from: 'sender-address', + to: 'me-address', + token: { + amount: '7', + direction: 'in', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps an unknown keyring transaction type to a contract interaction', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.unknownContractInteraction, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + chainId: SolScope.Mainnet, + status: 'failed', + hash: 'unknown-id', + data: { + from: 'from-address', + to: 'to-address', + fees: [ + { + type: 'base', + amount: '0.0001', + symbol: 'SOL', + assetId: `${SolScope.Mainnet}/slip44:501`, + }, + ], + }, + }); + }); + + it('maps token approve with amount ≤15 digits to approveSpendingCap preserving the amount', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.approveFifteenDigits, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + chainId: SolScope.Mainnet, + status: 'success', + timestamp: 1716367781000, + hash: 'approve-id', + data: { + from: 'owner-address', + token: { + amount: '999999999999999', + assetId: `${SolScope.Mainnet}/token:usdc`, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('strips token amount for approve with >15 digit integer part (uint256.max)', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.approveUint256Max, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + hash: 'unlimited-approve-id', + data: { + token: { + assetId: `${SolScope.Mainnet}/token:usdc`, + symbol: 'USDC', + direction: 'out', + amount: undefined, + }, + }, + }); + }); + + it('returns the approve token unchanged when no amount is present', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.approveNoAmount, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { from: 'owner-address', token: undefined }, + }); + }); + + it('strips token amount when integer part has exactly 16 digits (boundary)', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.approveSixteenDigits, + ); + + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + amount: undefined, + }, + }, + }); + }); + + it('falls back to an empty address when a movement list is empty', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.emptyMovements, + ); + + expect(item).toMatchObject({ + type: 'contractInteraction', + data: { from: '', to: '' }, + }); + }); + + it('maps a missing timestamp to a zero timestamp', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.noTimestamp, + ); + + expect(item.timestamp).toBe(0); + }); + + it('skips non-fungible fees', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.nonFungibleFee, + ); + + expect(item).toMatchObject({ type: 'send', data: { fees: [] } }); + }); + + it('maps bitcoin send from account address and to output address', () => { + const item = mapKeyringTransaction( + keyringTransactionFixtures.mapArgs.bitcoinSend, + ); + + expect(item).toMatchObject({ + type: 'send', + data: { + from: 'bc1qcj8v4ft5uvt59jjrxd856a48xegclwne78h0ye', + to: 'bc1qc5tzsfpd3zjecma6529kanjtug69rf58mtfxmu', + token: { + amount: '0.000003', + direction: 'out', + symbol: 'BTC', + }, + }, + }); + }); +}); diff --git a/packages/client-utils/src/mappers/keyring-transaction-mapper.ts b/packages/client-utils/src/mappers/keyring-transaction-mapper.ts new file mode 100644 index 0000000000..0337f0fa66 --- /dev/null +++ b/packages/client-utils/src/mappers/keyring-transaction-mapper.ts @@ -0,0 +1,189 @@ +import type { Transaction as KeyringTransaction } from '@metamask/keyring-api'; +import { + TransactionStatus, + TransactionType as KeyringTransactionType, +} from '@metamask/keyring-api'; + +import type { Fee, ActivityItem, Status, TokenAmount } from '../types'; + +type Movement = KeyringTransaction['from'][number]; +type KeyringFee = KeyringTransaction['fees'][number]; +type FungibleAsset = Extract< + NonNullable, + { fungible: true } +>; + +function mapStatus(status: KeyringTransaction['status']): Status { + switch (status) { + case TransactionStatus.Confirmed: + return 'success'; + case TransactionStatus.Failed: + return 'failed'; + case TransactionStatus.Submitted: + case TransactionStatus.Unconfirmed: + default: + return 'pending'; + } +} + +function getAddress(movements: Movement[]): string { + return movements[0]?.address ?? ''; +} + +function hasFungibleAsset( + movement: Movement, +): movement is Movement & { asset: FungibleAsset } { + return movement.asset?.fungible === true; +} + +function getToken( + movements: Movement[], + direction: TokenAmount['direction'], +): TokenAmount | undefined { + const movement = movements.find(hasFungibleAsset); + + if (!movement) { + return undefined; + } + + return { + amount: movement.asset.amount, + symbol: movement.asset.unit, + assetId: movement.asset.type, + direction, + }; +} + +function getFee(fee: KeyringFee): Fee | undefined { + const { asset } = fee; + + if (!asset.fungible) { + return undefined; + } + + return { + type: fee.type, + amount: asset.amount, + symbol: asset.unit, + assetId: asset.type, + }; +} + +function getFees(transaction: KeyringTransaction): Fee[] { + return transaction.fees.flatMap((fee) => { + const mappedFee = getFee(fee); + + return mappedFee ? [mappedFee] : []; + }); +} + +const approveAmountMaxIntegerDigits = 15; + +/** + * Maps a keyring transaction into the shared activity item shape. + * + * @param options - The mapping options. + * @param options.transaction - The keyring transaction to map. + * @param options.subjectAddress - Account address used for send/receive attribution. + * @returns The normalized activity item. + */ +export function mapKeyringTransaction({ + transaction, + subjectAddress, +}: { + transaction: KeyringTransaction; + subjectAddress?: string; +}): ActivityItem { + const { type, id } = transaction; + const status = mapStatus(transaction.status); + const timestamp = transaction.timestamp ? transaction.timestamp * 1000 : 0; + const chainId = transaction.chain; + + const from = + type === KeyringTransactionType.Send && subjectAddress + ? subjectAddress + : getAddress(transaction.from); + + const to = + type === KeyringTransactionType.Receive && subjectAddress + ? subjectAddress + : getAddress(transaction.to); + + const fees = getFees(transaction); + const common = { chainId, status, timestamp, hash: id }; + + switch (type) { + case KeyringTransactionType.Send: { + const fromToken = getToken(transaction.from, 'out'); + const token = + !fromToken && chainId.startsWith('bip122:') + ? getToken(transaction.to, 'out') + : fromToken; + + return { + type: 'send', + ...common, + data: { + from, + to, + token, + fees, + }, + }; + } + + case KeyringTransactionType.Receive: + return { + type: 'receive', + ...common, + data: { + from, + to, + token: getToken(transaction.to, 'in'), + fees, + }, + }; + + case KeyringTransactionType.Swap: + return { + type: 'swap', + ...common, + data: { + from, + destinationToken: getToken(transaction.to, 'in'), + sourceToken: getToken(transaction.from, 'out'), + fees, + }, + }; + + case KeyringTransactionType.TokenApprove: { + const rawToken = getToken(transaction.from, 'out'); + const isUnlimited = + rawToken?.amount !== undefined && + rawToken.amount.split('.')[0].length > approveAmountMaxIntegerDigits; + + return { + type: 'approveSpendingCap', + ...common, + data: { + from, + token: rawToken + ? { ...rawToken, amount: isUnlimited ? undefined : rawToken.amount } + : rawToken, + fees, + }, + }; + } + + default: + return { + type: 'contractInteraction', + ...common, + data: { + from, + to, + fees, + }, + }; + } +} diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.test.ts b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts new file mode 100644 index 0000000000..47304c4d60 --- /dev/null +++ b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts @@ -0,0 +1,1107 @@ +import { localTransactionFixtures } from '../../test/fixtures/local-transactions'; +import { formatAddressToAssetId } from './helpers/caip'; +import { mapLocalTransaction } from './local-transaction-mapper'; + +jest.mock('./helpers/token-metadata', () => ({ + getKnownTokenMetadata: jest.requireActual('../../test/test-helpers') + .getKnownTokenMetadata, +})); + +const { + from, + to, + baseUsdc, + lineaDai, + lineaMusd, + wethContractAddress, + mainnetUsdt, +} = localTransactionFixtures.addresses; + +describe('mapLocalTransaction', () => { + it('maps a pending native send to a Send activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPendingNativeSendTo, + ); + expect(item).toStrictEqual({ + type: 'send', + chainId: 'eip155:1', + status: 'pending', + timestamp: 1716367781000, + hash: '0xsend', + data: { + from, + to, + token: { + amount: '0x1', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + }, + }); + }); + it('maps a native send on an unknown chain without a ticker to a tokenless Send', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsANativeSendOnAn, + ); + expect(item).toStrictEqual({ + type: 'send', + chainId: 'eip155:1338', + status: 'success', + timestamp: 1779392463306, + hash: '0xnonative', + data: { + from, + to, + token: undefined, + }, + }); + }); + it('maps a custom network native send without bridge native asset metadata', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsACustomNetworkNativeSend, + ); + expect(item).toStrictEqual({ + type: 'send', + chainId: 'eip155:1338', + status: 'success', + timestamp: 1779392463306, + hash: '0xcustomsend', + data: { + from, + to, + token: { + amount: '0xde0b6b3a7640000', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + }, + }); + }); + it('maps a USDC transfer with transferInformation', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsAUsdcTransferWithTransferinformation, + ); + expect(item).toStrictEqual({ + type: 'send', + chainId: 'eip155:1', + status: 'pending', + timestamp: 1716367781000, + hash: '0xtokensend', + data: { + from, + to: localTransactionFixtures.addresses.mainnetUsdc, + token: { + amount: '20000', + assetId: 'eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + it('maps a USDT transfer without transferInformation', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsAUsdtTransferWithoutTransferinformation, + ); + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1', + data: { + from, + to: localTransactionFixtures.addresses.mainnetUsdt, + token: { + assetId: 'eip155:1/erc20:0xdAC17F958D2ee523a2206206994597C13D831ec7', + direction: 'out', + }, + }, + }); + }); + it('leaves unknown token transfer symbols blank', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.leavesUnknownTokenTransferSymbolsBlank, + ); + expect(item.type).toBe('send'); + if (item.type !== 'send') { + throw new Error(`Expected send item, got ${item.type}`); + } + + expect(item.data.token).toStrictEqual({ + assetId: 'eip155:1/erc20:0x1111111111111111111111111111111111111111', + direction: 'out', + }); + }); + it('falls back to the txParams to when transfer data lacks a recipient', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.fallsBackToTheTxparamsTo, + ); + expect(item).toMatchObject({ + type: 'send', + data: { to: mainnetUsdt }, + }); + }); + it('uses the original transaction type and primary transaction status', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.usesTheOriginalTransactionTypeAnd, + ); + expect(item).toStrictEqual({ + type: 'approveSpendingCap', + chainId: 'eip155:59144', + status: 'pending', + timestamp: 1716367881000, + hash: '0xretry', + data: { + from, + token: { + assetId: + 'eip155:59144/erc20:0x239FD4B0c4DB49Fa8660E65B97619D43D0E0A79d', + decimals: 0, + direction: 'out', + symbol: 'TDN', + }, + }, + }); + }); + it('resolves Permit2 approval token address from calldata', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .resolvesPermit2ApprovalTokenAddressFrom, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + assetId: formatAddressToAssetId( + localTransactionFixtures.addresses.permit2Address, + 'eip155:59144', + ), + }, + }, + }); + }); + it('falls back to transferInformation when txParams.to is not a valid address', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .fallsBackToTransferinformationWhenTxparams, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + }); + it('omits the approved amount for a token approve (mirrors the API path)', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.omitsTheApprovedAmountForA, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + symbol: 'mUSD', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + }, + }, + }); + expect( + item.type === 'approveSpendingCap' ? item.data.token?.amount : 'unset', + ).toBeUndefined(); + }); + it('maps a zero-amount token approve to a revoke spending cap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAZeroAmountTokenApprove, + ); + expect(item.type).toBe('approveSpendingCap'); + }); + it('maps a setApprovalForAll group type to an approve spending cap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASetapprovalforallGroupTypeTo, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { + token: { + direction: 'out', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + it('maps an increaseAllowance to an increase spending cap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnIncreaseallowanceToAnIncrease, + ); + expect(item).toMatchObject({ + type: 'increaseSpendingCap', + data: { + token: { + direction: 'out', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + }, + }, + }); + }); + it('maps an explicit lendingDeposit type', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnExplicitLendingdepositType, + ); + expect(item).toMatchObject({ type: 'lendingDeposit', data: { from } }); + }); + it('maps a stakingDeposit type to a deposit activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAStakingdepositTypeToA, + ); + expect(item).toMatchObject({ + type: 'deposit', + data: { from }, + }); + }); + it('maps an incoming token transfer to a Receive activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnIncomingTokenTransferTo, + ); + expect(item).toMatchObject({ + type: 'receive', + data: { + token: { direction: 'in', symbol: 'USDC', amount: '100000' }, + }, + }); + }); + it('maps an incoming native transfer to a Receive activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnIncomingNativeTransferTo, + ); + expect(item).toMatchObject({ + type: 'receive', + data: { token: { direction: 'in', symbol: 'ETH' } }, + }); + }); + it('maps an mUSD conversion to a Convert activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnMusdConversionToA, + ); + expect(item).toStrictEqual({ + type: 'convert', + chainId: 'eip155:59144', + status: 'success', + timestamp: 1779805800000, + hash: '0xmusdconversion', + data: { + from, + sourceToken: { + assetId: formatAddressToAssetId(lineaDai, 'eip155:59144'), + decimals: 18, + direction: 'out', + symbol: 'DAI', + }, + destinationToken: { + amount: '100099', + assetId: formatAddressToAssetId(lineaMusd, 'eip155:59144'), + decimals: 6, + direction: 'in', + symbol: 'mUSD', + }, + }, + }); + }); + it('maps a Perps withdrawal local transaction to a Perps withdraw funds activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPerpsWithdrawalLocalTransaction, + ); + expect(item).toMatchObject({ + type: 'perpsWithdraw', + chainId: 'eip155:42161', + status: 'success', + timestamp: 1780690942752, + hash: '0xd5dbb4421d123fd16d16485c394a68b5a28d9b5da9d9973554258a9fd2e9ebf6', + data: { + fiat: { + amount: '0.714705', + }, + networkFee: { + amount: '0', + }, + token: { + assetId: formatAddressToAssetId( + '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + 'eip155:42161', + ), + direction: 'out', + }, + }, + }); + }); + it('maps a Perps deposit local transaction to a Perps add funds activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPerpsDepositLocalTransaction, + ); + expect(item).toMatchObject({ + type: 'perpsAddFunds', + chainId: 'eip155:42161', + status: 'success', + timestamp: 1781185241609, + hash: '0x3073fa67020abb1931ed043d7a8b6b020aa1004c9d0dd9ebd43ca5b9c10e9503', + data: { + fiat: { + amount: '1.000169', + }, + networkFee: { + amount: '0.04143764111397638042', + }, + token: { + assetId: formatAddressToAssetId( + '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + 'eip155:42161', + ), + direction: 'out', + }, + }, + }); + }); + it('maps a perps deposit without a target address to a tokenless add funds activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPerpsDepositWithoutA, + ); + expect(item).toMatchObject({ + type: 'perpsAddFunds', + data: { token: undefined, fiat: undefined, networkFee: undefined }, + }); + }); + it('maps an Aave supply contract interaction to a Lending deposit activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnAaveSupplyContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779892154611, + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + data: { + from, + }, + }); + }); + it('maps a native-asset Lido stake contract interaction to a Lending deposit activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsALidoNativeStakeContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:1', + status: 'success', + timestamp: 1782912963672, + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + data: { + from, + }, + }); + }); + it('maps a withdraw contract interaction from the received token transfer', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWithdrawContractInteractionFrom, + ); + expect(item).toStrictEqual({ + type: 'lendingWithdrawal', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779912434153, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + data: { + from, + destinationToken: { + amount: '200000', + assetId: formatAddressToAssetId(baseUsdc, 'eip155:8453'), + decimals: 6, + direction: 'in', + symbol: 'USDC', + }, + }, + }); + }); + it('sets no destination token amount when the received transfer log data is not a valid amount', () => { + const base = + localTransactionFixtures.mapInputs.mapsAWithdrawContractInteractionFrom; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + txReceipt: { + logs: (base.initialTransaction.txReceipt?.logs ?? []).map((log) => ({ + ...log, + data: 'not-a-hex-amount', + })), + }, + }, + }); + expect(item.type).toBe('lendingWithdrawal'); + expect( + item.type === 'lendingWithdrawal' + ? item.data.destinationToken?.amount + : 'unset', + ).toBeUndefined(); + }); + it('maps a withdraw contract interaction without a matching log to no destination token', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsAWithdrawContractInteractionWithout, + ); + expect(item).toMatchObject({ + type: 'lendingWithdrawal', + data: { from, destinationToken: undefined }, + }); + }); + it('maps bridge history token data to a local swap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsBridgeHistoryTokenDataTo, + ); + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1779392463306, + hash: '0xbridgeswap', + data: { + from, + sourceToken: { + amount: '10000000000000', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '19546', + assetId: 'eip155:1/erc20:0xACa92e438df0B2401fF60Da7E4337B687a2435dA', + decimals: 6, + direction: 'in', + symbol: 'MUSD', + }, + }, + }); + }); + it('maps a swap without a destination token to a swap with only a source', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASwapWithoutADestination, + ); + expect(item).toMatchObject({ + type: 'swap', + data: { from }, + }); + }); + it('uses a bridge history activity status override', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.usesABridgeHistoryActivityStatus, + ); + expect(item.status).toBe('failed'); + }); + it('maps a local bridge network fee from the transaction receipt', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALocalBridgeNetworkFee, + ); + expect(item).toMatchObject({ + type: 'bridge', + data: { + fees: [ + { + type: 'base', + amount: String(BigInt('0x24405') * BigInt('0x6fc23ac1d')), + assetId: 'eip155:42161/slip44:60', + decimals: 18, + symbol: 'ETH', + }, + ], + }, + }); + }); + it('maps swap metadata token symbols to a Swap activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsSwapMetadataTokenSymbolsTo, + ); + expect(item).toMatchObject({ + type: 'swap', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1716367781000, + hash: '0xswap', + data: { from }, + }); + }); + it('uses native source symbol for a legacy swap with native value and no source metadata', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.usesNativeSourceSymbolForA, + ); + expect(item).toMatchObject({ + type: 'swap', + data: { from }, + }); + }); + it('maps a legacy swap with an invalid native value without throwing', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALegacySwapWithAn, + ); + expect(item).toMatchObject({ + type: 'swap', + data: { from }, + }); + }); + it('maps a WETH9 deposit contract interaction to a Wrap activity', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWeth9DepositContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'wrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + hash: '0xwrap', + data: { + from, + sourceToken: { + amount: '0x3782dace9d900000', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '0x3782dace9d900000', + assetId: formatAddressToAssetId(wethContractAddress, 'eip155:1'), + decimals: 18, + direction: 'in', + }, + }, + }); + }); + it('treats a WETH9 deposit with zero native value as a contract interaction', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.treatsAWeth9DepositWithZero, + ); + expect(item.type).toBe('contractInteraction'); + }); + it('maps a WETH9 withdraw contract interaction to an Unwrap activity', () => { + const unwrapAmount = '1000000000000000000'; + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWeth9WithdrawContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'unwrap', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + hash: '0xunwrap', + data: { + from, + sourceToken: { + amount: unwrapAmount, + assetId: formatAddressToAssetId(wethContractAddress, 'eip155:1'), + decimals: 18, + direction: 'out', + }, + destinationToken: { + amount: unwrapAmount, + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'in', + symbol: 'ETH', + }, + }, + }); + }); + it('maps a WETH9 unwrap with malformed amount data to an unwrap without amount', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWeth9UnwrapWithMalformed, + ); + expect(item).toMatchObject({ + type: 'unwrap', + data: { destinationToken: { direction: 'in', symbol: 'ETH' } }, + }); + }); + it('maps a native value contract interaction with an outgoing token', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsANativeValueContractInteraction, + ); + expect(item).toStrictEqual({ + type: 'contractInteraction', + chainId: 'eip155:1', + status: 'success', + timestamp: 1716367781000, + hash: '0xcontract', + data: { + from, + to, + token: { + amount: '0x3782dace9d900000', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + methodId: '0xd0e30db0', + }, + }); + }); + it('maps a zero-value contract interaction without a token', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAZeroValueContractInteraction, + ); + expect(item).toMatchObject({ + type: 'contractInteraction', + data: { from, to, methodId: '0x12345678' }, + }); + expect( + item.type === 'contractInteraction' ? item.data.token : undefined, + ).toBeUndefined(); + }); + it('maps a contract interaction without a value to a tokenless interaction', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAContractInteractionWithoutA, + ); + expect(item.type).toBe('contractInteraction'); + }); + it('maps a contract interaction with an invalid value without throwing', () => { + expect(() => + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAContractInteractionWithAn, + ), + ).not.toThrow(); + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAContractInteractionWithAn, + ).type, + ).toBe('contractInteraction'); + }); + it('maps a local contract interaction with an incoming NFT simulation change to an NFT buy', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALocalContractInteractionWith, + ); + expect(item).toStrictEqual({ + type: 'nftBuy', + chainId: 'eip155:1', + status: 'success', + timestamp: 1780606867763, + hash: '0x2fda37c5b591c30367649c3c317621429bb5c59ff6a77b0a8cd48b56897168bc', + data: { + from, + token: { + direction: 'in', + }, + }, + }); + }); + it('maps a smart transaction status to a pending activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASmartTransactionStatusTo, + ).status, + ).toBe('pending'); + }); + it('maps a successful smart transaction to a success activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASuccessfulSmartTransactionTo, + ).status, + ).toBe('success'); + }); + it('maps a cancelled smart transaction to a failed activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsACancelledSmartTransactionTo, + ).status, + ).toBe('failed'); + }); + it('maps an unknown smart transaction status to a pending activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnUnknownSmartTransactionStatus, + ).status, + ).toBe('pending'); + }); + it('maps a failed transaction receipt status to a failed activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAFailedTransactionReceiptStatus, + ).status, + ).toBe('failed'); + }); + it('maps a cancelled transaction group to a failed activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsACancelledTransactionGroupTo, + ).status, + ).toBe('failed'); + }); + it('maps a dropped transaction to a failed activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsADroppedTransactionToA, + ).status, + ).toBe('failed'); + }); + it('maps an unapproved transaction to a pending activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnUnapprovedTransactionToA, + ).status, + ).toBe('pending'); + }); + it('maps a status outside the known set to a pending activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAStatusOutsideTheKnown, + ).status, + ).toBe('pending'); + }); + it('uses precomputed fees from the transaction group when present', () => { + const fees = [{ type: 'base', amount: '7', symbol: 'ETH' }]; + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.usesPrecomputedFeesFromTheTransaction, + ); + expect(item).toMatchObject({ type: 'bridge', data: { fees } }); + }); + it('maps a local bridge fee using txParams gasPrice when no receipt price is present', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALocalBridgeFeeUsing, + ); + expect(item).toMatchObject({ + type: 'bridge', + data: { + fees: [ + { + type: 'base', + amount: String(BigInt('0x100') * BigInt('0x10')), + symbol: 'ETH', + }, + ], + }, + }); + }); + it('maps a local bridge with an invalid fee input to no fees', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsALocalBridgeWithAn, + ); + expect(item).toMatchObject({ type: 'bridge', data: { fees: undefined } }); + }); + it('maps a token transfer without a contract address to a tokenless send', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsATokenTransferWithoutA, + ); + expect(item.type).toBe('send'); + expect(item.type === 'send' ? item.data.token : 'unset').toBeUndefined(); + }); + it('maps a WETH9 unwrap with non-hex amount data to an unwrap without amount', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAWeth9UnwrapWithNon, + ); + expect(item).toMatchObject({ + type: 'unwrap', + data: { destinationToken: { direction: 'in', symbol: 'ETH' } }, + }); + expect( + item.type === 'unwrap' ? item.data.sourceToken?.amount : 'unset', + ).toBeUndefined(); + }); + it('falls back to initial transaction id and empty addresses when fields are missing', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.fallsBackToInitialTransactionId, + ); + expect(item).toMatchObject({ + type: 'send', + hash: 'primary-fallback-id', + timestamp: 1716367781000, + data: { + from: '', + to: '', + token: { direction: 'out', symbol: 'ETH' }, + }, + }); + expect( + item.type === 'send' ? item.data.token?.amount : 'unset', + ).toBeUndefined(); + }); + it('handles token transfers on a chain without a wrapped-native token entry', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.handlesTokenTransfersOnAChain, + ); + expect(item).toMatchObject({ + type: 'send', + chainId: 'eip155:1338', + data: { token: { direction: 'out' } }, + }); + }); + it('maps a token transfer with no calldata to a tokenless send', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsATokenTransferWithNo, + ); + expect(item.type).toBe('send'); + }); + it('wraps native value on a chain without canonical native asset metadata', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.wrapsNativeValueOnAChain, + ); + expect(item).toMatchObject({ + type: 'wrap', + chainId: 'eip155:1337', + data: { + destinationToken: { + direction: 'in', + decimals: 18, + amount: '0x3782dace9d900000', + }, + }, + }); + }); + it('defaults missing txParams fields when txParams is omitted', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionWithNo; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + chainId: base.initialTransaction.chainId, + id: base.initialTransaction.id, + hash: base.initialTransaction.hash, + status: base.initialTransaction.status, + time: base.initialTransaction.time, + type: base.initialTransaction.type, + }, + primaryTransaction: base.primaryTransaction, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { from: '', destinationToken: undefined }, + }); + }); + it('maps an mUSD conversion for an unknown destination token without optional metadata fields', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionWithNo; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + chainId: '0x53a', + txParams: { + from, + to: 'not-an-address', + }, + }, + primaryTransaction: { + ...base.primaryTransaction, + chainId: '0x53a', + txParams: { + from, + to: 'not-an-address', + }, + }, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { + destinationToken: { + direction: 'in', + }, + }, + }); + expect( + item.type === 'convert' ? item.data.destinationToken : undefined, + ).toStrictEqual({ direction: 'in' }); + }); + it('maps an mUSD conversion with transferInformation amount to convert decimals from transferInformation', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionToA; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + transferInformation: { + amount: '100000', + decimals: 6, + }, + }, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { + destinationToken: { + direction: 'in', + amount: '100000', + decimals: 6, + }, + }, + }); + }); + it('maps an mUSD conversion with no calldata to a convert without a destination amount', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAnMusdConversionWithNo, + ); + expect(item).toMatchObject({ + type: 'convert', + data: { destinationToken: { direction: 'in', symbol: 'mUSD' } }, + }); + expect( + item.type === 'convert' ? item.data.destinationToken?.amount : 'unset', + ).toBeUndefined(); + }); + it('maps an mUSD conversion with invalid calldata amount to a convert without a destination amount', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionToA; + const invalidAmountData = `0x${'0'.repeat(72)}zz${'0'.repeat(64)}`; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + txParams: { + ...base.initialTransaction.txParams, + data: invalidAmountData, + }, + }, + primaryTransaction: { + ...base.primaryTransaction, + txParams: { + ...base.primaryTransaction.txParams, + data: invalidAmountData, + }, + }, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { destinationToken: { direction: 'in', symbol: 'mUSD' } }, + }); + expect( + item.type === 'convert' ? item.data.destinationToken?.amount : 'unset', + ).toBeUndefined(); + }); + it('maps an mUSD conversion without a destination contract to a convert without a destination token', () => { + const base = localTransactionFixtures.mapInputs.mapsAnMusdConversionWithNo; + const item = mapLocalTransaction({ + ...base, + initialTransaction: { + ...base.initialTransaction, + txParams: { from }, + }, + primaryTransaction: { + ...base.primaryTransaction, + txParams: { from }, + }, + }); + + expect(item).toMatchObject({ + type: 'convert', + data: { destinationToken: undefined }, + }); + }); + it('maps a token approve with no calldata to an approve spending cap', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsATokenApproveWithNo, + ); + expect(item).toMatchObject({ + type: 'approveSpendingCap', + data: { token: { direction: 'out' } }, + }); + }); + it('ignores withdraw logs that have no recipient topic', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.ignoresWithdrawLogsThatHaveNo, + ); + expect(item).toMatchObject({ + type: 'lendingWithdrawal', + data: { destinationToken: undefined }, + }); + }); + it('maps a token transferFrom to a Send activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsATokenTransferfromToA, + ).type, + ).toBe('send'); + }); + it('maps a safeTransferFrom to a Send activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASafetransferfromToASend, + ).type, + ).toBe('send'); + }); + it('maps a swapAndSend to a swap activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsASwapandsendToASwap, + ).type, + ).toBe('swap'); + }); + it('maps a perpsDepositAndOrder to an add funds activity', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsAPerpsdepositandorderToAnAdd, + ).type, + ).toBe('perpsAddFunds'); + }); + it('maps a bridgeApproval to an approve spending cap', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsABridgeapprovalToAnApprove, + ).type, + ).toBe('approveSpendingCap'); + }); + it('maps a shieldSubscriptionApprove to an approve spending cap', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsAShieldsubscriptionapproveToAnApprove, + ).type, + ).toBe('approveSpendingCap'); + }); + it('maps a tokenMethodSetApprovalForAll to an approve spending cap', () => { + expect( + mapLocalTransaction( + localTransactionFixtures.mapInputs + .mapsATokenmethodsetapprovalforallToAnApprove, + ).type, + ).toBe('approveSpendingCap'); + }); + it('omits the assetId when the token contract address cannot be encoded', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.omitsTheAssetidWhenTheToken, + ); + expect(item.type).toBe('send'); + expect(item.type === 'send' ? item.data.token : undefined).toStrictEqual({ + direction: 'out', + }); + }); + it('ignores withdraw logs that omit topics entirely', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.ignoresWithdrawLogsThatOmitTopics, + ); + expect(item).toMatchObject({ + type: 'lendingWithdrawal', + data: { destinationToken: undefined }, + }); + }); + it('maps musdClaim to claimMusdBonus with from address', () => { + const item = mapLocalTransaction( + localTransactionFixtures.mapInputs.mapsMusdclaimToClaimmusdbonusWithFrom, + ); + expect(item).toMatchObject({ + type: 'claimMusdBonus', + chainId: 'eip155:59144', + status: 'pending', + timestamp: 1778633325000, + hash: '0xmusdclaim', + data: { + from, + }, + }); + }); +}); diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.ts b/packages/client-utils/src/mappers/local-transaction-mapper.ts new file mode 100644 index 0000000000..eb6673dab4 --- /dev/null +++ b/packages/client-utils/src/mappers/local-transaction-mapper.ts @@ -0,0 +1,542 @@ +import { isEqualCaseInsensitive as equalsIgnoreCase } from '@metamask/controller-utils'; +import { TransactionType } from '@metamask/transaction-controller'; +import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils'; + +import type { Fee, ActivityItem, TokenAmount } from '../types'; +import { + swapsWrappedTokensAddresses, + supplyMethodIds, + tokenTransferLogTopicHash, + unwrapMethodIds, + withdrawMethodIds, + wrapMethodIds, +} from './constants'; +import { formatAddressToAssetId, getNativeAsset } from './helpers/caip'; +import { getKnownTokenMetadata } from './helpers/token-metadata'; +import { + getLocalTransactionFees, + getLocalTransactionStatus, + isNftStandard, +} from './helpers/transactions'; +import type { TransactionGroup } from './helpers/transactions'; + +const evmNativeDecimals = 18; + +/** + * Maps a local TransactionController group into the shared activity item shape. + * + * @param transactionGroup - The transaction group to map, optionally enriched by the client. + * @returns The normalized activity item. + */ +export function mapLocalTransaction( + transactionGroup: TransactionGroup & { + sourceToken?: TokenAmount; + destinationToken?: TokenAmount; + nativeAssetSymbol?: string; + contractTokenMetadata?: { symbol?: string; decimals?: number }; + activityStatus?: ActivityItem['status']; + fees?: Fee[]; + }, +): ActivityItem { + const fees = + transactionGroup.fees ?? getLocalTransactionFees(transactionGroup); + const { initialTransaction, primaryTransaction } = transactionGroup; + const { + transferInformation, + type: transactionType, + simulationData, + txReceipt, + metamaskPay, + txParams: { from = '', to = '', data: txData, value: txValue } = {}, + } = initialTransaction; + const { + time: primaryTime, + hash: primaryHash, + id: primaryId, + } = primaryTransaction; + const methodId = txData?.slice(0, 10); + const tokenContractAddress = + transferInformation?.contractAddress ?? (to || undefined); + const chainId = toCaipChainId( + KnownCaipNamespace.Eip155, + Number.parseInt(initialTransaction.chainId, 16).toString(), + ); + const nativeAsset = getNativeAsset(initialTransaction.chainId); + const nativeSymbol = + transactionGroup.nativeAssetSymbol ?? nativeAsset?.symbol; + + const getNativeToken = ( + transaction: TransactionGroup['initialTransaction'], + direction: TokenAmount['direction'], + ): TokenAmount | undefined => { + if (nativeSymbol === undefined) { + return undefined; + } + + return { + direction, + symbol: nativeSymbol, + ...(transaction.txParams.value + ? { amount: transaction.txParams.value } + : {}), + ...(nativeAsset?.assetId ? { assetId: nativeAsset.assetId } : {}), + decimals: nativeAsset?.decimals ?? evmNativeDecimals, + }; + }; + + const getContractTokenFromTransaction = ({ + contractAddress, + direction, + transaction, + }: { + contractAddress?: string; + direction: TokenAmount['direction']; + transaction: TransactionGroup['initialTransaction']; + }): TokenAmount | undefined => { + if (!contractAddress) { + return undefined; + } + + const symbol = + transaction.transferInformation?.symbol ?? + transactionGroup.contractTokenMetadata?.symbol; + const decimals = + transaction.transferInformation?.decimals ?? + transactionGroup.contractTokenMetadata?.decimals; + const amount = transaction.transferInformation?.amount; + const assetId = formatAddressToAssetId(contractAddress, chainId); + + return { + direction, + ...(symbol ? { symbol } : {}), + ...(assetId ? { assetId } : {}), + ...(amount ? { amount } : {}), + ...(decimals === undefined ? {} : { decimals }), + }; + }; + + const getContractTokenWithKnownMetadata = ({ + amount, + contractAddress, + direction, + transaction, + }: { + amount?: string; + contractAddress?: string; + direction: TokenAmount['direction']; + transaction: TransactionGroup['initialTransaction']; + }): TokenAmount | undefined => { + if (!contractAddress) { + return undefined; + } + + const tokenMetadata = getKnownTokenMetadata(chainId, contractAddress); + + const isWrappedNativeToken = equalsIgnoreCase( + contractAddress, + swapsWrappedTokensAddresses[ + initialTransaction.chainId as keyof typeof swapsWrappedTokensAddresses + ] || '', + ); + const wrappedNativeTokenDecimals = isWrappedNativeToken + ? (nativeAsset?.decimals ?? evmNativeDecimals) + : undefined; + + const decimals = + transaction.transferInformation?.amount === undefined + ? (tokenMetadata?.decimals ?? + transactionGroup.contractTokenMetadata?.decimals ?? + wrappedNativeTokenDecimals) + : transaction.transferInformation.decimals; + const tokenAmount = transaction.transferInformation?.amount ?? amount; + const symbol = + transaction.transferInformation?.symbol ?? + tokenMetadata?.symbol ?? + transactionGroup.contractTokenMetadata?.symbol; + const assetId = formatAddressToAssetId(contractAddress, chainId); + + return { + direction, + ...(symbol ? { symbol } : {}), + ...(assetId ? { assetId } : {}), + ...(tokenAmount ? { amount: tokenAmount } : {}), + ...(decimals === undefined ? {} : { decimals }), + }; + }; + + const status = + transactionGroup.activityStatus ?? + getLocalTransactionStatus({ + primaryTransaction, + initialTransaction, + }); + const timestamp = primaryTime ?? initialTransaction.time; + const hash = primaryHash ?? initialTransaction.hash ?? primaryId; + const common = { chainId, status, timestamp, hash }; + + switch (transactionType) { + case TransactionType.simpleSend: { + return { + type: 'send', + ...common, + data: { + from, + to, + token: getNativeToken(initialTransaction, 'out'), + }, + }; + } + + case TransactionType.swap: + case TransactionType.swapAndSend: + case TransactionType.bridge: { + const { sourceToken, destinationToken } = transactionGroup; + + return { + type: transactionType === TransactionType.bridge ? 'bridge' : 'swap', + ...common, + data: { + from, + sourceToken, + destinationToken, + fees, + }, + }; + } + + case TransactionType.tokenMethodSafeTransferFrom: + case TransactionType.tokenMethodTransfer: + case TransactionType.tokenMethodTransferFrom: { + return { + type: 'send', + ...common, + data: { + from, + to, + token: getContractTokenFromTransaction({ + transaction: initialTransaction, + direction: 'out', + contractAddress: tokenContractAddress, + }), + }, + }; + } + + case TransactionType.lendingDeposit: + case TransactionType.stakingDeposit: + return { + type: + transactionType === TransactionType.stakingDeposit + ? 'deposit' + : 'lendingDeposit', + ...common, + data: { + from, + }, + }; + + case TransactionType.incoming: { + return { + type: 'receive', + ...common, + data: { + from, + to, + token: transferInformation?.contractAddress + ? getContractTokenFromTransaction({ + transaction: initialTransaction, + direction: 'in', + contractAddress: transferInformation.contractAddress, + }) + : getNativeToken(initialTransaction, 'in'), + }, + }; + } + case TransactionType.musdClaim: + return { + type: 'claimMusdBonus', + ...common, + data: { + from, + }, + }; + + case TransactionType.musdConversion: { + let conversionAmount: string | undefined; + + if (txData && txData.length >= 138) { + try { + conversionAmount = BigInt(`0x${txData.slice(74, 138)}`).toString(); + } catch { + conversionAmount = undefined; + } + } + + return { + type: 'convert', + ...common, + data: { + from, + sourceToken: transactionGroup.sourceToken, + destinationToken: getContractTokenWithKnownMetadata({ + amount: conversionAmount, + transaction: initialTransaction, + direction: 'in', + contractAddress: to, + }), + }, + }; + } + + case TransactionType.bridgeApproval: + case TransactionType.shieldSubscriptionApprove: + case TransactionType.swapApproval: + case TransactionType.tokenMethodSetApprovalForAll: + case TransactionType.tokenMethodApprove: + case TransactionType.tokenMethodIncreaseAllowance: { + const approvalToken = getContractTokenFromTransaction({ + transaction: initialTransaction, + direction: 'out', + contractAddress: tokenContractAddress, + }); + + return { + type: + transactionType === TransactionType.tokenMethodIncreaseAllowance + ? 'increaseSpendingCap' + : 'approveSpendingCap', + ...common, + data: { + from, + token: approvalToken, + }, + }; + } + + case TransactionType.perpsDeposit: + case TransactionType.perpsDepositAndOrder: + case TransactionType.perpsWithdraw: { + const token = to + ? { + direction: 'out' as const, + assetId: formatAddressToAssetId(to, chainId), + } + : undefined; + + const fiat = metamaskPay?.targetFiat + ? { amount: metamaskPay.targetFiat } + : undefined; + const networkFee = + typeof metamaskPay?.networkFeeFiat === 'string' + ? { amount: metamaskPay.networkFeeFiat } + : undefined; + + return { + type: + transactionType === TransactionType.perpsWithdraw + ? 'perpsWithdraw' + : 'perpsAddFunds', + ...common, + data: { + from, + token, + fiat, + networkFee, + }, + }; + } + + default: { + const isSupplyContractInteraction = + transactionType === TransactionType.contractInteraction && + methodId && + supplyMethodIds.has(methodId.toLowerCase()); + const isWithdrawContractInteraction = + transactionType === TransactionType.contractInteraction && + methodId && + withdrawMethodIds.has(methodId.toLowerCase()); + + let hasNativeValue = false; + + try { + hasNativeValue = BigInt(txValue ?? '0') > 0n; + } catch { + hasNativeValue = false; + } + + // Confirm an outflow before labelling a supply, mirroring the API mapper's + // `sentTransfer` guard: native stakes (e.g. Lido) decrease the native + // balance, while ERC-20 supplies (e.g. Aave) show a token decrease. + const isSupply = + isSupplyContractInteraction && + (simulationData?.nativeBalanceChange?.isDecrease === true || + simulationData?.tokenBalanceChanges?.some( + ({ isDecrease, standard }) => isDecrease && standard === 'erc20', + ) === true); + const incomingNftBalanceChange = + transactionType === TransactionType.contractInteraction && + simulationData?.tokenBalanceChanges?.find( + ({ isDecrease, standard }) => !isDecrease && isNftStandard(standard), + ); + + if (incomingNftBalanceChange && hasNativeValue) { + return { + type: 'nftBuy', + ...common, + data: { + from, + token: { + direction: 'in', + }, + }, + }; + } + + if (isSupply) { + return { + type: 'lendingDeposit', + ...common, + data: { + from, + }, + }; + } + + // lending withdrawal - applies to Earn features only + if (isWithdrawContractInteraction) { + const fromAddress = from.toLowerCase(); + const receivedTokenLog = (txReceipt?.logs ?? []).find( + ({ topics: [eventTopic, , logTo] = [] }) => { + const toAddress = logTo + ? `0x${logTo.slice(-40)}`.toLowerCase() + : undefined; + + return ( + eventTopic?.toLowerCase() === tokenTransferLogTopicHash && + toAddress === fromAddress + ); + }, + ); + let receivedAmount: string | undefined; + + if (receivedTokenLog) { + try { + receivedAmount = BigInt(String(receivedTokenLog.data)).toString(); + } catch { + receivedAmount = undefined; + } + } + + const destinationToken = receivedTokenLog + ? getContractTokenWithKnownMetadata({ + amount: receivedAmount, + transaction: initialTransaction, + direction: 'in', + contractAddress: receivedTokenLog.address, + }) + : undefined; + + return { + type: 'lendingWithdrawal', + ...common, + data: { + from, + destinationToken, + }, + }; + } + + // wrap and unwrap + if (transactionType === TransactionType.contractInteraction && methodId) { + const wrappedTokenAddress = + swapsWrappedTokensAddresses[ + initialTransaction.chainId as keyof typeof swapsWrappedTokensAddresses + ]; + + if (wrappedTokenAddress && equalsIgnoreCase(to, wrappedTokenAddress)) { + const normalizedMethodId = methodId.toLowerCase(); + + if (wrapMethodIds.has(normalizedMethodId)) { + try { + if (txValue && BigInt(txValue) > 0n) { + return { + type: 'wrap', + ...common, + data: { + from, + sourceToken: getNativeToken(initialTransaction, 'out'), + destinationToken: getContractTokenWithKnownMetadata({ + amount: txValue, + transaction: initialTransaction, + direction: 'in', + contractAddress: wrappedTokenAddress, + }), + }, + }; + } + } catch { + // Invalid native value — fall through. + } + } + + if (unwrapMethodIds.has(normalizedMethodId)) { + let unwrapAmount: string | undefined; + + if (txData && txData.length >= 74) { + try { + unwrapAmount = BigInt(`0x${txData.slice(10, 74)}`).toString(); + } catch { + unwrapAmount = undefined; + } + } + + const nativeToken = getNativeToken(initialTransaction, 'in'); + + return { + type: 'unwrap', + ...common, + data: { + from, + sourceToken: getContractTokenWithKnownMetadata({ + amount: unwrapAmount, + transaction: initialTransaction, + direction: 'out', + contractAddress: wrappedTokenAddress, + }), + destinationToken: + nativeToken && unwrapAmount + ? { ...nativeToken, amount: unwrapAmount } + : nativeToken, + }, + }; + } + } + } + + const token = ((): TokenAmount | undefined => { + if (txValue === undefined || txValue === '') { + return undefined; + } + + try { + return BigInt(txValue) > 0n + ? getNativeToken(initialTransaction, 'out') + : undefined; + } catch { + return undefined; + } + })(); + + return { + type: 'contractInteraction', + ...common, + data: { + from, + to, + ...(token ? { token } : {}), + methodId, + }, + }; + } + } +} diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts new file mode 100644 index 0000000000..4408c2518d --- /dev/null +++ b/packages/client-utils/src/types.ts @@ -0,0 +1,159 @@ +import type { ValueTransfer as _ValueTransfer } from '@metamask/core-backend'; +import type { CaipChainId } from '@metamask/utils'; + +export type ActivityKind = + | 'receive' + | 'sell' + | 'buy' + | 'deposit' + | 'swap' + | 'claim' + | 'claimMusdBonus' + | 'send' + | 'wrap' + | 'unwrap' + | 'approveSpendingCap' + | 'revokeSpendingCap' + | 'increaseSpendingCap' + | 'contractInteraction' + | 'contractDeployment' + | 'bridge' + | 'convert' + | 'nftBuy' + | 'nftMint' + | 'nftSell' + | 'smartAccountUpgrade' + | 'lendingDeposit' + | 'lendingWithdrawal' + | 'predictionsAddFunds' + | 'predictionsWithdrawFunds' + | 'predictionClaimWinnings' + | 'predictionCashedOut' + | 'predictionPlaced' + | 'perpsAddFunds' + | 'perpsWithdraw' + | 'perpsOpenLong' + | 'perpsCloseLong' + | 'perpsCloseLongLiquidated' + | 'perpsCloseLongStopLoss' + | 'perpsOpenShort' + | 'perpsCloseShort' + | 'perpsCloseShortLiquidated' + | 'perpsCloseShortStopLoss' + | 'perpsPaidFundingFees' + | 'perpsReceivedFundingFees' + | 'perpsCloseShortTakeProfit' + | 'perpsCloseLongTakeProfit' + | 'marketShort' + | 'stopMarketCloseShort' + | 'marketCloseShort'; + +export type Status = 'pending' | 'success' | 'failed' | 'cancelled'; + +export type TokenAmount = { + amount?: string; + decimals?: number; + symbol?: string; + assetId?: string; + direction: 'in' | 'out'; +}; + +export type FiatAmount = { + amount: string; + currency?: string; +}; + +export type Fee = { + type: string; + amount?: string; + decimals?: number; + symbol?: string; + assetId?: string; +}; + +type ActivityData = { + type: Type; + chainId: CaipChainId; + status: Status; + timestamp: number; + hash?: string; + data: Data; +}; + +export type ActivityItem = + | ActivityData< + 'approveSpendingCap' | 'revokeSpendingCap' | 'increaseSpendingCap', + { + from?: string; + token?: TokenAmount; + fees?: Fee[]; + } + > + | ActivityData< + 'send' | 'receive', + { + from: string; + to: string; + token?: TokenAmount; + fees?: Fee[]; + } + > + | ActivityData< + 'nftBuy' | 'nftMint' | 'nftSell', + { + from?: string; + to?: string; + token?: TokenAmount; + paymentToken?: TokenAmount; + } + > + | ActivityData< + | 'swap' + | 'bridge' + | 'convert' + | 'lendingDeposit' + | 'lendingWithdrawal' + | 'wrap' + | 'unwrap', + { + from?: string; + sourceToken?: TokenAmount; + destinationToken?: TokenAmount; + fees?: Fee[]; + } + > + | ActivityData< + 'buy' | 'claim' | 'deposit' | 'claimMusdBonus', + { + from?: string; + token?: TokenAmount; + } + > + | ActivityData< + 'perpsAddFunds' | 'perpsWithdraw', + { + from?: string; + fiat?: FiatAmount; + networkFee?: FiatAmount; + token?: TokenAmount; + } + > + | ActivityData< + 'contractInteraction', + { + from: string; + to: string; + token?: TokenAmount; + fees?: Fee[]; + methodId?: string; + transactionCategory?: string; + transactionProtocol?: string; + } + >; + +// Note: Update core-backend +export type ValueTransfer = _ValueTransfer & { + contractAddress: string; + symbol: string; + name: string; +}; diff --git a/packages/client-utils/test/fixtures/api-transactions.ts b/packages/client-utils/test/fixtures/api-transactions.ts new file mode 100644 index 0000000000..283f10c44f --- /dev/null +++ b/packages/client-utils/test/fixtures/api-transactions.ts @@ -0,0 +1,1162 @@ +import type { V1TransactionByHashResponse } from '@metamask/core-backend'; + +const addresses = { + subjectAddress: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + baseUsdc: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + mainnetUsdc: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + baseAaveUsdc: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + baseAavePool: '0xa238dd80c259a72e81d7e4664a9801593f98d1c5', + baseRecipientAddress: '0x6fdb1e9d93c1279177b00baaf44524055455e92e', + lineaMusd: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + lineaSenderAddress: '0xf70da97812cb96acdf810712aa562db8dfa3dbef', + exchangeRecipient: '0x3913a8aca88c946284abbe7ab2ed671c6603de20', + metamaskBonusContract: '0x3ef3d8ba38ebe18db133cec108f4d14ce00dd9ae', + bscContractCallerAddress: '0xf70da97812cb96acdf810712aa562db8dfa3dbef', + bscUniversalRouter: '0xca11bde05977b3631167028862be2a173976ca11', + bscRecipientAddress: '0xb92fe925dc43a0ecde6c8b1a2709c170ec4fff4f', + polygonRecipientAddress: '0x2cd071562a1688b3e9f31be39c92aa140a1acc94', + wethContractAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + zeroAddress: '0x0000000000000000000000000000000000000000', + aggregatorAddress: '0x0a2854fbbd9b3ef66f17d47284e7f899b9509330', + nftRecipientAddress: '0x4f5243ceea96cee1da0fdb89c756d0e999439424', + nftBuyerAddress: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + nftSellerAddress: '0x107b2e855528f344556f8c766a6187326a2c2fa6', + openseaSellerAddress: '0xe321bd63cde8ea046b382f82964575f2a5586474', + lifiSwapperAddress: '0xe321bd63cde8ea046b382f82964575f2a5586474', + nftSaleBuyerAddress: '0x78c87da124bb36a914ff1c0f2d642f47870c997c', + mainnetUsdt: '0xdac17f958d2ee523a2206206994597c13d831ec7', + nftContractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + stakingContractAddress: '0x00000000219ab540356cbb839cbe05303d7705fa', + lidoStEth: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', +} as const; + +const transactions = { + nftPurchaseErc1155: { + hash: '0x8719dadd883779624845106e61fd94af234411c30d73184a72f4daf1425c4595', + timestamp: '2026-06-04T19:31:47.000Z', + chainId: 1, + accountId: 'eip155:1:0x699e414873f56c7bb60e54ad63d3bb7b283874df', + blockNumber: 25246129, + blockHash: + '0x3f6354ab0733a6f760eadbcaadc8502aa70a334ea12a2253a64cb15dc002a06d', + gas: 209197, + gasUsed: 137209, + gasPrice: '2488839994', + effectiveGasPrice: '2488839994', + nonce: 70, + cumulativeGasUsed: 2529723, + methodId: '0x00000000', + value: '89992880000000', + to: '0x0000000000000068f116a894984e2db1123eb395', + from: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + isError: false, + valueTransfers: [ + { + from: '0x107b2e855528f344556f8c766a6187326a2c2fa6', + to: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + amount: 1, + tokenId: '57', + contractAddress: '0x6fad73936527d2a82aea5384d252462941b44042', + symbol: '', + name: 'FLUF World: Scenes and Sounds', + transferType: 'erc1155', + }, + { + from: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + to: '0x0000000000000068f116a894984e2db1123eb395', + amount: '89992880000000', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + readableExtended: 'Unidentified Transaction', + }, + lineaAaveUsdcSendWithRebaseCredit: { + hash: '0xdbd832950b40d6242f99176e8f263473670e6a39ddb00580dfcda67772cc6aae', + timestamp: '2026-05-06T13:32:51.000Z', + chainId: 59144, + accountId: 'eip155:59144:0x699e414873f56c7bb60e54ad63d3bb7b283874df', + blockNumber: 30529877, + blockHash: + '0xdc81c3e6834e5697830aa6cb4bc68ea744f38d72718e080f948c9c2c8d17fb51', + gas: 120484, + gasUsed: 118377, + gasPrice: '50000011', + effectiveGasPrice: '50000011', + nonce: 43, + cumulativeGasUsed: 139377, + methodId: '0xa9059cbb', + value: '0', + to: '0x374d7860c4f2f604de0191298dd393703cce84f3', + from: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + amount: '13344', + decimal: 6, + contractAddress: '0x374d7860c4f2f604de0191298dd393703cce84f3', + symbol: 'aLinUSDC', + name: 'Aave Linea USDC', + transferType: 'erc20', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/59144/0x374d7860c4f2f604de0191298dd393703cce84f3.png', + }, + { + from: '0x699e414873f56c7bb60e54ad63d3bb7b283874df', + to: '0xed8799cd90c48f62d0b4f1bb00876b03f0b71c91', + amount: '419402', + decimal: 6, + contractAddress: '0x374d7860c4f2f604de0191298dd393703cce84f3', + symbol: 'aLinUSDC', + name: 'Aave Linea USDC', + transferType: 'erc20', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/59144/0x374d7860c4f2f604de0191298dd393703cce84f3.png', + }, + ], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'TRANSFER', + transactionType: 'ERC_20_TRANSFER', + readable: 'Sent aLinUSDC', + readableExtended: 'Sent 0.4194 aLinUSDC', + }, + lifiLineaUsdcEthExchange: { + hash: '0x3ac43e7c4a1a4421304ada43b41acec4d71ad90abfa418e97e92540a26eef0a2', + timestamp: '2026-01-16T21:09:00.000Z', + chainId: 59144, + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + to: '0xde1e598b81620773454588b85d6b5d4eec32573e', + methodId: '0x2c57e884', + value: '0', + isError: false, + gasUsed: 341413, + effectiveGasPrice: '34544851', + transactionCategory: 'EXCHANGE', + transactionType: 'LIFI_EXCHANGE', + transactionProtocol: 'LIFI', + valueTransfers: [ + { + from: '0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f', + to: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + amount: '2388594176642019', + decimal: 18, + symbol: 'ETH', + transferType: 'internal', + }, + { + from: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + to: '0xde1e598b81620773454588b85d6b5d4eec32573e', + amount: '2388594176642019', + decimal: 18, + symbol: 'ETH', + transferType: 'internal', + }, + { + from: '0xde1e598b81620773454588b85d6b5d4eec32573e', + to: '0xe321bd63cde8ea046b382f82964575f2a5586474', + amount: '2388594176642019', + decimal: 18, + symbol: 'ETH', + transferType: 'internal', + }, + { + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + to: '0xde1e598b81620773454588b85d6b5d4eec32573e', + amount: '7934205', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0xde1e598b81620773454588b85d6b5d4eec32573e', + to: '0xa4a24bdd4608d7dfc496950850f9763b674f0db2', + amount: '87275', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0xde1e598b81620773454588b85d6b5d4eec32573e', + to: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + amount: '7846929', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0x2848973568af7c89dac4321cb3c270a49ed242cc', + to: '0xf359e3e29b20041511a48aa257ecf5a56951a3da', + amount: '7846976', + decimal: 6, + contractAddress: '0xa219439258ca9da29e9cc4ce5596924745e12b93', + symbol: 'USDT', + transferType: 'erc20', + }, + { + from: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + to: '0x2848973568af7c89dac4321cb3c270a49ed242cc', + amount: '7846929', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0xf359e3e29b20041511a48aa257ecf5a56951a3da', + to: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + amount: '2388594176642019', + decimal: 18, + contractAddress: '0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f', + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: '0x0a354845411cc1212cfb33acc6a52fcd4a80e3ae', + to: '0x0000000000000000000000000000000000000000', + amount: '2388594176642019', + decimal: 18, + contractAddress: '0xe5d7c2a44ffddf6b295a15c148167daaaf5cf34f', + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: '0xde1e598b81620773454588b85d6b5d4eec32573e', + to: '0xe321bd63cde8ea046b382f82964575f2a5586474', + amount: '1', + decimal: 6, + contractAddress: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', + symbol: 'USDC', + transferType: 'erc20', + }, + ], + logs: [], + }, + openseaNftSaleWeth: { + hash: '0x0e7f29fa4af73f3708a7383a2fa8d0e09f6c6bf8a176bccf3a6b3259e2886bae', + timestamp: '2026-01-14T21:50:29.000Z', + chainId: 8453, + accountId: 'eip155:8453:0xe321bd63cde8ea046b382f82964575f2a5586474', + blockNumber: 40819041, + blockHash: + '0xb01189d473edf852620d176a4abe46a999a3ce58d3e4ea3dca0125811421b8cf', + gas: 287257, + gasUsed: 180293, + gasPrice: '2878406', + effectiveGasPrice: '2878406', + nonce: 731, + cumulativeGasUsed: 7133440, + methodId: '0xe7acab24', + value: '0', + to: '0x0000000000000068f116a894984e2db1123eb395', + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + isError: false, + valueTransfers: [ + { + from: '0xbaf3ad6542f932cc0e0b54983e82e0cfb7c5a5a1', + to: '0xe321bd63cde8ea046b382f82964575f2a5586474', + amount: '1600000000000000', + decimal: 18, + contractAddress: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + transferType: 'erc20', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/8453/0x4200000000000000000000000000000000000006.png', + }, + { + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + to: '0xbaf3ad6542f932cc0e0b54983e82e0cfb7c5a5a1', + tokenId: '327437', + symbol: 'WPLT', + name: 'The Warplets', + contractAddress: '0x699727f9e01a822efdcf7333073f0461e5914b4e', + transferType: 'erc721', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/8453/0x699727f9e01a822efdcf7333073f0461e5914b4e.png', + }, + { + from: '0xe321bd63cde8ea046b382f82964575f2a5586474', + to: '0x0000a26b00c1f0df003000390027140000faa719', + amount: '16000000000000', + decimal: 18, + contractAddress: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + transferType: 'erc20', + iconUrl: + 'https://static.cx.metamask.io/api/v1/tokenIcons/8453/0x4200000000000000000000000000000000000006.png', + }, + ], + logs: [], + toAddressName: 'OPENSEA_SEAPORT', + transactionProtocol: 'OPENSEA', + transactionCategory: 'NFT_EXCHANGE', + transactionType: 'OPENSEA_V1.6_NFT_EXCHANGE', + readable: 'Exchanged NFT', + readableExtended: 'Exchanged NFT', + }, + + mapsAnErc20TransferSent: { + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 8453, + from: addresses.subjectAddress, + to: addresses.baseUsdc, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.baseRecipientAddress, + symbol: 'USDC', + }, + ], + }, + mapsANativeValueContractCall: { + hash: '0x64d2f26c261178252fcad9dbb665cf40337b827a582066553dd6634eaeea9f0a', + timestamp: '2026-05-19T19:27:12.000Z', + chainId: 137, + from: addresses.subjectAddress, + to: addresses.polygonRecipientAddress, + methodId: null, + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + value: '100000000000000000', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.polygonRecipientAddress, + amount: '100000000000000000', + decimal: 18, + symbol: 'MATIC', + transferType: 'normal', + }, + ], + }, + mapsAnApprovalWithoutValueTransfers: { + hash: '0x91f89897197afcc09ad98ec4282366fd7938d8a9609e4fc2a0aa2d070664bc27', + timestamp: '2026-05-27T13:20:27.000Z', + chainId: 8453, + methodId: '0x095ea7b3', + value: '0', + to: addresses.baseUsdc, + from: addresses.subjectAddress, + isError: false, + valueTransfers: [], + logs: [], + transactionProtocol: 'ERC_20', + transactionCategory: 'APPROVE', + transactionType: 'ERC_20_APPROVE', + }, + fallsBackToValueTransferContract: { + hash: '0x91f89897197afcc09ad98ec4282366fd7938d8a9609e4fc2a0aa2d070664bc27', + timestamp: '2026-05-27T13:20:27.000Z', + chainId: 59144, + methodId: '0x095ea7b3', + value: '0', + to: '0x23', + from: addresses.subjectAddress, + isError: false, + valueTransfers: [ + { + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + decimal: 18, + transferType: 'erc20', + }, + ], + transactionCategory: 'APPROVE', + transactionType: 'ERC_20_APPROVE', + }, + mapsAnApprovalWithNeitherA: { + hash: '0xapprovenoaddr', + timestamp: '2026-05-27T13:20:27.000Z', + chainId: 59144, + methodId: '0x095ea7b3', + value: '0', + to: '0x23', + from: addresses.subjectAddress, + isError: false, + valueTransfers: [], + transactionCategory: 'APPROVE', + transactionType: 'ERC_20_APPROVE', + }, + mapsAnErc20TransferReceived: { + timestamp: '2026-05-05T12:15:27.000Z', + chainId: 59144, + from: addresses.lineaSenderAddress, + to: addresses.lineaMusd, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.lineaSenderAddress, + to: addresses.subjectAddress, + symbol: 'mUSD', + }, + ], + }, + mapsAnExchangeTransactionWithoutA: { + timestamp: '2026-05-05T17:57:53.000Z', + chainId: 59144, + from: addresses.subjectAddress, + to: addresses.subjectAddress, + transactionCategory: 'EXCHANGE', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.exchangeRecipient, + symbol: 'mUSD', + }, + ], + }, + mapsAnExchangeTransactionWithAn: { + hash: '0x80b974d5834e1047a78332369de3d4b988f0237ff8a418c9464217e55c542f2f', + timestamp: '2026-05-28T01:03:49.000Z', + chainId: 59144, + methodId: '0xe9ae5c53', + value: '0', + to: addresses.subjectAddress, + from: addresses.subjectAddress, + isError: false, + transactionCategory: 'EXCHANGE', + valueTransfers: [ + { + from: addresses.aggregatorAddress, + to: addresses.subjectAddress, + amount: '4894004361763', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'internal', + }, + { + from: addresses.subjectAddress, + to: addresses.aggregatorAddress, + amount: '10000', + decimal: 6, + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + name: 'MetaMask USD', + transferType: 'erc20', + }, + ], + logs: [], + }, + mapsAnNftSaleWithReceived: { + timestamp: '2026-02-23T22:04:23.000Z', + chainId: 1, + from: addresses.nftSaleBuyerAddress, + to: addresses.subjectAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + amount: 1, + tokenId: '984', + symbol: 'BAE', + transferType: 'erc1155', + }, + { + from: addresses.nftSaleBuyerAddress, + to: addresses.subjectAddress, + amount: '1000000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + }, + mapsAPlainNftSendWith: { + timestamp: '2026-02-23T22:04:23.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + amount: 1, + tokenId: '984', + symbol: 'BAE', + transferType: 'erc1155', + }, + ], + }, + mapsAnNftPurchasePaidIn: { + timestamp: '2026-06-04T19:31:47.000Z', + chainId: 1, + from: addresses.nftBuyerAddress, + to: '0x0000000000000068f116a894984e2db1123eb395', + transactionCategory: 'CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.nftSellerAddress, + to: addresses.nftBuyerAddress, + amount: 1, + tokenId: '57', + contractAddress: '0x6fad73936527d2a82aea5384d252462941b44042', + name: 'FLUF World: Scenes and Sounds', + transferType: 'erc1155', + }, + { + from: addresses.nftBuyerAddress, + to: addresses.nftSellerAddress, + amount: '89992880000000', + decimal: 18, + symbol: 'WETH', + contractAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + transferType: 'erc20', + }, + ], + }, + mapsAnNftTransferReceivedWithout: { + timestamp: '2026-06-04T19:31:47.000Z', + chainId: 1, + from: addresses.nftSellerAddress, + to: addresses.subjectAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.nftSellerAddress, + to: addresses.subjectAddress, + amount: 1, + tokenId: '57', + contractAddress: '0x6fad73936527d2a82aea5384d252462941b44042', + name: 'FLUF World', + transferType: 'erc1155', + }, + ], + }, + mapsAPlainNftSendNo: { + timestamp: '2026-02-23T22:04:23.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + transactionCategory: 'CONTRACT_CALL', + methodId: '0x12345678', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.nftRecipientAddress, + amount: 1, + tokenId: '984', + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + symbol: 'BAE', + transferType: 'erc721', + }, + ], + }, + mapsAnNftMintTransferTo: { + hash: '0x25805d4ae16935e6fa92add9dcee97db0127749d4244032a79489098a880210c', + timestamp: '2026-05-13T14:34:23.000Z', + chainId: 59144, + from: addresses.zeroAddress, + to: addresses.subjectAddress, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.zeroAddress, + to: addresses.subjectAddress, + contractAddress: addresses.nftContractAddress, + tokenId: '1', + symbol: 'TDN', + transferType: 'erc721', + }, + ], + }, + mapsAnAaveSupplyContractCall: { + hash: '0x08d14578168f22001e95503469c63613bd9f3d3f60e81dbbf204fbd21f484bd9', + timestamp: '2026-05-13T03:31:29.000Z', + chainId: 8453, + from: addresses.subjectAddress, + to: addresses.baseAavePool, + methodId: '0x617ba037', + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: addresses.subjectAddress, + amount: '99999', + decimal: 6, + contractAddress: addresses.baseAaveUsdc, + symbol: 'aBasUSDC', + }, + { + from: addresses.subjectAddress, + to: addresses.baseAaveUsdc, + amount: '100000', + decimal: 6, + contractAddress: addresses.baseUsdc, + symbol: 'USDC', + }, + ], + }, + mapsAnAaveWithdrawWithA: { + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + timestamp: '2026-05-27T14:47:14.000Z', + chainId: 8453, + from: addresses.subjectAddress, + to: addresses.baseAavePool, + methodId: '0x69328dec', + transactionCategory: 'WITHDRAW', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.baseAaveUsdc, + amount: '100000', + decimal: 6, + contractAddress: addresses.baseAaveUsdc, + symbol: 'aBasUSDC', + }, + { + from: addresses.baseAavePool, + to: addresses.subjectAddress, + amount: '200000', + decimal: 6, + contractAddress: addresses.baseUsdc, + symbol: 'USDC', + }, + ], + }, + mapsADepositWithoutAnInbound: { + hash: '0xabc123deposit00000000000000000000000000000000000000000000000001', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.stakingContractAddress, + transactionCategory: 'DEPOSIT', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.stakingContractAddress, + amount: '1000000000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + }, + // Real Lido stETH stake response captured from the transactions API. + mapsALidoStakeToA: { + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + timestamp: '2026-07-01T13:36:23.000Z', + chainId: 1, + accountId: 'eip155:1:0x9bed78535d6a03a955f1504aadba974d9a29e292', + blockNumber: 25438003, + blockHash: + '0x536c1a1e521c3bc41efac41b418efd9e2bba49bf71eec4b06c5428e36691e1f4', + gas: 131071, + gasUsed: 76670, + gasPrice: '2371985355', + effectiveGasPrice: '2371985355', + nonce: 224, + cumulativeGasUsed: 18700942, + methodId: '0xa1903eab', + value: '1000000000000', + to: addresses.lidoStEth, + from: addresses.subjectAddress, + isError: false, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: addresses.subjectAddress, + amount: '999999999999', + decimal: 18, + contractAddress: addresses.lidoStEth, + symbol: 'stETH', + name: 'Liquid staked Ether 2.0', + transferType: 'erc20', + }, + { + from: addresses.subjectAddress, + to: addresses.lidoStEth, + amount: '1000000000000', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'normal', + }, + ], + logs: [], + transactionType: 'GENERIC_CONTRACT_CALL', + transactionCategory: 'CONTRACT_CALL', + readable: 'Unidentified Transaction', + readableExtended: 'Unidentified Transaction', + }, + mapsAWethDepositToA: { + hash: '0x6e448f5b8cf55534507770c1cb90ba14e723d03b4a46b4919a5847eb8d13b7b5', + timestamp: '2026-05-28T13:42:23.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.wethContractAddress, + methodId: '0xd0e30db0', + transactionCategory: 'DEPOSIT', + transactionProtocol: 'WETH', + transactionType: 'WETH_DEPOSIT', + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: addresses.subjectAddress, + amount: '1000000000000', + decimal: 18, + contractAddress: addresses.wethContractAddress, + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: addresses.subjectAddress, + to: addresses.wethContractAddress, + amount: '1000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + }, + mapsAWethWithdrawalToAn: { + hash: '0x8f2a1c9e4b7d30651234567890abcdef1234567890abcdef1234567890abcdef', + timestamp: '2026-05-28T14:15:00.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.wethContractAddress, + methodId: '0x2e1a7d4d', + transactionCategory: 'UNWRAP', + transactionProtocol: 'WETH', + transactionType: 'WETH_WITHDRAW', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.wethContractAddress, + amount: '1000000000000', + decimal: 18, + contractAddress: addresses.wethContractAddress, + symbol: 'WETH', + transferType: 'erc20', + }, + { + from: addresses.wethContractAddress, + to: addresses.subjectAddress, + amount: '1000000000000', + decimal: 18, + symbol: 'ETH', + transferType: 'normal', + }, + ], + }, + mapsAMetamaskMusdBonusClaim: { + hash: '0x875ded271a40278391fca5d71892231afd0cb9592f31bdf3b7c949906cb982c4', + timestamp: '2026-05-13T00:48:45.000Z', + chainId: 59144, + from: addresses.subjectAddress, + to: addresses.metamaskBonusContract, + transactionCategory: 'CLAIM_BONUS', + valueTransfers: [ + { + from: addresses.metamaskBonusContract, + to: addresses.subjectAddress, + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + }, + ], + }, + mapsAGenericClaimWithA: { + hash: '0xclaim', + timestamp: '2026-05-13T00:48:45.000Z', + chainId: 59144, + from: addresses.subjectAddress, + to: addresses.metamaskBonusContract, + transactionCategory: 'CLAIM', + valueTransfers: [ + { + from: addresses.metamaskBonusContract, + to: addresses.subjectAddress, + amount: '5', + decimal: 6, + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + }, + ], + }, + mapsAGenericClaimWithOnly: { + hash: '0xclaimout', + timestamp: '2026-05-13T00:48:45.000Z', + chainId: 59144, + from: addresses.subjectAddress, + to: addresses.metamaskBonusContract, + transactionCategory: 'CLAIM', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.metamaskBonusContract, + amount: '5', + decimal: 6, + contractAddress: addresses.lineaMusd, + symbol: 'mUSD', + }, + ], + }, + mapsABridgeWithdrawToA: { + hash: '0x9f81163d00374094411f44732738c6dea194551e4500bde9fd7ee60319aac766', + timestamp: '2026-05-28T04:13:31.000Z', + chainId: 8453, + gasUsed: '0x24405', + effectiveGasPrice: '0x6fc23ac1d', + methodId: '0xe9ae5c53', + value: '0', + to: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + isError: false, + valueTransfers: [ + { + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + to: '0xa5c1ce365ddb5a91ff466774ec4bdf8f97cb9f55', + amount: '100000', + decimal: 6, + contractAddress: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + logs: [], + transactionCategory: 'BRIDGE_WITHDRAW', + transactionProtocol: 'ACROSS', + transactionType: 'ACROSS_BRIDGE_WITHDRAW', + }, + mapsAnUnrecognizedTransactionCategoryTo: { + timestamp: '2026-05-12T16:04:40.000Z', + chainId: 56, + from: addresses.bscContractCallerAddress, + to: addresses.bscUniversalRouter, + methodId: '0x174dea71', + transactionCategory: 'CONTRACT_CALL', + transactionProtocol: 'GENERIC', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.bscRecipientAddress, + symbol: 'BNB', + }, + ], + }, + mapsTheReportedGenericContractCall: { + hash: '0xd206cc6c16974409bae072ce4cd1559743041af40c2bae84775a0bbb4dff5fee', + timestamp: '2026-05-01T13:39:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.subjectAddress, + methodId: '0xe9ae5c53', + value: '0', + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: '0x4cd00e387622c35bddb9b4c962c136462338bc31', + amount: '580060', + decimal: 6, + contractAddress: addresses.mainnetUsdc, + symbol: 'USDC', + name: 'USD Coin', + transferType: 'erc20', + }, + ], + }, + mapsAContractCallContractCall: { + hash: '0xcontractswap', + timestamp: '2026-05-01T13:39:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: '0xrouter', + methodId: '0xabcdef12', + value: '0', + transactionCategory: 'CONTRACT_CALL', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: '0xrouter', + amount: '580060', + decimal: 6, + contractAddress: addresses.mainnetUsdc, + symbol: 'USDC', + transferType: 'erc20', + }, + { + from: '0xrouter', + to: addresses.subjectAddress, + amount: '1000000000000000', + decimal: 18, + symbol: 'DAI', + transferType: 'erc20', + }, + ], + }, + mapsAFailedTransactionToA: { + hash: '0xfailed', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 8453, + from: addresses.subjectAddress, + to: addresses.baseRecipientAddress, + isError: true, + transactionCategory: 'TRANSFER', + valueTransfers: [ + { + from: addresses.subjectAddress, + to: addresses.baseRecipientAddress, + symbol: 'USDC', + }, + ], + }, + mapsAStandardTransactionOnA: { + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 4657, + from: addresses.subjectAddress, + to: addresses.baseRecipientAddress, + transactionCategory: 'STANDARD', + 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', + chainId: 1, + from: '0x1111111111111111111111111111111111111111', + to: addresses.mainnetUsdc, + transactionCategory: 'APPROVE', + valueTransfers: [ + { + from: '0x1111111111111111111111111111111111111111', + to: addresses.subjectAddress, + contractAddress: addresses.mainnetUsdc, + transferType: 'erc20', + symbol: 'USDC', + }, + ], + }, + mapsAnApproveForAKnown: { + hash: '0xknownapprove', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: addresses.mainnetUsdt, + 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', + chainId: 1, + from: '0x1111111111111111111111111111111111111111', + to: '0x2222222222222222222222222222222222222222', + methodId: '0x12345678', + transactionCategory: 'MYSTERY', + valueTransfers: [ + { + from: '0x2222222222222222222222222222222222222222', + to: addresses.subjectAddress, + amount: '12345', + decimal: 6, + contractAddress: addresses.mainnetUsdc, + symbol: 'USDC', + transferType: 'erc20', + }, + ], + }, +} as const satisfies Record; + +const mapArgs = { + mapsAnErc20TransferSent: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnErc20TransferSent, + }, + mapsAnErc20TransferWith: { + get subjectAddress() { + return transactions.lineaAaveUsdcSendWithRebaseCredit.from as string; + }, + transaction: transactions.lineaAaveUsdcSendWithRebaseCredit, + }, + mapsANativeValueContractCall: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsANativeValueContractCall, + }, + mapsAnApprovalWithoutValueTransfers: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnApprovalWithoutValueTransfers, + }, + fallsBackToValueTransferContract: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.fallsBackToValueTransferContract, + }, + mapsAnApprovalWithNeitherA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnApprovalWithNeitherA, + }, + mapsAnErc20TransferReceived: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnErc20TransferReceived, + }, + mapsAnExchangeTransactionWithoutA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnExchangeTransactionWithoutA, + }, + mapsAnExchangeTransactionWithAn: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnExchangeTransactionWithAn, + }, + mapsTheLifiLineaUsdcTo: { + subjectAddress: addresses.lifiSwapperAddress, + transaction: transactions.lifiLineaUsdcEthExchange, + }, + mapsAnNftSaleWithReceived: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnNftSaleWithReceived, + }, + mapsAnOpenseaNftSalePaid: { + subjectAddress: addresses.openseaSellerAddress, + transaction: transactions.openseaNftSaleWeth, + }, + mapsAPlainNftSendWith: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAPlainNftSendWith, + }, + mapsAnNftPurchase: { + subjectAddress: addresses.nftBuyerAddress, + transaction: transactions.nftPurchaseErc1155, + }, + mapsAnNftPurchasePaidIn: { + subjectAddress: addresses.nftBuyerAddress, + transaction: transactions.mapsAnNftPurchasePaidIn, + }, + mapsAnNftTransferReceivedWithout: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnNftTransferReceivedWithout, + }, + mapsAPlainNftSendNo: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAPlainNftSendNo, + }, + mapsAnNftMintTransferTo: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnNftMintTransferTo, + }, + mapsAnAaveSupplyContractCall: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnAaveSupplyContractCall, + }, + mapsAnAaveWithdrawWithA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnAaveWithdrawWithA, + }, + mapsADepositWithoutAnInbound: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsADepositWithoutAnInbound, + }, + mapsALidoStakeToA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsALidoStakeToA, + }, + mapsAWethDepositToA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAWethDepositToA, + }, + mapsAWethWithdrawalToAn: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAWethWithdrawalToAn, + }, + mapsAMetamaskMusdBonusClaim: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAMetamaskMusdBonusClaim, + }, + mapsAGenericClaimWithA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAGenericClaimWithA, + }, + mapsAGenericClaimWithOnly: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAGenericClaimWithOnly, + }, + mapsABridgeWithdrawToA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsABridgeWithdrawToA, + }, + mapsAnUnrecognizedTransactionCategoryTo: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnUnrecognizedTransactionCategoryTo, + }, + mapsTheReportedGenericContractCall: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsTheReportedGenericContractCall, + }, + mapsAContractCallContractCall: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAContractCallContractCall, + }, + mapsAFailedTransactionToA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAFailedTransactionToA, + }, + mapsAStandardTransactionOnA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAStandardTransactionOnA, + }, + mapsAStandardTransactionWithA: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAStandardTransactionWithA, + }, + mapsAnApproveWithOnlyAn: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnApproveWithOnlyAn, + }, + mapsAnApproveForAKnown: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnApproveForAKnown, + }, + mapsAStandardInboundNativeTransfer: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAStandardInboundNativeTransfer, + }, + mapsAnUnrecognizedCategoryWithOnly: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAnUnrecognizedCategoryWithOnly, + }, +} as const; + +export const apiTransactionFixtures = { + addresses, + transactions, + mapArgs, +}; diff --git a/packages/client-utils/test/fixtures/keyring-transactions.ts b/packages/client-utils/test/fixtures/keyring-transactions.ts new file mode 100644 index 0000000000..b2de9576d9 --- /dev/null +++ b/packages/client-utils/test/fixtures/keyring-transactions.ts @@ -0,0 +1,293 @@ +import { + BtcScope, + SolScope, + TransactionStatus, + TransactionType, +} from '@metamask/keyring-api'; + +const accountId = '00000000-0000-4000-8000-000000000000'; + +export const keyringTransactionFixtures = { + addresses: { + fromAddress: 'from-address', + toAddress: 'to-address', + meAddress: 'me-address', + ownerAddress: 'owner-address', + spenderAddress: 'spender-address', + senderAddress: 'sender-address', + bitcoinSubject: 'bc1qcj8v4ft5uvt59jjrxd856a48xegclwne78h0ye', + bitcoinOutput: 'bc1qc5tzsfpd3zjecma6529kanjtug69rf58mtfxmu', + }, + constants: { + uint256Max: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + }, + mapArgs: { + sendWithToken: { + transaction: { + id: 'send-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [ + { + address: 'from-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '2.5', + }, + }, + ], + to: [{ address: 'to-address', asset: null }], + fees: [], + events: [], + } as never, + }, + swap: { + transaction: { + id: 'swap-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Submitted, + timestamp: 1716367781, + type: TransactionType.Swap, + from: [ + { + address: 'from-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + amount: '1', + }, + }, + ], + to: [ + { + address: 'to-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '100', + }, + }, + ], + fees: [], + events: [], + } as never, + }, + receive: { + subjectAddress: 'me-address', + transaction: { + id: 'receive-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Receive, + from: [{ address: 'sender-address', asset: null }], + to: [ + { + address: 'me-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '7', + }, + }, + ], + fees: [], + events: [], + } as never, + }, + unknownContractInteraction: { + transaction: { + id: 'unknown-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Failed, + timestamp: 1716367781, + type: TransactionType.Unknown, + from: [{ address: 'from-address', asset: null }], + to: [{ address: 'to-address', asset: null }], + fees: [ + { + type: 'base', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/slip44:501`, + unit: 'SOL', + amount: '0.0001', + }, + }, + ], + events: [], + } as never, + }, + approveFifteenDigits: { + transaction: { + id: 'approve-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + from: [ + { + address: 'owner-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '999999999999999', + }, + }, + ], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + approveUint256Max: { + transaction: { + id: 'unlimited-approve-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + from: [ + { + address: 'owner-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: + '115792089237316195423570985008687907853269984665640564039457584007913129639935', + }, + }, + ], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + approveNoAmount: { + transaction: { + id: 'approve-no-amount-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + from: [{ address: 'owner-address', asset: null }], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + approveSixteenDigits: { + transaction: { + id: 'boundary-approve-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.TokenApprove, + from: [ + { + address: 'owner-address', + asset: { + fungible: true, + type: `${SolScope.Mainnet}/token:usdc`, + unit: 'USDC', + amount: '1000000000000000', + }, + }, + ], + to: [{ address: 'spender-address', asset: null }], + fees: [], + events: [], + } as never, + }, + emptyMovements: { + transaction: { + id: 'empty-movements-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Unknown, + from: [], + to: [], + fees: [], + events: [], + } as never, + }, + noTimestamp: { + transaction: { + id: 'no-timestamp-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: null, + type: TransactionType.Send, + from: [{ address: 'from-address', asset: null }], + to: [{ address: 'to-address', asset: null }], + fees: [], + events: [], + } as never, + }, + nonFungibleFee: { + transaction: { + id: 'nonfungible-fee-id', + chain: SolScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [{ address: 'from-address', asset: null }], + to: [{ address: 'to-address', asset: null }], + fees: [ + { + type: 'base', + asset: { fungible: false, id: `${SolScope.Mainnet}/nft:1` }, + }, + ], + events: [], + } as never, + }, + bitcoinSend: { + subjectAddress: 'bc1qcj8v4ft5uvt59jjrxd856a48xegclwne78h0ye', + transaction: { + id: '9a2098cdeb6dcd2d89b9d8993b5f5b2d97a49f91b63aba0ae6d525e6532a64b6', + chain: BtcScope.Mainnet, + account: accountId, + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [], + to: [ + { + address: 'bc1qc5tzsfpd3zjecma6529kanjtug69rf58mtfxmu', + asset: { + fungible: true, + type: `${BtcScope.Mainnet}/slip44:0`, + unit: 'BTC', + amount: '0.000003', + }, + }, + ], + fees: [], + events: [], + } as never, + }, + }, +}; diff --git a/packages/client-utils/test/fixtures/local-transactions.ts b/packages/client-utils/test/fixtures/local-transactions.ts new file mode 100644 index 0000000000..99df9f9f6a --- /dev/null +++ b/packages/client-utils/test/fixtures/local-transactions.ts @@ -0,0 +1,2756 @@ +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import { formatAddressToAssetId } from '../../src/mappers/helpers/caip'; +import { + buildApproveTransactionData, + buildPermit2ApproveTransactionData, + encodeMerklClaimCalldata, +} from '../test-helpers'; + +const chainIds = { + mainnet: '0x1', + base: '0x2105', + arbitrum: '0xa4b1', + lineaMainnet: '0xe708', +} as const; + +const addresses = { + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + to: '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc', + baseUsdc: '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913', + baseAavePool: '0xa238dd80c259a72e81d7e4664a9801593f98d1c5', + lineaDai: '0x4AF15ec2A0BD43Db75dd04E62FAA3B8EF36b00d5', + lineaMusd: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + merklDistributor: '0x3Ef3D8bA38EBe18DB133cEc108f4D14CE00Dd9Ae', + wethContractAddress: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + permit2Address: '0x000000000022D473030F116dDEE9FD8b9aFE764ad8', + spender: '0x80181d3ba89220cdb80234fc7aa19d5cc56229cc', + mainnetUsdc: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + mainnetUsdt: '0xdac17f958d2ee523a2206206994597c13d831ec7', + unknownTokenContract: '0x1111111111111111111111111111111111111111', + lidoStEth: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', +} as const; + +const unwrapAmount = '1000000000000000000'; +const unwrapAmountHex = BigInt(unwrapAmount).toString(16).padStart(64, '0'); + +const nftPurchaseErc1155Transaction = { + chainId: chainIds.mainnet, + id: 'nft-buy-id', + hash: '0x2fda37c5b591c30367649c3c317621429bb5c59ff6a77b0a8cd48b56897168bc', + status: TransactionStatus.confirmed, + time: 1780606867763, + type: TransactionType.contractInteraction, + txParams: { + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + to: '0x0000000000000068f116a894984e2db1123eb395', + value: '0x51d91a3da280', + data: '0x00000000', + }, + simulationData: { + nativeBalanceChange: { + previousBalance: '0x49bfcb2d8362e', + newBalance: '0x44a23989a93ae', + difference: '0x51d91a3da280', + isDecrease: true, + }, + tokenBalanceChanges: [ + { + address: '0x6fad73936527d2a82aea5384d252462941b44042', + standard: 'erc1155', + id: '0x39', + previousBalance: '0x0', + newBalance: '0x1', + difference: '0x1', + isDecrease: false, + }, + ], + }, +}; + +const perpsWithdrawTransaction = { + actionId: 1780690942749.6296, + batchTransactions: [], + batchTransactionsOptions: {}, + chainId: chainIds.arbitrum, + customNonceValue: '', + defaultGasEstimates: { + estimateType: 'medium', + gas: '0xce91', + maxFeePerGas: '0x3a430a0', + maxPriorityFeePerGas: '0x0', + }, + delegationAddress: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b', + gasFeeEstimatesLoaded: true, + gasFeeTokens: [], + gasLimitNoBuffer: '0xac24', + hash: '0xd5dbb4421d123fd16d16485c394a68b5a28d9b5da9d9973554258a9fd2e9ebf6', + id: '427ad200-611c-11f1-960a-af7f25501f42', + isFirstTimeInteraction: false, + isGasFeeSponsored: false, + isGasFeeTokenIgnoredIfBalance: false, + isIntentComplete: true, + isInternal: true, + metamaskPay: { + bridgeFeeFiat: '0.28429', + chainId: chainIds.mainnet, + isPostQuote: true, + networkFeeFiat: '0', + sourceHash: + '0xc01843d173d62145c192043d0c69ec02048100b70ed9401763e0ef2432d9fb30', + targetFiat: '0.714705', + tokenAddress: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + totalFiat: '1.28429', + }, + nestedTransactions: undefined, + networkClientId: 'arbitrum-mainnet', + origin: 'metamask', + originalGasEstimate: '0xce91', + r: '0x80a65112995ff66a0c1a65aed4b26ab0c2db34dc1e08af9618c957bc49878858', + rawTx: + '0x02f8ad82a4b138808403a476f082ce9194af88d065e77c8cc2239327c5edb3a432268e583180b844a9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e29200000000000000000000000000000000000000000000000000000000000f4240c080a080a65112995ff66a0c1a65aed4b26ab0c2db34dc1e08af9618c957bc49878858a06456697c1016f9265f308dd92c707a62611f4891067795cc676571e316a15c36', + requiredTransactionIds: undefined, + s: '0x6456697c1016f9265f308dd92c707a62611f4891067795cc676571e316a15c36', + status: TransactionStatus.confirmed, + submittedTime: 1780690964536, + time: 1780690942752, + txParams: { + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e29200000000000000000000000000000000000000000000000000000000000f4240', + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + gas: '0xce91', + gasLimit: '0xce91', + maxFeePerGas: '0x3a476f0', + maxPriorityFeePerGas: '0x0', + to: '0xaf88d065e77c8cc2239327c5edb3a432268e5831', + type: '0x2', + value: '0x0', + }, + txReceipt: undefined, + type: TransactionType.perpsWithdraw, + userEditedGasLimit: false, + userFeeLevel: 'medium', + v: '0x0', + verifiedOnBlockchain: false, +}; + +const lineaMusd = '0xacA92E438df0B2401fF60dA7E4337B687a2435DA'; +const arbitrumUsdc = '0xaf88d065e77c8cc2239327c5edb3a432268e5831'; + +const perpsDepositTransaction = { + batchTransactions: [], + batchTransactionsOptions: {}, + chainId: chainIds.arbitrum, + customNonceValue: '', + delegationAddress: '0x63c0c19a282a1b52b07dd5a65b58948a07dae32b', + gasFeeEstimatesLoaded: true, + gasFeeTokens: [], + hash: '0x3073fa67020abb1931ed043d7a8b6b020aa1004c9d0dd9ebd43ca5b9c10e9503', + id: '238ebf90-659b-11f1-982b-71194b583664', + isGasFeeSponsored: false, + isGasFeeTokenIgnoredIfBalance: false, + isIntentComplete: true, + isInternal: true, + metamaskPay: { + bridgeFeeFiat: '0.001054', + chainId: chainIds.mainnet, + networkFeeFiat: '0.04143764111397638042', + sourceHash: + '0xc93326d28bfe73508ed6bbb9333835a0c6ee4e1b954205390eee0f2956b8c2f6', + targetFiat: '1.000169', + tokenAddress: lineaMusd, + totalFiat: '1.04266064111397638042', + }, + networkClientId: 'arbitrum-mainnet', + origin: 'metamask', + r: '0xd3ecffa0be63e8a61e487c727ce36a760f0c0a61291b2817cbe9dc55598e45bf', + rawTx: + '0x02f8ae82a4b139808403938700830186a094af88d065e77c8cc2239327c5edb3a432268e583180b844a9059cbb0000000000000000000000002df1c51e09aecf9cacb7bc98cb1742757f163df700000000000000000000000000000000000000000000000000000000000f42e9c080a0d3ecffa0be63e8a61e487c727ce36a760f0c0a61291b2817cbe9dc55598e45bfa013410ef68186a2782a10749ff54e39d6f014ed78a307cd4a344621fc2fac8441', + requiredTransactionIds: ['276012e0-659b-11f1-982b-71194b583664'], + s: '0x13410ef68186a2782a10749ff54e39d6f014ed78a307cd4a344621fc2fac8441', + status: TransactionStatus.confirmed, + submittedTime: 1781185247980, + time: 1781185241609, + txParams: { + data: '0xa9059cbb0000000000000000000000002df1c51e09aecf9cacb7bc98cb1742757f163df700000000000000000000000000000000000000000000000000000000000f42e9', + from: '0x9bed78535d6a03a955f1504aadba974d9a29e292', + gas: '0x186a0', + gasLimit: '0x186a0', + maxFeePerGas: '0x3938700', + maxPriorityFeePerGas: '0x0', + to: arbitrumUsdc, + type: '0x2', + value: '0x0', + }, + type: TransactionType.perpsDeposit, + userEditedGasLimit: false, + v: '0x0', + verifiedOnBlockchain: false, +}; + +const transactionGroups = { + // ERC-1155 purchase local state before API metadata is available. + nftPurchaseErc1155: { + transactionGroup: { + hasCancelled: false, + hasRetried: false, + initialTransaction: nftPurchaseErc1155Transaction, + nonce: '0xd8', + primaryTransaction: nftPurchaseErc1155Transaction, + transactions: [nftPurchaseErc1155Transaction], + }, + }, + perpsWithdraw: { + // Local-only Perps withdrawal from state. + transactionGroup: { + initialTransaction: perpsWithdrawTransaction, + primaryTransaction: perpsWithdrawTransaction, + transactions: [perpsWithdrawTransaction], + }, + }, + perpsDeposit: { + // Local-only Perps deposit from state. + transactionGroup: { + initialTransaction: perpsDepositTransaction, + primaryTransaction: perpsDepositTransaction, + transactions: [perpsDepositTransaction], + }, + }, +}; + +const mapInputs = { + mapsAPendingNativeSendTo: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'send-id', + hash: '0xsend', + status: TransactionStatus.submitted, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x1', + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'send-id', + hash: '0xsend', + status: TransactionStatus.submitted, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x1', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'send-id', + hash: '0xsend', + status: TransactionStatus.submitted, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x1', + }, + }, + ], + }, + mapsANativeSendOnAn: { + initialTransaction: { + chainId: '0x53a', + id: 'no-native-id', + hash: '0xnonative', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + primaryTransaction: { + chainId: '0x53a', + id: 'no-native-id', + hash: '0xnonative', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + }, + mapsACustomNetworkNativeSend: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: '0x53a', + id: 'custom-send-id', + hash: '0xcustomsend', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + nativeAssetSymbol: 'ETH', + nonce: '0x1', + primaryTransaction: { + chainId: '0x53a', + id: 'custom-send-id', + hash: '0xcustomsend', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + transactions: [ + { + chainId: '0x53a', + id: 'custom-send-id', + hash: '0xcustomsend', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.simpleSend, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0xde0b6b3a7640000', + }, + }, + ], + }, + mapsAUsdcTransferWithTransferinformation: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'token-send-id', + hash: '0xtokensend', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + amount: '20000', + contractAddress: addresses.mainnetUsdc, + decimals: 6, + symbol: 'USDC', + }, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdc, + data: '0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20', + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'token-send-id', + hash: '0xtokensend', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + amount: '20000', + contractAddress: addresses.mainnetUsdc, + decimals: 6, + symbol: 'USDC', + }, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdc, + data: '0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'token-send-id', + hash: '0xtokensend', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + amount: '20000', + contractAddress: addresses.mainnetUsdc, + decimals: 6, + symbol: 'USDC', + }, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdc, + data: '0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20', + }, + }, + ], + }, + mapsAUsdtTransferWithoutTransferinformation: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'usdt-send-id', + hash: '0x41f675c4a384e5064b1d9620934b0ff5e8a84f5c84530a25d025e27fb784d303', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'usdt-send-id', + hash: '0x41f675c4a384e5064b1d9620934b0ff5e8a84f5c84530a25d025e27fb784d303', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'usdt-send-id', + hash: '0x41f675c4a384e5064b1d9620934b0ff5e8a84f5c84530a25d025e27fb784d303', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + ], + }, + leavesUnknownTokenTransferSymbolsBlank: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unknown-token-send-id', + hash: '0xunknown', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.unknownTokenContract, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unknown-token-send-id', + hash: '0xunknown', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.unknownTokenContract, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'unknown-token-send-id', + hash: '0xunknown', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.unknownTokenContract, + value: '0x0', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + ], + }, + fallsBackToTheTxparamsTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'no-recipient-id', + hash: '0xnorecipient', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xdeadbeef', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'no-recipient-id', + hash: '0xnorecipient', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: addresses.mainnetUsdt, + value: '0x0', + data: '0xdeadbeef', + }, + }, + }, + usesTheOriginalTransactionTypeAnd: { + hasCancelled: false, + hasRetried: true, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-id', + hash: '0xapprove', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + }, + nonce: '0x2', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'retry-id', + hash: '0xretry', + status: TransactionStatus.approved, + time: 1716367881000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.retry, + txParams: { + from: addresses.from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'approve-id', + hash: '0xapprove', + status: TransactionStatus.submitted, + time: 1716367781000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + }, + { + chainId: chainIds.lineaMainnet, + id: 'retry-id', + hash: '0xretry', + status: TransactionStatus.approved, + time: 1716367881000, + transferInformation: { + contractAddress: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + decimals: 0, + symbol: 'TDN', + }, + type: TransactionType.retry, + txParams: { + from: addresses.from, + to: '0x239fd4b0c4db49fa8660e65b97619d43d0e0a79d', + data: '0xa22cb465', + }, + }, + ], + }, + resolvesPermit2ApprovalTokenAddressFrom: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'permit2-approve-id', + hash: '0xpermit2approve', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.permit2Address, + data: buildPermit2ApproveTransactionData( + addresses.lineaMusd, + addresses.spender, + 1000, + 123, + ), + }, + }, + nonce: '0x4', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'permit2-approve-id', + hash: '0xpermit2approve', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.permit2Address, + data: buildPermit2ApproveTransactionData( + addresses.lineaMusd, + addresses.spender, + 1000, + 123, + ), + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'permit2-approve-id', + hash: '0xpermit2approve', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.permit2Address, + data: buildPermit2ApproveTransactionData( + addresses.lineaMusd, + addresses.spender, + 1000, + 123, + ), + }, + }, + ], + contractTokenMetadata: { symbol: 'mUSD', decimals: 18 }, + }, + fallsBackToTransferinformationWhenTxparams: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'invalid-to-approve-id', + hash: '0xinvalidtoapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + transferInformation: { + contractAddress: addresses.lineaMusd, + decimals: 18, + symbol: 'mUSD', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x23', + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + nonce: '0x5', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'invalid-to-approve-id', + hash: '0xinvalidtoapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + transferInformation: { + contractAddress: addresses.lineaMusd, + decimals: 18, + symbol: 'mUSD', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x23', + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'invalid-to-approve-id', + hash: '0xinvalidtoapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + transferInformation: { + contractAddress: addresses.lineaMusd, + decimals: 18, + symbol: 'mUSD', + }, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: '0x23', + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + ], + }, + omitsTheApprovedAmountForA: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-amount-id', + hash: '0xapproveamount', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + nonce: '0x8', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-amount-id', + hash: '0xapproveamount', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'approve-amount-id', + hash: '0xapproveamount', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 1000), + }, + }, + ], + contractTokenMetadata: { symbol: 'mUSD', decimals: 18 }, + }, + mapsAZeroAmountTokenApprove: { + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'revoke-id', + hash: '0xrevoke', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 0), + }, + }, + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'revoke-id', + hash: '0xrevoke', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + data: buildApproveTransactionData(addresses.spender, 0), + }, + }, + }, + mapsASetapprovalforallGroupTypeTo: { + initialTransaction: { + chainId: chainIds.base, + id: 'set-approval-id', + hash: '0xsetapproval', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.swapApproval, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0xa22cb465', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'set-approval-id', + hash: '0xsetapproval', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.swapApproval, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0xa22cb465', + }, + }, + }, + mapsAnIncreaseallowanceToAnIncrease: { + initialTransaction: { + chainId: chainIds.base, + id: 'increase-id', + hash: '0xincrease', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodIncreaseAllowance, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x39509351', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'increase-id', + hash: '0xincrease', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodIncreaseAllowance, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x39509351', + }, + }, + }, + mapsAnExplicitLendingdepositType: { + initialTransaction: { + chainId: chainIds.base, + id: 'lending-deposit-id', + hash: '0xlendingdeposit', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.lendingDeposit, + txParams: { from: addresses.from, to: addresses.baseAavePool }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'lending-deposit-id', + hash: '0xlendingdeposit', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.lendingDeposit, + txParams: { from: addresses.from, to: addresses.baseAavePool }, + }, + }, + mapsAStakingdepositTypeToA: { + initialTransaction: { + chainId: chainIds.base, + id: 'staking-deposit-id', + hash: '0xstaking', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.stakingDeposit, + txParams: { from: addresses.from, to: addresses.baseUsdc }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'staking-deposit-id', + hash: '0xstaking', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.stakingDeposit, + txParams: { from: addresses.from, to: addresses.baseUsdc }, + }, + }, + mapsAnIncomingTokenTransferTo: { + initialTransaction: { + chainId: chainIds.base, + id: 'incoming-token-id', + hash: '0xincomingtoken', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.incoming, + transferInformation: { + amount: '100000', + contractAddress: addresses.baseUsdc, + decimals: 6, + symbol: 'USDC', + }, + txParams: { from: addresses.from, to: addresses.from }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'incoming-token-id', + hash: '0xincomingtoken', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.incoming, + transferInformation: { + amount: '100000', + contractAddress: addresses.baseUsdc, + decimals: 6, + symbol: 'USDC', + }, + txParams: { from: addresses.from, to: addresses.from }, + }, + }, + mapsAnIncomingNativeTransferTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'incoming-native-id', + hash: '0xincomingnative', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.incoming, + txParams: { from: addresses.from, to: addresses.from, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'incoming-native-id', + hash: '0xincomingnative', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.incoming, + txParams: { from: addresses.from, to: addresses.from, value: '0x1' }, + }, + }, + mapsAnMusdConversionToA: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-id', + hash: '0xmusdconversion', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + value: '0x0', + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000018703', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-id', + hash: '0xmusdconversion', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + value: '0x0', + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000018703', + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-id', + hash: '0xmusdconversion', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + value: '0x0', + data: '0xa9059cbb0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000018703', + }, + }, + ], + sourceToken: { + assetId: formatAddressToAssetId(addresses.lineaDai, 'eip155:59144'), + decimals: 18, + direction: 'out', + symbol: 'DAI', + }, + }, + mapsAPerpsWithdrawalLocalTransaction: + transactionGroups.perpsWithdraw.transactionGroup, + mapsAPerpsDepositLocalTransaction: + transactionGroups.perpsDeposit.transactionGroup, + mapsAPerpsDepositWithoutA: { + initialTransaction: { + chainId: chainIds.arbitrum, + id: 'perps-no-target-id', + hash: '0xperpsnotarget', + status: TransactionStatus.confirmed, + time: 1781185241609, + type: TransactionType.perpsDeposit, + txParams: { from: addresses.from, to: '' }, + }, + primaryTransaction: { + chainId: chainIds.arbitrum, + id: 'perps-no-target-id', + hash: '0xperpsnotarget', + status: TransactionStatus.confirmed, + time: 1781185241609, + type: TransactionType.perpsDeposit, + txParams: { from: addresses.from, to: '' }, + }, + }, + mapsAnAaveSupplyContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.base, + id: 'aave-supply-id', + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + value: '0x0', + data: '0x617ba037000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000000000000000000000000000000000000186a00000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000000000', + }, + simulationData: { + tokenBalanceChanges: [ + { + address: addresses.baseUsdc, + difference: '0x186a0', + isDecrease: true, + newBalance: '0x11284f', + previousBalance: '0x12aeef', + standard: 'erc20', + }, + { + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + difference: '0x1869f', + isDecrease: false, + newBalance: '0x65101', + previousBalance: '0x4ca62', + standard: 'erc20', + }, + ], + }, + }, + nonce: '0x210', + primaryTransaction: { + chainId: chainIds.base, + id: 'aave-supply-id', + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + value: '0x0', + data: '0x617ba037000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000000000000000000000000000000000000186a00000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000000000', + }, + simulationData: { + tokenBalanceChanges: [ + { + address: addresses.baseUsdc, + difference: '0x186a0', + isDecrease: true, + newBalance: '0x11284f', + previousBalance: '0x12aeef', + standard: 'erc20', + }, + { + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + difference: '0x1869f', + isDecrease: false, + newBalance: '0x65101', + previousBalance: '0x4ca62', + standard: 'erc20', + }, + ], + }, + }, + transactions: [ + { + chainId: chainIds.base, + id: 'aave-supply-id', + hash: '0x093844dd6200984f0e27d3c3a76b7a63b360bfb2136213237d693afd2cd69740', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + value: '0x0', + data: '0x617ba037000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda0291300000000000000000000000000000000000000000000000000000000000186a00000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e2920000000000000000000000000000000000000000000000000000000000000000', + }, + simulationData: { + tokenBalanceChanges: [ + { + address: addresses.baseUsdc, + difference: '0x186a0', + isDecrease: true, + newBalance: '0x11284f', + previousBalance: '0x12aeef', + standard: 'erc20', + }, + { + address: '0x4e65fe4dba92790696d040ac24aa414708f5c0ab', + difference: '0x1869f', + isDecrease: false, + newBalance: '0x65101', + previousBalance: '0x4ca62', + standard: 'erc20', + }, + ], + }, + }, + ], + }, + // Real Lido stETH stake: native ETH supplied (recorded in `nativeBalanceChange`), + // only the received stETH appears in `tokenBalanceChanges` as an erc20 increase. + mapsALidoNativeStakeContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'lido-stake-id', + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + status: TransactionStatus.confirmed, + time: 1782912963672, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.lidoStEth, + value: '0xe8d4a51000', + data: '0xa1903eab00000000000000000000000011d00000000000000000000000000000000011d0', + }, + simulationData: { + nativeBalanceChange: { + difference: '0xe8d4a51000', + isDecrease: true, + newBalance: '0x2caa8ba791077', + previousBalance: '0x2cb918f1e2077', + }, + tokenBalanceChanges: [ + { + address: addresses.lidoStEth, + difference: '0xe8d4a50fff', + isDecrease: false, + newBalance: '0xe8d4a50fff', + previousBalance: '0x0', + standard: 'erc20', + }, + ], + }, + }, + nonce: '0xe0', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'lido-stake-id', + hash: '0xd8ca1456ed6305ec3d9c058f28a1ba48eb335ffcffd7d7c4321d3169c29e6a07', + status: TransactionStatus.confirmed, + time: 1782912963672, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.lidoStEth, + value: '0xe8d4a51000', + data: '0xa1903eab00000000000000000000000011d00000000000000000000000000000000011d0', + }, + }, + }, + mapsAWithdrawContractInteractionFrom: { + initialTransaction: { + chainId: chainIds.base, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000000000000030d400000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000004e65fe4dba92790696d040ac24aa414708f5c0ab', + '0x0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + ], + }, + ], + }, + }, + primaryTransaction: { + chainId: chainIds.base, + hash: '0x26f4911467b538702c0945e4ec5e303de44c0c1c174897141d1b548ea3161795', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda029130000000000000000000000000000000000000000000000000000000000030d400000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + '0x0000000000000000000000004e65fe4dba92790696d040ac24aa414708f5c0ab', + '0x0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e292', + ], + }, + ], + }, + }, + }, + mapsAWithdrawContractInteractionWithout: { + initialTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnolog', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnolog', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + }, + }, + mapsBridgeHistoryTokenDataTo: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-swap-id', + hash: '0xbridgeswap', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + value: '0x0', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-swap-id', + hash: '0xbridgeswap', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + value: '0x0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'bridge-swap-id', + hash: '0xbridgeswap', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + value: '0x0', + }, + }, + ], + sourceToken: { + amount: '10000000000000', + assetId: 'eip155:1/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '19546', + assetId: 'eip155:1/erc20:0xACa92e438df0B2401fF60Da7E4337B687a2435dA', + decimals: 6, + direction: 'in', + symbol: 'MUSD', + }, + }, + mapsASwapWithoutADestination: { + initialTransaction: { + chainId: chainIds.base, + id: 'swap-incomplete-id', + hash: '0xswapincomplete', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + value: '0x1', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'swap-incomplete-id', + hash: '0xswapincomplete', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + value: '0x1', + }, + }, + }, + usesABridgeHistoryActivityStatus: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-id', + hash: '0xbridge', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-id', + hash: '0xbridge', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'bridge-id', + hash: '0xbridge', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + }, + ], + activityStatus: 'failed', + }, + mapsALocalBridgeNetworkFee: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.arbitrum, + id: 'bridge-fee-id', + hash: '0xbridgefee', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + txReceipt: { + gasUsed: '0x24405', + effectiveGasPrice: '0x6fc23ac1d', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.arbitrum, + id: 'bridge-fee-id', + hash: '0xbridgefee', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + txReceipt: { + gasUsed: '0x24405', + effectiveGasPrice: '0x6fc23ac1d', + }, + }, + transactions: [ + { + chainId: chainIds.arbitrum, + id: 'bridge-fee-id', + hash: '0xbridgefee', + status: TransactionStatus.confirmed, + time: 1779392463306, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + }, + txReceipt: { + gasUsed: '0x24405', + effectiveGasPrice: '0x6fc23ac1d', + }, + }, + ], + sourceToken: { + amount: '99130000000000', + assetId: 'eip155:42161/slip44:60', + decimals: 18, + direction: 'out', + symbol: 'ETH', + }, + destinationToken: { + amount: '141592', + assetId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/spl-token:EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + decimals: 6, + direction: 'in', + symbol: 'USDC', + }, + }, + mapsSwapMetadataTokenSymbolsTo: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.base, + id: 'swap-id', + hash: '0xswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + nonce: '0x3', + primaryTransaction: { + chainId: chainIds.base, + id: 'swap-id', + hash: '0xswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + transactions: [ + { + chainId: chainIds.base, + id: 'swap-id', + hash: '0xswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + ], + }, + usesNativeSourceSymbolForA: { + initialTransaction: { + chainId: chainIds.base, + id: 'legacy-native-swap-id', + hash: '0xlegacynativeswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'legacy-native-swap-id', + hash: '0xlegacynativeswap', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: '0x246139ca8000', + }, + }, + }, + mapsALegacySwapWithAn: { + initialTransaction: { + chainId: chainIds.base, + id: 'legacy-bad-value-id', + hash: '0xlegacybadvalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: 'not-a-number', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'legacy-bad-value-id', + hash: '0xlegacybadvalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_to: 'USDC', + }, + type: TransactionType.swap, + txParams: { + from: addresses.from, + to: '0x9dda6ef3d919c9bc8885d5560999a3640431e8e6', + value: 'not-a-number', + }, + }, + }, + mapsAWeth9DepositContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'wrap-id', + hash: '0xwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + nonce: '0x4', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'wrap-id', + hash: '0xwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'wrap-id', + hash: '0xwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + ], + }, + treatsAWeth9DepositWithZero: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'wrap-zero-id', + hash: '0xwrapzero', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: '0xd0e30db0', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'wrap-zero-id', + hash: '0xwrapzero', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: '0xd0e30db0', + }, + }, + }, + mapsAWeth9WithdrawContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-id', + hash: '0xunwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${unwrapAmountHex}`, + }, + }, + nonce: '0x5', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-id', + hash: '0xunwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${unwrapAmountHex}`, + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'unwrap-id', + hash: '0xunwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${unwrapAmountHex}`, + }, + }, + ], + }, + mapsAWeth9UnwrapWithMalformed: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-bad-id', + hash: '0xunwrapbad', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: '0x2e1a7d4d', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-bad-id', + hash: '0xunwrapbad', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: '0x2e1a7d4d', + }, + }, + }, + mapsANativeValueContractInteraction: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.mainnet, + id: 'contract-interaction-id', + hash: '0xcontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + nonce: '0x4', + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'contract-interaction-id', + hash: '0xcontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + transactions: [ + { + chainId: chainIds.mainnet, + id: 'contract-interaction-id', + hash: '0xcontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + ], + }, + mapsAZeroValueContractInteraction: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'contract-no-token-id', + hash: '0xcontractnotoken', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + data: '0x12345678', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'contract-no-token-id', + hash: '0xcontractnotoken', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + data: '0x12345678', + }, + }, + }, + mapsAContractInteractionWithoutA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'contract-no-value-id', + hash: '0xcontractnovalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + data: '0x12345678', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'contract-no-value-id', + hash: '0xcontractnovalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + data: '0x12345678', + }, + }, + }, + mapsAContractInteractionWithAn: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'contract-bad-value-id', + hash: '0xcontractbadvalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: 'not-a-number', + data: '0x12345678', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'contract-bad-value-id', + hash: '0xcontractbadvalue', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.to, + value: 'not-a-number', + data: '0x12345678', + }, + }, + }, + mapsALocalContractInteractionWith: + transactionGroups.nftPurchaseErc1155.transactionGroup, + mapsASmartTransactionStatusTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'stx-id', + hash: '0xstx', + status: 'pending', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'stx-id', + hash: '0xstx', + status: 'pending', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsASuccessfulSmartTransactionTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'stx-success-id', + hash: '0xstxsuccess', + status: 'success', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'stx-success-id', + hash: '0xstxsuccess', + status: 'success', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsACancelledSmartTransactionTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'stx-cancel-id', + hash: '0xstxcancel', + status: 'cancelled', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'stx-cancel-id', + hash: '0xstxcancel', + status: 'cancelled', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsAnUnknownSmartTransactionStatus: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'stx-unknown-id', + hash: '0xstxunknown', + status: 'mystery', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'stx-unknown-id', + hash: '0xstxunknown', + status: 'mystery', + isSmartTransaction: true, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsAFailedTransactionReceiptStatus: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'receipt-failed-id', + hash: '0xreceiptfailed', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + txReceipt: { status: '0x0' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'receipt-failed-id', + hash: '0xreceiptfailed', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + txReceipt: { status: '0x0' }, + }, + }, + mapsACancelledTransactionGroupTo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'cancel-id', + hash: '0xcancel', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.cancel, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'cancel-id', + hash: '0xcancel', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.cancel, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsADroppedTransactionToA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'dropped-id', + hash: '0xdropped', + status: TransactionStatus.dropped, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'dropped-id', + hash: '0xdropped', + status: TransactionStatus.dropped, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsAnUnapprovedTransactionToA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unapproved-id', + hash: '0xunapproved', + status: TransactionStatus.unapproved, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unapproved-id', + hash: '0xunapproved', + status: TransactionStatus.unapproved, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + mapsAStatusOutsideTheKnown: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'weird-status-id', + hash: '0xweird', + status: 'weird-status', + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'weird-status-id', + hash: '0xweird', + status: 'weird-status', + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: { from: addresses.from, to: addresses.to, value: '0x1' }, + }, + }, + usesPrecomputedFeesFromTheTransaction: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'fees-id', + hash: '0xfees', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { from: addresses.from, to: addresses.to, value: '0x0' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'fees-id', + hash: '0xfees', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { from: addresses.from, to: addresses.to, value: '0x0' }, + }, + fees: [{ type: 'base', amount: '7', symbol: 'ETH' }], + }, + mapsALocalBridgeFeeUsing: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-gasprice-id', + hash: '0xbridgegasprice', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + gasPrice: '0x10', + }, + txReceipt: { gasUsed: '0x100' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-gasprice-id', + hash: '0xbridgegasprice', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { + from: addresses.from, + to: addresses.to, + value: '0x0', + gasPrice: '0x10', + }, + txReceipt: { gasUsed: '0x100' }, + }, + }, + mapsALocalBridgeWithAn: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-bad-fee-id', + hash: '0xbridgebadfee', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { from: addresses.from, to: addresses.to, value: '0x0' }, + txReceipt: { gasUsed: 'nope', effectiveGasPrice: '0x10' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'bridge-bad-fee-id', + hash: '0xbridgebadfee', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridge, + txParams: { from: addresses.from, to: addresses.to, value: '0x0' }, + txReceipt: { gasUsed: 'nope', effectiveGasPrice: '0x10' }, + }, + }, + mapsATokenTransferWithoutA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'no-contract-id', + hash: '0xnocontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + data: '0xdeadbeef', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'no-contract-id', + hash: '0xnocontract', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + data: '0xdeadbeef', + }, + }, + }, + mapsAWeth9UnwrapWithNon: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-nonhex-id', + hash: '0xunwrapnonhex', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${'z'.repeat(64)}`, + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unwrap-nonhex-id', + hash: '0xunwrapnonhex', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x0', + data: `0x2e1a7d4d${'z'.repeat(64)}`, + }, + }, + }, + fallsBackToInitialTransactionId: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'fallback-id', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.simpleSend, + txParams: {}, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'primary-fallback-id', + status: TransactionStatus.confirmed, + type: TransactionType.simpleSend, + txParams: {}, + }, + }, + handlesTokenTransfersOnAChain: { + initialTransaction: { + chainId: '0x53a', + id: 'unlisted-chain-id', + hash: '0xunlistedchain', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: '0x1234567890123456789012345678901234567890', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + primaryTransaction: { + chainId: '0x53a', + id: 'unlisted-chain-id', + hash: '0xunlistedchain', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: '0x1234567890123456789012345678901234567890', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + }, + mapsATokenTransferWithNo: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'no-data-transfer-id', + hash: '0xnodatatransfer', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { from: addresses.from }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'no-data-transfer-id', + hash: '0xnodatatransfer', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { from: addresses.from }, + }, + }, + wrapsNativeValueOnAChain: { + initialTransaction: { + chainId: '0x539', + id: 'localhost-wrap-id', + hash: '0xlocalhostwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + primaryTransaction: { + chainId: '0x539', + id: 'localhost-wrap-id', + hash: '0xlocalhostwrap', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.wethContractAddress, + value: '0x3782dace9d900000', + data: '0xd0e30db0', + }, + }, + }, + mapsAnMusdConversionWithNo: { + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-no-data-id', + hash: '0xmusdconversionnodata', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + }, + }, + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-conversion-no-data-id', + hash: '0xmusdconversionnodata', + status: TransactionStatus.confirmed, + time: 1779805800000, + type: TransactionType.musdConversion, + txParams: { + from: addresses.from, + to: addresses.lineaMusd, + }, + }, + }, + mapsATokenApproveWithNo: { + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-no-data-id', + hash: '0xapprovenodata', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { from: addresses.from, to: addresses.lineaMusd }, + }, + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'approve-no-data-id', + hash: '0xapprovenodata', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodApprove, + txParams: { from: addresses.from, to: addresses.lineaMusd }, + }, + }, + ignoresWithdrawLogsThatHaveNo: { + initialTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnotopic', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + ], + }, + ], + }, + }, + primaryTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnotopic', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + topics: [ + '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + ], + }, + ], + }, + }, + }, + mapsATokenTransferfromToA: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'transfer-from-id', + hash: '0xtransferfrom', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransferFrom, + txParams: { + from: addresses.from, + to: '0xdac17f958d2ee523a2206206994597c13d831ec7', + value: '0x0', + data: '0x23b872dd0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e29200000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a70600000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'transfer-from-id', + hash: '0xtransferfrom', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransferFrom, + txParams: { + from: addresses.from, + to: '0xdac17f958d2ee523a2206206994597c13d831ec7', + value: '0x0', + data: '0x23b872dd0000000000000000000000009bed78535d6a03a955f1504aadba974d9a29e29200000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a70600000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + }, + mapsASafetransferfromToASend: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'safe-transfer-from-id', + hash: '0xsafetransferfrom', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodSafeTransferFrom, + txParams: { from: addresses.from, to: addresses.to, data: '0x42842e0e' }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'safe-transfer-from-id', + hash: '0xsafetransferfrom', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodSafeTransferFrom, + txParams: { from: addresses.from, to: addresses.to, data: '0x42842e0e' }, + }, + }, + mapsASwapandsendToASwap: { + initialTransaction: { + chainId: chainIds.base, + id: 'swap-and-send-id', + hash: '0xswapandsend', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swapAndSend, + txParams: { from: addresses.from, to: addresses.baseUsdc, value: '0x1' }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'swap-and-send-id', + hash: '0xswapandsend', + status: TransactionStatus.confirmed, + time: 1716367781000, + swapMetaData: { + token_from: 'ETH', + + token_to: 'USDC', + }, + type: TransactionType.swapAndSend, + txParams: { from: addresses.from, to: addresses.baseUsdc, value: '0x1' }, + }, + }, + mapsAPerpsdepositandorderToAnAdd: { + initialTransaction: { + chainId: chainIds.arbitrum, + id: 'perps-deposit-and-order-id', + hash: '0xperpsdepositandorder', + status: TransactionStatus.confirmed, + time: 1781185241609, + type: TransactionType.perpsDepositAndOrder, + txParams: { from: addresses.from, to: addresses.baseUsdc }, + }, + primaryTransaction: { + chainId: chainIds.arbitrum, + id: 'perps-deposit-and-order-id', + hash: '0xperpsdepositandorder', + status: TransactionStatus.confirmed, + time: 1781185241609, + type: TransactionType.perpsDepositAndOrder, + txParams: { from: addresses.from, to: addresses.baseUsdc }, + }, + }, + mapsABridgeapprovalToAnApprove: { + initialTransaction: { + chainId: chainIds.base, + id: 'bridge-approval-id', + hash: '0xbridgeapproval', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridgeApproval, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x095ea7b3', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'bridge-approval-id', + hash: '0xbridgeapproval', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.bridgeApproval, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x095ea7b3', + }, + }, + }, + mapsAShieldsubscriptionapproveToAnApprove: { + initialTransaction: { + chainId: chainIds.base, + id: 'shield-approve-id', + hash: '0xshieldapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.shieldSubscriptionApprove, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x095ea7b3', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'shield-approve-id', + hash: '0xshieldapprove', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.shieldSubscriptionApprove, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0x095ea7b3', + }, + }, + }, + mapsATokenmethodsetapprovalforallToAnApprove: { + initialTransaction: { + chainId: chainIds.base, + id: 'set-approval-for-all-id', + hash: '0xsetapprovalforall', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodSetApprovalForAll, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0xa22cb465', + }, + }, + primaryTransaction: { + chainId: chainIds.base, + id: 'set-approval-for-all-id', + hash: '0xsetapprovalforall', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodSetApprovalForAll, + txParams: { + from: addresses.from, + to: addresses.baseUsdc, + data: '0xa22cb465', + }, + }, + }, + omitsTheAssetidWhenTheToken: { + initialTransaction: { + chainId: chainIds.mainnet, + id: 'unencodable-token-id', + hash: '0xunencodable', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: '0xZZ', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + primaryTransaction: { + chainId: chainIds.mainnet, + id: 'unencodable-token-id', + hash: '0xunencodable', + status: TransactionStatus.confirmed, + time: 1716367781000, + type: TransactionType.tokenMethodTransfer, + txParams: { + from: addresses.from, + to: '0xZZ', + data: '0xa9059cbb000000000000000000000000a6372edd08c857870f9c245a17ee6895307957d500000000000000000000000000000000000000000000000000000000000186a0', + }, + }, + }, + ignoresWithdrawLogsThatOmitTopics: { + initialTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnotopics', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + }, + ], + }, + }, + primaryTransaction: { + chainId: chainIds.base, + hash: '0xwithdrawnotopics', + status: TransactionStatus.confirmed, + time: 1779912434153, + type: TransactionType.contractInteraction, + txParams: { + from: addresses.from, + to: addresses.baseAavePool, + data: '0x69328dec', + }, + txReceipt: { + logs: [ + { + address: addresses.baseUsdc, + data: '0x0000000000000000000000000000000000000000000000000000000000030d40', + }, + ], + }, + }, + }, + mapsMusdclaimToClaimmusdbonusWithFrom: { + hasCancelled: false, + hasRetried: false, + initialTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-claim-id', + hash: '0xmusdclaim', + status: TransactionStatus.submitted, + time: 1778633325000, + type: TransactionType.musdClaim, + txParams: { + from: addresses.from, + to: addresses.merklDistributor, + value: '0x0', + data: encodeMerklClaimCalldata({ + users: [addresses.from], + tokens: [addresses.lineaMusd], + amounts: ['5000000'], + proofs: [[]], + }), + }, + }, + nonce: '0x1', + primaryTransaction: { + chainId: chainIds.lineaMainnet, + id: 'musd-claim-id', + hash: '0xmusdclaim', + status: TransactionStatus.submitted, + time: 1778633325000, + type: TransactionType.musdClaim, + txParams: { + from: addresses.from, + to: addresses.merklDistributor, + value: '0x0', + data: encodeMerklClaimCalldata({ + users: [addresses.from], + tokens: [addresses.lineaMusd], + amounts: ['5000000'], + proofs: [[]], + }), + }, + }, + transactions: [ + { + chainId: chainIds.lineaMainnet, + id: 'musd-claim-id', + hash: '0xmusdclaim', + status: TransactionStatus.submitted, + time: 1778633325000, + type: TransactionType.musdClaim, + txParams: { + from: addresses.from, + to: addresses.merklDistributor, + value: '0x0', + data: encodeMerklClaimCalldata({ + users: [addresses.from], + tokens: [addresses.lineaMusd], + amounts: ['5000000'], + proofs: [[]], + }), + }, + }, + ], + }, +} as const; + +export const localTransactionFixtures = { + chainIds, + addresses, + transactionGroups, + mapInputs, +}; diff --git a/packages/client-utils/test/test-helpers.ts b/packages/client-utils/test/test-helpers.ts new file mode 100644 index 0000000000..0f43a70021 --- /dev/null +++ b/packages/client-utils/test/test-helpers.ts @@ -0,0 +1,125 @@ +import { Interface } from '@ethersproject/abi'; +import type { CaipChainId, Hex } from '@metamask/utils'; + +import { formatAddressToAssetId } from '../src/mappers/helpers/caip'; +import type { KnownTokenMetadata } from '../src/mappers/helpers/token-metadata'; + +const knownTokens: Record< + string, + Record +> = { + 'eip155:1': { + '0xdac17f958d2ee523a2206206994597c13d831ec7': { + symbol: 'USDT', + decimals: 6, + }, + }, + 'eip155:8453': { + '0x833589fcd6edb6e08f4c7c32d4f71b54bda02913': { + symbol: 'USDC', + decimals: 6, + }, + }, + 'eip155:59144': { + '0xaca92e438df0b2401ff60da7e4337b687a2435da': { + symbol: 'mUSD', + decimals: 6, + }, + }, +}; + +/** + * Mock of the package's `getKnownTokenMetadata`. Returns metadata plus the + * encoded CAIP asset id for the small set of tokens the adapter tests rely on. + * + * @param chainId - CAIP-2 (or hex) chain id. + * @param contractAddress - The token contract address. + * @returns The known token metadata, or `undefined`. + */ +export function getKnownTokenMetadata( + chainId: CaipChainId | Hex, + contractAddress?: string, +): KnownTokenMetadata | undefined { + if (!contractAddress) { + return undefined; + } + + const entry = knownTokens[chainId]?.[contractAddress.toLowerCase()]; + + if (!entry) { + return undefined; + } + + const assetId = formatAddressToAssetId(contractAddress, chainId); + + return { ...entry, ...(assetId ? { assetId } : {}) }; +} + +/** + * Encodes ERC-20 `approve(spender, amountOrTokenId)` calldata. + * + * @param address - The spender address. + * @param amountOrTokenId - The approved amount or token id. + * @returns The encoded calldata. + */ +export function buildApproveTransactionData( + address: string, + amountOrTokenId: number, +): Hex { + return new Interface([ + 'function approve(address spender, uint256 amountOrTokenId)', + ]).encodeFunctionData('approve', [address, amountOrTokenId]) as Hex; +} + +/** + * Encodes Permit2 `approve(token, spender, amount, nonce)` calldata. + * + * @param token - The token contract address. + * @param spender - The spender address. + * @param amount - The approved amount. + * @param expiration - The approval expiration / nonce. + * @returns The encoded calldata. + */ +export function buildPermit2ApproveTransactionData( + token: string, + spender: string, + amount: number, + expiration: number, +): Hex { + return new Interface([ + 'function approve(address token, address spender, uint160 amount, uint48 nonce)', + ]).encodeFunctionData('approve', [token, spender, amount, expiration]) as Hex; +} + +const merklClaimAbi = [ + 'function claim(address[] calldata users, address[] calldata tokens, uint256[] calldata amounts, bytes32[][] calldata proofs)', +]; + +/** + * Encodes Merkl `claim(...)` calldata used to exercise the mUSD claim path. + * + * @param args - The claim arguments. + * @param args.users - The claiming user addresses. + * @param args.tokens - The claimed token addresses. + * @param args.amounts - The claimed amounts. + * @param args.proofs - The Merkle proofs. + * @returns The encoded calldata. + */ +export function encodeMerklClaimCalldata({ + users, + tokens, + amounts, + proofs, +}: { + users: string[]; + tokens: string[]; + amounts: string[]; + proofs: string[][]; +}): Hex { + return new Interface(merklClaimAbi).encodeFunctionData('claim', [ + users, + tokens, + amounts, + proofs, + ]) as Hex; +} diff --git a/yarn.lock b/yarn.lock index 9e514a392f..c6d7cd888b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6129,7 +6129,14 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/client-utils@workspace:packages/client-utils" dependencies: + "@ethersproject/abi": "npm:^5.7.0" "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/contract-metadata": "npm:^2.4.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/core-backend": "npm:^6.5.0" + "@metamask/keyring-api": "npm:^23.3.0" + "@metamask/transaction-controller": "npm:^68.2.2" + "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" From cb918c7af70b6c241a20256fff384d12f1c2a04c Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 2 Jul 2026 12:39:50 -0400 Subject: [PATCH 2/7] fix: resolve Permit2 approval token from calldata Permit2 `approve(token, spender, amount, expiration)` transactions are classified as `tokenMethodApprove`, but `txParams.to` is the Permit2 contract rather than the approved ERC-20. The mapper previously used `to` as the spending-cap token, so Permit2 approvals showed the wrong `assetId`/symbol. Detect the Permit2 approve selector and decode the approved token from the first calldata argument instead. Co-authored-by: Cursor --- packages/client-utils/src/mappers/constants.ts | 2 ++ .../src/mappers/local-transaction-mapper.test.ts | 4 +++- .../client-utils/src/mappers/local-transaction-mapper.ts | 9 +++++++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/client-utils/src/mappers/constants.ts b/packages/client-utils/src/mappers/constants.ts index 3124319cbd..ba9aea7ecb 100644 --- a/packages/client-utils/src/mappers/constants.ts +++ b/packages/client-utils/src/mappers/constants.ts @@ -25,6 +25,8 @@ export const withdrawMethodIds = new Set([ export const wrapMethodIds = new Set(['0xd0e30db0']); export const unwrapMethodIds = new Set(['0x2e1a7d4d']); +export const permit2ApproveMethodId = '0x87517c45'; + export const tokenTransferLogTopicHash = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.test.ts b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts index 47304c4d60..e1016b3923 100644 --- a/packages/client-utils/src/mappers/local-transaction-mapper.test.ts +++ b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts @@ -177,8 +177,10 @@ describe('mapLocalTransaction', () => { data: { token: { direction: 'out', + symbol: 'mUSD', + decimals: 18, assetId: formatAddressToAssetId( - localTransactionFixtures.addresses.permit2Address, + localTransactionFixtures.addresses.lineaMusd, 'eip155:59144', ), }, diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.ts b/packages/client-utils/src/mappers/local-transaction-mapper.ts index eb6673dab4..db91e40dbd 100644 --- a/packages/client-utils/src/mappers/local-transaction-mapper.ts +++ b/packages/client-utils/src/mappers/local-transaction-mapper.ts @@ -4,6 +4,7 @@ import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils'; import type { Fee, ActivityItem, TokenAmount } from '../types'; import { + permit2ApproveMethodId, swapsWrappedTokensAddresses, supplyMethodIds, tokenTransferLogTopicHash, @@ -55,8 +56,12 @@ export function mapLocalTransaction( id: primaryId, } = primaryTransaction; const methodId = txData?.slice(0, 10); - const tokenContractAddress = - transferInformation?.contractAddress ?? (to || undefined); + // Permit2 approvals encode the approved ERC-20 as the first calldata argument; + // `to` is the Permit2 contract, so decode the real token from calldata instead. + const isPermit2Approve = methodId === permit2ApproveMethodId; + const tokenContractAddress = isPermit2Approve + ? `0x${(txData as string).slice(34, 74)}` + : (transferInformation?.contractAddress ?? (to || undefined)); const chainId = toCaipChainId( KnownCaipNamespace.Eip155, Number.parseInt(initialTransaction.chainId, 16).toString(), From 4b7e053e1d1a4792099c64b218d43503b88bf786 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 2 Jul 2026 12:44:58 -0400 Subject: [PATCH 3/7] fix: require NFT buy payment to reach counterparty or tx target On the buy path, getNftPaymentTransfer treated any outbound native (`normal`) transfer as the NFT payment, so an inbound NFT plus an unrelated native send in the same indexed transaction could be mapped as `nftBuy` instead of `receive`. Only count a sent transfer as payment when it goes to the NFT counterparty (direct sale) or to the contract being called (marketplace/router via `transaction.to`). Co-authored-by: Cursor --- .../mappers/api-transaction-mapper.test.ts | 16 ++++++++++ .../src/mappers/api-transaction-mapper.ts | 1 + .../src/mappers/helpers/transactions.ts | 7 ++++- .../test/fixtures/api-transactions.ts | 31 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) 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 47a7e186fb..16dc3b6140 100644 --- a/packages/client-utils/src/mappers/api-transaction-mapper.test.ts +++ b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts @@ -400,6 +400,22 @@ describe('mapApiTransaction', () => { }); }); + it('maps an inbound NFT with an unrelated native send to a Receive activity', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAnNftReceiveWithUnrelatedNativeSend, + ); + + expect(item).toMatchObject({ + type: 'receive', + data: { + from: nftSellerAddress, + to: nftBuyerAddress, + token: { direction: 'in', symbol: 'FLUF World' }, + }, + }); + expect(item.type).not.toBe('nftBuy'); + }); + it('maps a plain NFT send (no NFT exchange, no payment) to a Send activity', () => { const item = mapApiTransaction( apiTransactionFixtures.mapArgs.mapsAPlainNftSendNo, diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.ts b/packages/client-utils/src/mappers/api-transaction-mapper.ts index 5d60a768d2..5b0fe52003 100644 --- a/packages/client-utils/src/mappers/api-transaction-mapper.ts +++ b/packages/client-utils/src/mappers/api-transaction-mapper.ts @@ -139,6 +139,7 @@ export function mapApiTransaction({ sentTransfer, sentNativeTransfer, nftCounterparty: receivedNftTransfer.from, + transactionTo: transaction.to, subjectAddress, }); diff --git a/packages/client-utils/src/mappers/helpers/transactions.ts b/packages/client-utils/src/mappers/helpers/transactions.ts index 6e686bdcc6..332ce98092 100644 --- a/packages/client-utils/src/mappers/helpers/transactions.ts +++ b/packages/client-utils/src/mappers/helpers/transactions.ts @@ -191,6 +191,7 @@ export function getNftPaymentTransfer({ sentNativeTransfer, nftCounterparty, transactionFrom, + transactionTo, subjectAddress, }: { side: 'buy' | 'sell'; @@ -199,6 +200,7 @@ export function getNftPaymentTransfer({ sentNativeTransfer?: ValueTransfer; nftCounterparty: string; transactionFrom?: string; + transactionTo?: string; subjectAddress: string; }): ValueTransfer | undefined { const isFungible = (transfer?: ValueTransfer): boolean => @@ -210,9 +212,12 @@ export function getNftPaymentTransfer({ continue; } + // Only count a payment that goes to the NFT counterparty (direct sale) or + // to the contract being called (marketplace/router). This avoids treating + // an unrelated native send in the same transaction as the NFT payment. if ( equalsIgnoreCase(transfer.to, nftCounterparty) || - transfer.transferType === 'normal' + equalsIgnoreCase(transfer.to, transactionTo as string) ) { return transfer; } diff --git a/packages/client-utils/test/fixtures/api-transactions.ts b/packages/client-utils/test/fixtures/api-transactions.ts index 283f10c44f..0ae7cf891f 100644 --- a/packages/client-utils/test/fixtures/api-transactions.ts +++ b/packages/client-utils/test/fixtures/api-transactions.ts @@ -539,6 +539,33 @@ const transactions = { }, ], }, + nftReceiveWithUnrelatedNativeSend: { + timestamp: '2026-06-04T19:31:47.000Z', + chainId: 1, + from: addresses.nftBuyerAddress, + to: '0x0000000000000068f116a894984e2db1123eb395', + transactionCategory: 'CONTRACT_CALL', + valueTransfers: [ + { + from: addresses.nftSellerAddress, + to: addresses.nftBuyerAddress, + amount: 1, + tokenId: '57', + contractAddress: '0x6fad73936527d2a82aea5384d252462941b44042', + name: 'FLUF World', + transferType: 'erc1155', + }, + { + from: addresses.nftBuyerAddress, + to: '0x1111111111111111111111111111111111111111', + amount: '89992880000000', + decimal: 18, + symbol: 'ETH', + name: 'Ether', + transferType: 'normal', + }, + ], + }, mapsAPlainNftSendNo: { timestamp: '2026-02-23T22:04:23.000Z', chainId: 1, @@ -1065,6 +1092,10 @@ const mapArgs = { subjectAddress: addresses.subjectAddress, transaction: transactions.mapsAnNftTransferReceivedWithout, }, + mapsAnNftReceiveWithUnrelatedNativeSend: { + subjectAddress: addresses.nftBuyerAddress, + transaction: transactions.nftReceiveWithUnrelatedNativeSend, + }, mapsAPlainNftSendNo: { subjectAddress: addresses.subjectAddress, transaction: transactions.mapsAPlainNftSendNo, From 8108d87ab0f3334d0a9e0fa0a0de0c3642850448 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 2 Jul 2026 12:48:05 -0400 Subject: [PATCH 4/7] refactor: keep local mapper thin for Permit2 approvals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rather than decoding the approved token from Permit2 calldata, the local mapper now just classifies the activity as an approve and omits the token when `to` is the Permit2 contract. This keeps the local mapper thin — its job is to identify the ActivityKind — and defers accurate token data to the API mapper, which has richer information. Co-authored-by: Cursor --- .../mappers/local-transaction-mapper.test.ts | 23 +++++-------------- .../src/mappers/local-transaction-mapper.ts | 8 ++++--- .../test/fixtures/local-transactions.ts | 3 +-- 3 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.test.ts b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts index e1016b3923..dd5feff765 100644 --- a/packages/client-utils/src/mappers/local-transaction-mapper.test.ts +++ b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts @@ -167,25 +167,14 @@ describe('mapLocalTransaction', () => { }, }); }); - it('resolves Permit2 approval token address from calldata', () => { + it('maps a Permit2 approve to an approve spending cap without the Permit2 contract as the token', () => { const item = mapLocalTransaction( - localTransactionFixtures.mapInputs - .resolvesPermit2ApprovalTokenAddressFrom, + localTransactionFixtures.mapInputs.mapsAPermit2Approve, ); - expect(item).toMatchObject({ - type: 'approveSpendingCap', - data: { - token: { - direction: 'out', - symbol: 'mUSD', - decimals: 18, - assetId: formatAddressToAssetId( - localTransactionFixtures.addresses.lineaMusd, - 'eip155:59144', - ), - }, - }, - }); + expect(item.type).toBe('approveSpendingCap'); + const token = + item.type === 'approveSpendingCap' ? item.data.token : 'unset'; + expect(token).toBeUndefined(); }); it('falls back to transferInformation when txParams.to is not a valid address', () => { const item = mapLocalTransaction( diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.ts b/packages/client-utils/src/mappers/local-transaction-mapper.ts index db91e40dbd..2a4a9804fe 100644 --- a/packages/client-utils/src/mappers/local-transaction-mapper.ts +++ b/packages/client-utils/src/mappers/local-transaction-mapper.ts @@ -56,11 +56,13 @@ export function mapLocalTransaction( id: primaryId, } = primaryTransaction; const methodId = txData?.slice(0, 10); - // Permit2 approvals encode the approved ERC-20 as the first calldata argument; - // `to` is the Permit2 contract, so decode the real token from calldata instead. + // Permit2 approvals use the Permit2 contract as `to`, not the approved token. + // Keep this mapper thin: still classify the activity as an approve, but omit + // the token rather than surfacing the wrong one — the API mapper provides + // accurate token data. const isPermit2Approve = methodId === permit2ApproveMethodId; const tokenContractAddress = isPermit2Approve - ? `0x${(txData as string).slice(34, 74)}` + ? undefined : (transferInformation?.contractAddress ?? (to || undefined)); const chainId = toCaipChainId( KnownCaipNamespace.Eip155, diff --git a/packages/client-utils/test/fixtures/local-transactions.ts b/packages/client-utils/test/fixtures/local-transactions.ts index 99df9f9f6a..6de26dc297 100644 --- a/packages/client-utils/test/fixtures/local-transactions.ts +++ b/packages/client-utils/test/fixtures/local-transactions.ts @@ -613,7 +613,7 @@ const mapInputs = { }, ], }, - resolvesPermit2ApprovalTokenAddressFrom: { + mapsAPermit2Approve: { hasCancelled: false, hasRetried: false, initialTransaction: { @@ -673,7 +673,6 @@ const mapInputs = { }, }, ], - contractTokenMetadata: { symbol: 'mUSD', decimals: 18 }, }, fallsBackToTransferinformationWhenTxparams: { hasCancelled: false, From 101edcee4de11b09779cfc49009e7a726cca3d48 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 2 Jul 2026 12:52:31 -0400 Subject: [PATCH 5/7] fix: attach contract interaction token metadata without amount The default contractInteraction path only attached `token` when `token.amount` was truthy, dropping symbol/direction/assetId when the value transfer had no amount. Attach the token whenever it is defined, since getTokenAmountFromTransfer only returns a token with meaningful metadata. Co-authored-by: Cursor --- .../src/mappers/api-transaction-mapper.test.ts | 15 +++++++++++++++ .../src/mappers/api-transaction-mapper.ts | 2 +- .../test/fixtures/api-transactions.ts | 14 ++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) 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 16dc3b6140..502beef9ed 100644 --- a/packages/client-utils/src/mappers/api-transaction-mapper.test.ts +++ b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts @@ -776,10 +776,25 @@ describe('mapApiTransaction', () => { to: bscUniversalRouter, transactionCategory: 'CONTRACT_CALL', transactionProtocol: 'GENERIC', + token: { + direction: 'out', + symbol: 'BNB', + }, }, }); }); + it('maps a contract interaction with no value transfers without a token', () => { + const item = mapApiTransaction( + apiTransactionFixtures.mapArgs.mapsAContractCallWithNoTransfers, + ); + + expect(item.type).toBe('contractInteraction'); + const token = + item.type === 'contractInteraction' ? item.data.token : 'unset'; + expect(token).toBeUndefined(); + }); + it('maps the reported generic contract call to a contract interaction with its token amount', () => { const item = mapApiTransaction( apiTransactionFixtures.mapArgs.mapsTheReportedGenericContractCall, diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.ts b/packages/client-utils/src/mappers/api-transaction-mapper.ts index 5b0fe52003..677b71dd4a 100644 --- a/packages/client-utils/src/mappers/api-transaction-mapper.ts +++ b/packages/client-utils/src/mappers/api-transaction-mapper.ts @@ -383,7 +383,7 @@ export function mapApiTransaction({ to: transaction.to, transactionCategory, transactionProtocol: transaction.transactionProtocol, - ...(token?.amount ? { token } : {}), + ...(token ? { token } : {}), }, }; } diff --git a/packages/client-utils/test/fixtures/api-transactions.ts b/packages/client-utils/test/fixtures/api-transactions.ts index 0ae7cf891f..72e2f24bbe 100644 --- a/packages/client-utils/test/fixtures/api-transactions.ts +++ b/packages/client-utils/test/fixtures/api-transactions.ts @@ -1023,6 +1023,16 @@ const transactions = { }, ], }, + mapsAContractCallWithNoTransfers: { + hash: '0xnotransfers', + timestamp: '2026-05-12T13:37:47.000Z', + chainId: 1, + from: addresses.subjectAddress, + to: '0x2222222222222222222222222222222222222222', + methodId: '0xdeadbeef', + transactionCategory: 'CONTRACT_CALL', + valueTransfers: [], + }, } as const satisfies Record; const mapArgs = { @@ -1184,6 +1194,10 @@ const mapArgs = { subjectAddress: addresses.subjectAddress, transaction: transactions.mapsAnUnrecognizedCategoryWithOnly, }, + mapsAContractCallWithNoTransfers: { + subjectAddress: addresses.subjectAddress, + transaction: transactions.mapsAContractCallWithNoTransfers, + }, } as const; export const apiTransactionFixtures = { From b2ab6d9b9573a801bd6dc917db0aed09c42c8564 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 2 Jul 2026 13:06:15 -0400 Subject: [PATCH 6/7] refactor: keep local mapper thin for NFT buys The local mapper only needs to classify an incoming NFT with native value as an nftBuy. Drop the placeholder `token: { direction: 'in' }` (no symbol/assetId/amount) and let the API mapper provide token and payment details. Co-authored-by: Cursor --- .../src/mappers/local-transaction-mapper.test.ts | 3 --- .../client-utils/src/mappers/local-transaction-mapper.ts | 5 ++--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.test.ts b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts index dd5feff765..2888cef1e6 100644 --- a/packages/client-utils/src/mappers/local-transaction-mapper.test.ts +++ b/packages/client-utils/src/mappers/local-transaction-mapper.test.ts @@ -687,9 +687,6 @@ describe('mapLocalTransaction', () => { hash: '0x2fda37c5b591c30367649c3c317621429bb5c59ff6a77b0a8cd48b56897168bc', data: { from, - token: { - direction: 'in', - }, }, }); }); diff --git a/packages/client-utils/src/mappers/local-transaction-mapper.ts b/packages/client-utils/src/mappers/local-transaction-mapper.ts index 2a4a9804fe..1678938104 100644 --- a/packages/client-utils/src/mappers/local-transaction-mapper.ts +++ b/packages/client-utils/src/mappers/local-transaction-mapper.ts @@ -387,14 +387,13 @@ export function mapLocalTransaction( ); if (incomingNftBalanceChange && hasNativeValue) { + // Keep this mapper thin: classify the activity as an NFT buy and let the + // API mapper provide the token/payment details. return { type: 'nftBuy', ...common, data: { from, - token: { - direction: 'in', - }, }, }; } From f273ba4bf2594493fa9a3175b48ad4a56d444e07 Mon Sep 17 00:00:00 2001 From: Francis Nepomuceno Date: Thu, 2 Jul 2026 13:20:07 -0400 Subject: [PATCH 7/7] fix: require wrapped-native target for API wrap classification mapApiTransaction classified a `wrap` on the WETH deposit selector plus any inbound transfer, without confirming transaction.to is the chain's wrapped-native contract (the local mapper already requires this via swapsWrappedTokensAddresses). Gate the wrap classification on transaction.to matching the chain's wrapped-native address. Co-authored-by: Cursor --- .../mappers/api-transaction-mapper.test.ts | 28 +++++++++++++++++++ .../src/mappers/api-transaction-mapper.ts | 15 +++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) 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 502beef9ed..2869421cf1 100644 --- a/packages/client-utils/src/mappers/api-transaction-mapper.test.ts +++ b/packages/client-utils/src/mappers/api-transaction-mapper.test.ts @@ -961,6 +961,34 @@ describe('mapApiTransaction', () => { expect(item.type).not.toBe('wrap'); }); + it('does not map a wrap when `to` is not the chain wrapped-native contract', () => { + const { transaction, subjectAddress: fixtureSubjectAddress } = + apiTransactionFixtures.mapArgs.mapsAWethDepositToA; + const item = mapApiTransaction({ + subjectAddress: fixtureSubjectAddress, + transaction: { + ...transaction, + to: '0x1111111111111111111111111111111111111111', + }, + }); + + expect(item.type).not.toBe('wrap'); + }); + + it('does not map a wrap on a chain without a known wrapped-native contract', () => { + const { transaction, subjectAddress: fixtureSubjectAddress } = + apiTransactionFixtures.mapArgs.mapsAWethDepositToA; + const item = mapApiTransaction({ + subjectAddress: fixtureSubjectAddress, + transaction: { + ...transaction, + chainId: 999999, + }, + }); + + expect(item.type).not.toBe('wrap'); + }); + it('maps an unrecognized category with only an inbound transfer to a contract interaction with an inbound token', () => { const item = mapApiTransaction( apiTransactionFixtures.mapArgs.mapsAnUnrecognizedCategoryWithOnly, diff --git a/packages/client-utils/src/mappers/api-transaction-mapper.ts b/packages/client-utils/src/mappers/api-transaction-mapper.ts index 677b71dd4a..e18751b0e8 100644 --- a/packages/client-utils/src/mappers/api-transaction-mapper.ts +++ b/packages/client-utils/src/mappers/api-transaction-mapper.ts @@ -14,6 +14,7 @@ import type { import { nativeTokenAddress, supplyMethodIds, + swapsWrappedTokensAddresses, withdrawMethodIds, wrapMethodIds, } from './constants'; @@ -313,7 +314,19 @@ export function mapApiTransaction({ }; } - if (receivedTransfer && wrapMethodIds.has(normalizedMethodId)) { + const wrappedNativeAddress = + swapsWrappedTokensAddresses[ + `0x${transaction.chainId.toString( + 16, + )}` as keyof typeof swapsWrappedTokensAddresses + ]; + + if ( + receivedTransfer && + wrapMethodIds.has(normalizedMethodId) && + wrappedNativeAddress && + equalsIgnoreCase(transaction.to, wrappedNativeAddress) + ) { return { type: 'wrap', ...common,