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
8 changes: 0 additions & 8 deletions oxlint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -737,9 +737,6 @@
"packages/assets-controller/src/AssetsController.ts": {
"typescript/no-unnecessary-type-assertion": {
"count": 1
},
"typescript/no-unsafe-assignment": {
"count": 5
}
},
"packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts": {
Expand Down Expand Up @@ -933,11 +930,6 @@
"count": 2
}
},
"packages/assets-controller/src/pipeline/buildFastFetchSources.test.ts": {
"no-unused-vars": {
"count": 1
}
},
"packages/assets-controller/src/pipeline/executeAssetsPipeline.ts": {
"no-unused-vars": {
"count": 1
Expand Down
6 changes: 6 additions & 0 deletions packages/assets-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump `@metamask/transaction-controller` from `^72.2.0` to `^72.2.1` ([#10817](https://github.com/MetaMask/core/pull/10817))
- Bump `@metamask/accounts-controller` from `^40.0.0` to `^40.1.0` ([#10851](https://github.com/MetaMask/core/pull/10851))
- Bump `@metamask/keyring-controller` from `^28.1.0` to `^28.2.0` ([#10851](https://github.com/MetaMask/core/pull/10851))
- Poll staked balances every hour instead of every 3 minutes. Subscribe still fetches immediately, and a confirmed transaction that touches the staking contract still refreshes the balance right away ([#10782](https://github.com/MetaMask/core/pull/10782))
- Remove staked balances from the fast asset pipeline ([#10782](https://github.com/MetaMask/core/pull/10782))

### Fixed

- Copy Accounts API v5 fetch errors onto the asset pipeline context so unprocessed and timed-out chains are retried by RPC fallback and the slow pipeline ([#10782](https://github.com/MetaMask/core/pull/10782))

## [19.0.0]

Expand Down
17 changes: 10 additions & 7 deletions packages/assets-controller/src/AssetsController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1239,7 +1239,7 @@ export class AssetsController extends BaseController<
this.#handleNetworkAdded(networkConfiguration.chainId);
this.#refreshAssetsAfterNetworkAdded(
networkConfiguration.chainId,
).catch((error) => {
).catch((error: unknown) => {
log('Failed to refresh assets after network added', { error });
});
},
Expand Down Expand Up @@ -1396,7 +1396,7 @@ export class AssetsController extends BaseController<
chainIds: [caipChainId],
forceUpdate: true,
bypassServerCache: true,
}).catch((error) => {
}).catch((error: unknown) => {
log('Failed to refresh assets after transaction event', { error });
});
}
Expand Down Expand Up @@ -1634,6 +1634,7 @@ export class AssetsController extends BaseController<
*/
bypassServerCache?: boolean;
dataTypes?: DataType[];
/** Specific CAIP-19 asset IDs whose prices should be refreshed. */
assetsForPriceUpdate?: Caip19AssetId[];
},
): Promise<Record<AccountId, Record<Caip19AssetId, Asset>>> {
Expand Down Expand Up @@ -1733,7 +1734,6 @@ export class AssetsController extends BaseController<
const fastSources = buildFastFetchSources(
{
accountsApiDataSource: this.#accountsApiDataSource,
stakedBalanceDataSource: this.#stakedBalanceDataSource,
customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
detectionMiddleware: this.#detectionMiddleware,
Expand Down Expand Up @@ -1777,7 +1777,6 @@ export class AssetsController extends BaseController<
const fastSources = buildFastFetchSources(
{
accountsApiDataSource: this.#accountsApiDataSource,
stakedBalanceDataSource: this.#stakedBalanceDataSource,
customAssetGraduationMiddleware: this.#customAssetGraduationMiddleware,
rpcFallbackMiddleware: this.#rpcFallbackMiddleware,
detectionMiddleware: this.#detectionMiddleware,
Expand Down Expand Up @@ -1955,7 +1954,7 @@ export class AssetsController extends BaseController<
});
await this.#updateState(response);
},
}).catch((error) => log('Background pipeline failed', { error }));
}).catch((error: unknown) => log('Background pipeline failed', { error }));
}

#emitFastFetchTraces({
Expand Down Expand Up @@ -2601,7 +2600,7 @@ export class AssetsController extends BaseController<
dataTypes: ['price'],
chainIds,
assetsForPriceUpdate,
}).catch((error) => {
}).catch((error: unknown) => {
log('Failed to fetch missing prices', { error });
});
}
Expand Down Expand Up @@ -3756,6 +3755,10 @@ export class AssetsController extends BaseController<

/**
* Subscribe to staked balance updates.
* This is the only staked-balance path: subscribe fetches immediately, then
* polls, and the data source refreshes again when a confirmed transaction
* touches the staking contract. The fast fetch lane does not query it.
*
* Unlike regular balance data sources, the staked balance data source provides
* supplementary data and does not participate in chain-claiming.
*
Expand Down Expand Up @@ -4235,7 +4238,7 @@ export class AssetsController extends BaseController<
this.getAssets(accounts, {
forceUpdate: true,
dataTypes: ['balance', 'metadata'],
}).catch((error) => {
}).catch((error: unknown) => {
log('Failed to refresh assets after network change', { error });
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1753,6 +1753,45 @@ describe('AccountsApiDataSource', () => {
controller.destroy();
});

it('middleware copies v5 unprocessed-chain errors onto the pipeline context', async () => {
const { controller } = await setupController({
unprocessedNetworks: ['eip155:1'],
});

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext({
response: { errors: { [CHAIN_POLYGON]: 'existing' } },
});

await controller.assetsMiddleware(context, next);

expect(context.response.errors?.[CHAIN_MAINNET]).toBe(
'Unprocessed by Accounts API',
);
expect(context.response.errors?.[CHAIN_POLYGON]).toBe('existing');

controller.destroy();
});

it('middleware copies timed-out v5 chains onto the pipeline context', async () => {
const { controller, apiClient } = await setupController({
fetchTimeoutMs: 10,
});

apiClient.accounts.fetchV5MultiAccountBalances.mockImplementationOnce(
() => new Promise(() => undefined),
);

const next = jest.fn().mockResolvedValue(undefined);
const context = createMiddlewareContext();

await controller.assetsMiddleware(context, next);

expect(context.response.errors?.[CHAIN_MAINNET]).toContain('timed out');

controller.destroy();
});

it('middleware forwards full updateMode from v6 fetches', async () => {
const { controller } = await setupController({
remoteFeatureFlags: { assetsAccountsApiV6: true },
Expand Down Expand Up @@ -1796,7 +1835,7 @@ describe('AccountsApiDataSource', () => {
controller.destroy();
});

it('middleware removes handled chains from next request', async () => {
it('middleware keeps every requested chain for later lane stages', async () => {
const { controller } = await setupController({ supportedChains: [1] });

const next = jest.fn().mockResolvedValue(undefined);
Expand All @@ -1809,7 +1848,7 @@ describe('AccountsApiDataSource', () => {
expect(next).toHaveBeenCalledWith(
expect.objectContaining({
request: expect.objectContaining({
chainIds: [CHAIN_POLYGON],
chainIds: [CHAIN_MAINNET, CHAIN_POLYGON],
}),
}),
);
Expand Down Expand Up @@ -2266,7 +2305,7 @@ describe('AccountsApiDataSource', () => {
controller.destroy();
});

it('claims handled chains on v6 when tokenDetectionEnabled is false', async () => {
it('keeps requested chains on v6 when tokenDetectionEnabled is false', async () => {
const { controller } = await setupController({
remoteFeatureFlags: { assetsAccountsApiV6: true },
tokenDetectionEnabled: (): boolean => false,
Expand Down Expand Up @@ -2294,7 +2333,7 @@ describe('AccountsApiDataSource', () => {
expect(nextFn).toHaveBeenCalledWith(
expect.objectContaining({
request: expect.objectContaining({
chainIds: [],
chainIds: [CHAIN_MAINNET],
}),
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -913,9 +913,10 @@ export class AccountsApiDataSource extends AbstractDataSource<
* Get the middleware for fetching balances via Accounts API.
* This middleware:
* - Supports multiple accounts in a single request
* - Uses unprocessedNetworks from API response to determine what to pass to next middleware
* - Merges response into context
* - Removes handled chains from request for next middleware
* - Fetches only chains this source currently supports
* - Merges that response into context
* - Leaves `request.chainIds` unchanged so later stages (RPC fallback,
* detection, prices) still see every chain the caller asked for
*
* @returns The middleware function for the assets pipeline.
*/
Expand All @@ -940,12 +941,19 @@ export class AccountsApiDataSource extends AbstractDataSource<
return next(context);
}

let successfullyHandledChains: ChainId[] = [];
const chainsToFetch = this.#getChainsToFetch(request);
if (chainsToFetch.length === 0) {
return next(context);
}

try {
const response = await this.fetch(request);
const response = await this.fetch({
...request,
chainIds: chainsToFetch,
});

// Merge response into context
// Merge response into context. The caller's chain list stays intact:
// the RPC fallback re-reads omitted tokens on chains this fetch answered.
if (response.assetsBalance) {
context.response.assetsBalance ??= {};
for (const [accountId, accountBalances] of Object.entries(
Expand All @@ -958,43 +966,16 @@ export class AccountsApiDataSource extends AbstractDataSource<
}
}

// Determine successfully handled chains (exclude unprocessed/error chains)
const unprocessedChains = new Set(Object.keys(response.errors ?? {}));
successfullyHandledChains = request.chainIds.filter(
(chainId) => !unprocessedChains.has(chainId),
);

// When token detection is off and we filtered out all balance data (e.g. new
// account with empty state), do not claim any chain as handled so that RPC
// middleware can still process them and fetch native balances (ETH, MATIC, etc.).
if (
!this.#tokenDetectionEnabled() &&
(!response.assetsBalance ||
Comment thread
cursor[bot] marked this conversation as resolved.
Object.keys(response.assetsBalance).length === 0)
) {
successfullyHandledChains = [];
if (response.errors && Object.keys(response.errors).length > 0) {
context.response.errors = {
...context.response.errors,
...response.errors,
};
}
} catch (error) {
log('Middleware fetch failed', { error });
successfullyHandledChains = [];
}

// Remove successfully handled chains from request for next middleware
if (successfullyHandledChains.length > 0) {
const remainingChains = request.chainIds.filter(
(chainId) => !successfullyHandledChains.includes(chainId),
);

return next({
...context,
request: {
...request,
chainIds: remainingChains,
},
});
}

// No chains handled - pass context unchanged
return next(context);
};

Expand All @@ -1011,12 +992,19 @@ export class AccountsApiDataSource extends AbstractDataSource<
return next(context);
}

let remainingChains = request.chainIds;
const chainsToFetch = this.#getChainsToFetch(request);
if (chainsToFetch.length === 0) {
return next(context);
}

try {
const response = await this.fetch(request);
const response = await this.fetch({
...request,
chainIds: chainsToFetch,
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unsupported chains skip fast RPC fallback

Medium Severity

The Accounts API middleware now calls fetch with only supported chainIds, so #markUnsupportedChains never records custom or unindexed networks. RpcFallbackMiddleware v6 retries solely from response.errors, so those chains no longer recover in the fast lane. getAssets can return before their balances exist; only the fire-and-forget background pipeline still fetches them.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 3002511. Configure here.


// Merge response into context
// Merge response into context. Failed chains stay on `response.errors`;
// the RPC fallback retries those. The caller's chain list is unchanged.
if (response.assetsBalance) {
context.response.assetsBalance ??= {};
for (const [accountId, accountBalances] of Object.entries(
Expand All @@ -1034,30 +1022,17 @@ export class AccountsApiDataSource extends AbstractDataSource<
updateMode: 'full',
};

const errors = response.errors ?? {};
remainingChains = request.chainIds.filter((chainId) => errors[chainId]);

// Remaining chains are exactly the failed ones; RPC fallback retries them.
if (remainingChains.length > 0) {
if (response.errors && Object.keys(response.errors).length > 0) {
context.response.errors = {
...context.response.errors,
...Object.fromEntries(
remainingChains.map((chainId) => [chainId, errors[chainId]]),
),
...response.errors,
};
}
} catch (error) {
log('Middleware fetch failed', { error });
remainingChains = request.chainIds;
}

return next({
...context,
request: {
...request,
chainIds: remainingChains,
},
});
return next(context);
};

// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
} from './evm-rpc-services/index.js';

const CONTROLLER_NAME = 'StakedBalanceDataSource';
const DEFAULT_POLL_INTERVAL = 180_000; // 3 minutes
const DEFAULT_POLL_INTERVAL = 3_600_000; // 1 hour

/** Metadata for staked ETH (same symbol and decimals as native ETH). */
const STAKED_ETH_METADATA: AssetMetadata = {
Expand All @@ -54,7 +54,7 @@ const log = createModuleLogger(projectLogger, CONTROLLER_NAME);
export type StakedBalanceDataSourceConfig = {
/** Whether staked balance fetching is enabled (default: true). */
enabled?: boolean;
/** Polling interval in ms (default: 180s / 3 min). */
/** Polling interval in ms (default: 1 hour). */
pollInterval?: number;
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,13 @@ export const STAKING_INTERFACE = new Interface(STAKING_CONTRACT_ABI);
const STAKING_DECIMALS = 18;

export type StakedBalanceFetcherConfig = {
/** Polling interval in ms (default: 180s) */
/** Polling interval in ms (default: 1 hour) */
pollingInterval?: number;
/** Returns the network provider for the given chain. Required for fetchStakedBalance. */
getNetworkProvider?: (chainId: ChainId) => Web3Provider | undefined;
};

const DEFAULT_STAKED_BALANCE_INTERVAL = 180_000; // 3 minutes
const DEFAULT_STAKED_BALANCE_INTERVAL = 3_600_000; // 1 hour

export class StakedBalanceFetcher extends StaticIntervalPollingControllerOnly<StakedBalancePollingInput>() {
readonly #providerGetter?: (chainId: ChainId) => Web3Provider | undefined;
Expand Down
Loading
Loading