Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Narrow `AssetsControllerState` types from `Json` to semantic types: `assetsMetadata` → `AssetMetadata`, `assetsBalance` → `AssetBalance`, `assetsPrice` → `AssetPrice`, `assetPreferences` → `AssetPreferences`, `customAssets` → `Caip19AssetId[]` ([#7777](https://github.com/MetaMask/core/pull/7777))

- Replace `viem` dependency with manual ABI encoding/decoding in `MulticallClient` ([#7839](https://github.com/MetaMask/core/pull/7839))

## [0.1.0]

### Added
Expand All @@ -27,7 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add batch utilities (`divideIntoBatches`, `reduceInBatchesSerially`) for processing arrays in batches ([#7677](https://github.com/MetaMask/core/pull/7677))
- Add `TokenDetector` service for detecting ERC-20 tokens with non-zero balances on a chain ([#7683](https://github.com/MetaMask/core/pull/7683))
- Add `BalanceFetcher` service for fetching token balances for user's imported/detected tokens ([#7684](https://github.com/MetaMask/core/pull/7684))
- Add `viem` dependency for ABI encoding/decoding in MulticallClient
Copy link
Contributor

Choose a reason for hiding this comment

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

Why did we remove this changelog entry if this version is already released ?

- Add configurable polling intervals for `RpcDataSource` via `RpcDataSourceConfig` in `initDataSources` ([#7709](https://github.com/MetaMask/core/pull/7709))
- Add comprehensive unit tests for data sources (`AccountsApiDataSource`, `BackendWebsocketDataSource`, `PriceDataSource`, `TokenDataSource`, `SnapDataSource`), `DetectionMiddleware`, and `AssetsController` ([#7714](https://github.com/MetaMask/core/pull/7714))

Expand All @@ -38,7 +39,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Store native token metadata (type, symbol, name, decimals) in `assetsMetadata` derived from `NetworkController` chain status ([#7752](https://github.com/MetaMask/core/pull/7752))
- `AccountsApiDataSource` now includes `assetsMetadata` in response with token info from V5 API ([#7752](https://github.com/MetaMask/core/pull/7752))
- Bump `@metamask/keyring-controller` from `^25.0.0` to `^25.1.0` ([#7713](https://github.com/MetaMask/core/pull/7713))
- Refactor `MulticallClient` to use viem for ABI encoding/decoding instead of manual implementation
- Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709))
- Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709))

Expand Down
3 changes: 1 addition & 2 deletions packages/assets-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@
"@metamask/utils": "^11.9.0",
"async-mutex": "^0.5.0",
"bignumber.js": "^9.1.2",
"lodash": "^4.17.21",
"viem": "^2.44.4"
"lodash": "^4.17.21"
},
"devDependencies": {
"@metamask/auto-changelog": "^3.4.4",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { Hex } from '@metamask/utils';
import { encodeAbiParameters, parseAbiParameters } from 'viem';

import {
decodeAggregate3Response,
Expand Down Expand Up @@ -36,39 +35,111 @@ const MAINNET_CHAIN_ID: ChainId = '0x1' as ChainId;
const UNSUPPORTED_CHAIN_ID: ChainId = '0xfffff' as ChainId;

// =============================================================================
// HELPER FUNCTIONS
// MANUAL ABI ENCODING HELPERS FOR TESTS
// =============================================================================

function leftPad32(hexNo0x: string): string {
return hexNo0x.padStart(64, '0');
}

function rightPad32Bytes(hexNo0x: string): string {
const byteLen = Math.ceil(hexNo0x.length / 2);
const paddedByteLen = Math.ceil(byteLen / 32) * 32;
const paddedHexLen = paddedByteLen * 2;
return hexNo0x.padEnd(paddedHexLen, '0');
}

function encodeUint256(value: bigint): string {
return leftPad32(value.toString(16));
}

function encodeBool(value: boolean): string {
return leftPad32(value ? '1' : '0');
}

/**
* Build a mock aggregate3 response using viem's ABI encoding.
* Each result is (success: bool, returnData: bytes).
* Build a mock aggregate3 response using manual ABI encoding.
* Encodes (bool success, bytes returnData)[]
*
* @param results - Array of result objects with success flag and optional balance
* @returns The encoded aggregate3 response as hex
*/
function buildMockAggregate3Response(
results: { success: boolean; balance?: string }[],
): `0x${string}` {
// Encode each result's returnData (balance as uint256)
const resultsWithEncodedData = results.map((result) => {
let returnData: `0x${string}` = '0x';
// First, encode each result's returnData (balance as uint256, or empty bytes)
const encodedResults = results.map((result) => {
let returnDataHex = '';
if (result.balance !== undefined) {
// Encode the balance as a uint256
returnData = encodeAbiParameters(parseAbiParameters('uint256'), [
BigInt(result.balance),
]);
// uint256 is 32 bytes
returnDataHex = encodeUint256(BigInt(result.balance));
}
return {
success: result.success,
returnData,
returnDataHex,
};
});

// Encode the full aggregate3 response: (bool success, bytes returnData)[]
return encodeAbiParameters(
parseAbiParameters('(bool success, bytes returnData)[]'),
[resultsWithEncodedData],
);
// ABI encoding for (bool, bytes)[]:
// - Offset to array data (0x20 = 32)
// - Array length
// - Offsets to each tuple (relative to start of offsets area)
// - Each tuple: bool, offset to bytes, bytes (length + data)

const parts: string[] = [];

// Word 0: offset to array (always 0x20 for single return value)
parts.push(encodeUint256(32n));

// At offset 32: array length
parts.push(encodeUint256(BigInt(results.length)));

// Calculate tuple offsets and build tuple data
// Offsets area starts after length word
// Each tuple offset is relative to the start of the offsets area

// First, calculate the size of the offsets area
const offsetsAreaSize = results.length * 32;

// Build tuple data and collect offsets
const tupleOffsets: bigint[] = [];
const tupleDataParts: string[] = [];

let currentOffset = offsetsAreaSize; // Start after all offsets

for (const { success, returnDataHex } of encodedResults) {
tupleOffsets.push(BigInt(currentOffset));

// Tuple: bool (32 bytes) + offset to bytes (32 bytes) + bytes data
const boolEncoded = encodeBool(success);

// Offset to bytes within tuple is always 64 (after bool and offset words)
const bytesOffsetInTuple = encodeUint256(64n);

// Bytes encoding: length (32 bytes) + padded data
const bytesLength = returnDataHex.length / 2;
const bytesLengthEncoded = encodeUint256(BigInt(bytesLength));
const bytesPadded = rightPad32Bytes(returnDataHex);

const tupleData = `${boolEncoded}${bytesOffsetInTuple}${bytesLengthEncoded}${bytesPadded}`;
tupleDataParts.push(tupleData);

// Calculate size of this tuple: bool(32) + offset(32) + length(32) + padded data
const tupleSize = 32 + 32 + 32 + bytesPadded.length / 2;
currentOffset += tupleSize;
}

// Add tuple offsets
for (const offset of tupleOffsets) {
parts.push(encodeUint256(offset));
}

// Add tuple data
for (const tupleData of tupleDataParts) {
parts.push(tupleData);
}

return `0x${parts.join('')}`;
}

// =============================================================================
Expand Down Expand Up @@ -687,6 +758,68 @@ describe('encodeAggregate3', () => {
expect(result).toMatch(/^0x82ad56cb/u);
expect(typeof result).toBe('string');
});

it('should encode with correct ABI structure including tuple offsets', () => {
// Test with 2 calls to verify offset calculation
const calls = [
{
target: '0x1111111111111111111111111111111111111111' as Address,
allowFailure: true,
callData: '0xabcd' as Hex, // 2 bytes of data
},
{
target: '0x2222222222222222222222222222222222222222' as Address,
allowFailure: false,
callData: '0x1234' as Hex, // 2 bytes of data
},
];

const result = encodeAggregate3(calls);
const hexNo0x = result.slice(2); // Remove 0x prefix

// Helper to read a 32-byte word at a given byte offset
const readWordAtByte = (byteOffset: number): string => {
const start = byteOffset * 2;
return hexNo0x.slice(start, start + 64);
};

// Word at byte 0: selector (4 bytes) - skip for structure validation
// After selector (byte 4): offset to array (should be 0x20 = 32)
const arrayOffset = BigInt(`0x${readWordAtByte(4)}`);
expect(arrayOffset).toBe(32n);

// At byte 4 + 32 = 36: array length (should be 2)
const arrayLength = BigInt(`0x${readWordAtByte(36)}`);
expect(arrayLength).toBe(2n);

// At byte 36 + 32 = 68: first tuple offset (relative to offsets area start)
// Offsets area is 2 * 32 = 64 bytes, so first tuple is at offset 64
const tuple0Offset = BigInt(`0x${readWordAtByte(68)}`);
expect(tuple0Offset).toBe(64n); // 2 offsets * 32 bytes each

// At byte 68 + 32 = 100: second tuple offset
// First tuple size: target(32) + allowFailure(32) + bytesOffset(32) + bytesLen(32) + bytesPadded(32) = 160
const tuple1Offset = BigInt(`0x${readWordAtByte(100)}`);
expect(tuple1Offset).toBe(64n + 160n); // 224

// Verify first tuple at correct position
// Tuple 0 starts at: offsetsAreaStart + tuple0Offset = 68 + 64 = 132
const tuple0Start = 132;

// First word of tuple 0: target address (padded to 32 bytes)
const tuple0Target = readWordAtByte(tuple0Start);
expect(tuple0Target.toLowerCase()).toBe(
'0000000000000000000000001111111111111111111111111111111111111111',
);

// Second word: allowFailure (should be 1 for true)
const tuple0AllowFailure = BigInt(`0x${readWordAtByte(tuple0Start + 32)}`);
expect(tuple0AllowFailure).toBe(1n);

// Third word: offset to bytes (always 0x60 = 96)
const tuple0BytesOffset = BigInt(`0x${readWordAtByte(tuple0Start + 64)}`);
expect(tuple0BytesOffset).toBe(96n);
});
});

describe('decodeAggregate3Response', () => {
Expand Down
Loading
Loading