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

Filter by extension

Filter by extension

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

### Fixed

- Run `RpcFallbackMiddleware` on Accounts API poll updates (`handleAssetsUpdate`), not only in the forced `getAssets` fast pipeline, so a stale amount for tokens omitted from poll responses no longer survives between forced refreshes while the wallet sits open ([#10078](https://github.com/MetaMask/core/pull/10078))
- WebSocket, RPC, and Snap updates are excluded: WebSocket pushes are incremental single-asset updates where absence is not staleness, and RPC/Snap updates must not re-trigger RPC
- Fix stale balances shown right after a transaction confirms: the post-confirmation refresh now calls `getAssets` with `bypassServerCache: true`, since WebSocket events do not invalidate the Accounts API's server-side cache and a plain refetch within its 60s window returns the pre-transaction snapshot ([#10068](https://github.com/MetaMask/core/pull/10068))
- Fix stale balances surviving in state when the Accounts API returns no entry for an asset it does not index (or reports an untrusted `0`), which the `merge` update kept as the previous amount ([#10061](https://github.com/MetaMask/core/pull/10061))
- `RpcFallbackMiddleware` now re-reads EVM assets tracked in state (`assetsBalance` or `customAssets`) whose balance is empty in the current response, passing them to `RpcDataSource` as `customAssets`, in addition to its existing retry of chains in `response.errors`. Staking vault assets and assets on chains outside the request or the account's supported set are excluded.
Expand Down
71 changes: 71 additions & 0 deletions packages/assets-controller/src/AssetsController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
import type { AccountsApiDataSourceConfig } from './data-sources/AccountsApiDataSource.js';
import type { PriceDataSourceConfig } from './data-sources/PriceDataSource.js';
import { PriceDataSource } from './data-sources/PriceDataSource.js';
import { RpcDataSource } from './data-sources/RpcDataSource.js';
import { TokenDataSource } from './data-sources/TokenDataSource.js';
import { buildDefaultAssetsInfo } from './defaults.js';
import type { Assets3346MigrationState } from './migrations/healAssetsInfoMetadata.js';
Expand Down Expand Up @@ -1763,6 +1764,76 @@ describe('AssetsController', () => {
});

describe('handleAssetsUpdate', () => {
it('re-reads tracked assets an Accounts API poll left empty via the RPC fallback', async () => {
const initialState: Partial<AssetsControllerState> = {
assetsBalance: {
[MOCK_ACCOUNT_ID]: { [MOCK_ASSET_ID]: { amount: '1000' } },
},
};
const rpcMiddleware = jest.fn(
async (ctx: unknown, next: (ctx: unknown) => Promise<unknown>) =>
next(ctx),
);
const rpcMiddlewareGetter = jest
.spyOn(RpcDataSource.prototype, 'assetsMiddleware', 'get')
.mockReturnValue(rpcMiddleware as never);

await withController({ state: initialState }, async ({ controller }) => {
const pollRequest: DataRequest = {
accountsWithSupportedChains: [
{
account: createMockInternalAccount(),
supportedChains: ['eip155:1' as ChainId],
},
],
chainIds: ['eip155:1' as ChainId],
dataTypes: ['balance'],
};

// The poll response omits MOCK_ASSET_ID even though state tracks it —
// the fallback must hand it to RPC for an on-chain re-read.
await controller.handleAssetsUpdate(
{
updateMode: 'merge',
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_NATIVE_ASSET_ID]: { amount: '2' },
},
},
},
'AccountsApiDataSource',
pollRequest,
);

expect(rpcMiddleware).toHaveBeenCalledTimes(1);
const [rpcCtx] = rpcMiddleware.mock.calls[0] as [
{ request: DataRequest },
];
expect(rpcCtx.request.chainIds).toStrictEqual(['eip155:1']);
expect(rpcCtx.request.customAssets).toContain(MOCK_ASSET_ID);

// Same update from the WebSocket source must NOT trigger the
// fallback: its pushes are incremental single-asset updates, so an
// absent asset is not stale there.
rpcMiddleware.mockClear();
await controller.handleAssetsUpdate(
{
updateMode: 'merge',
assetsBalance: {
[MOCK_ACCOUNT_ID]: {
[MOCK_NATIVE_ASSET_ID]: { amount: '2' },
},
},
},
'AccountActivityDataSource',
pollRequest,
);
expect(rpcMiddleware).not.toHaveBeenCalled();
});

rpcMiddlewareGetter.mockRestore();
});

it('does not fail when parent trace rejects after enrichment completes', async () => {
const traceMock = jest
.fn()
Expand Down
2 changes: 2 additions & 0 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3912,6 +3912,7 @@ export class AssetsController extends BaseController<
sourceId === 'AccountActivityDataSource' &&
this.#isBasicFunctionality();

const shouldRunRpcFallback = sourceId === 'AccountsApiDataSource';
const enrichmentSources: AssetsDataSource[] = [
...(shouldGraduateCustomAssets
? [this.#customAssetGraduationMiddleware]
Expand All @@ -3925,6 +3926,7 @@ export class AssetsController extends BaseController<
},
]
: []),
...(shouldRunRpcFallback ? [this.#rpcFallbackMiddleware] : []),
Comment thread
cursor[bot] marked this conversation as resolved.
this.#detectionMiddleware,
];
if (this.#isBasicFunctionality()) {
Expand Down