Skip to content

Commit 7b49e02

Browse files
fix(transaction-pay-controller): use Infura endpoint for live token balance queries (#8839)
## Explanation When querying a live on-chain token balance, the controller previously resolved the network client using `findNetworkClientIdByChainId`, which returns whatever endpoint is currently selected for that chain — including custom RPC endpoints. Custom RPC endpoints may not support the `pending` block tag used by these calls, causing the query to fail. This change adds a preference for the chain's Infura endpoint when one is configured, falling back to the existing `findNetworkClientIdByChainId` behaviour if no Infura endpoint is configured or if the lookup throws. ## References <!-- Are there any issues that this pull request is tied to? --> ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes which RPC endpoint serves balance checks and on-chain amount reads before Relay submit; wrong endpoint selection could block or mis-validate pay deposits, though Infura preference falls back to the default client. > > **Overview** > Introduces shared **`provider` utilities** (`getNetworkClientId`, `rpcRequest`) so chain RPC calls go through the controller messenger’s `provider.request` instead of **ethers `Web3Provider` / `Contract`**. > > **Live token balances** (`getLiveTokenBalance`) now call `eth_getBalance` / `eth_call` with the **`pending`** block tag and pass **`preferInfura: true`**, picking the chain’s Infura `networkClientId` when configured and otherwise using `findNetworkClientIdByChainId`—addressing failures on custom RPCs that don’t support pending queries. > > The same RPC helper path is used for **fiat source amount resolution** and **transfer amount from tx hash** (receipt logs, `debug_traceTransaction`, `eth_getTransactionByHash`). **Across**, **Relay**, and **gas** estimation call sites switch to `getNetworkClientId` for consistency. > > Messenger **`AllowedActions`** and test mocks register **`NetworkController:getNetworkConfigurationByChainId`**. Changelog documents the Infura preference for live balance queries. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 4ca5364. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 855ab80 commit 7b49e02

13 files changed

Lines changed: 615 additions & 225 deletions

File tree

packages/transaction-pay-controller/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12+
- Live token balance queries now prefer the chain's Infura endpoint, falling back to the default endpoint if no Infura endpoint is configured ([#8839](https://github.com/MetaMask/core/pull/8839))
1213
- Bump `@metamask/assets-controllers` from `^108.2.0` to `^108.3.0` ([#8941](https://github.com/MetaMask/core/pull/8941))
1314

1415
## [22.8.0]

packages/transaction-pay-controller/src/strategy/across/across-submit.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
import { accountSupports7702 } from '../../utils/7702';
2222
import { getPayStrategiesConfig } from '../../utils/feature-flags';
2323
import { getGasBuffer } from '../../utils/feature-flags';
24+
import { getNetworkClientId } from '../../utils/provider';
2425
import {
2526
collectTransactionIds,
2627
getTransaction,
@@ -141,10 +142,7 @@ async function submitTransactions(
141142
const transactionCount =
142143
orderedTransactions.length + (shouldPrependOriginalTransaction ? 1 : 0);
143144

144-
const networkClientId = messenger.call(
145-
'NetworkController:findNetworkClientIdByChainId',
146-
chainId,
147-
);
145+
const networkClientId = getNetworkClientId(messenger, chainId);
148146

149147
const is7702Batch = is7702 && transactionCount > 1;
150148
const canUseQuotedBatchGasLimit =

packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts

Lines changed: 18 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Web3Provider } from '@ethersproject/providers';
21
import type { RampsOrder } from '@metamask/ramps-controller';
32
import type { TransactionMeta } from '@metamask/transaction-controller';
43
import { TransactionType } from '@metamask/transaction-controller';
@@ -15,11 +14,6 @@ import {
1514
resolveSourceAmountRaw,
1615
} from './utils';
1716

18-
jest.mock('@ethersproject/providers', () => ({
19-
...jest.requireActual('@ethersproject/providers'),
20-
Web3Provider: jest.fn(),
21-
}));
22-
2317
const TX_HASH_MOCK = '0xabc123';
2418
const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex;
2519
const ERC20_ADDRESS_MOCK = '0x2222222222222222222222222222222222222222' as Hex;
@@ -210,23 +204,17 @@ describe('Fiat Utils', () => {
210204
messenger: resolveMessenger,
211205
findNetworkClientIdByChainIdMock,
212206
getNetworkClientByIdMock,
207+
getNetworkConfigurationByChainIdMock,
213208
getTokensControllerStateMock,
214209
getRemoteFeatureFlagControllerStateMock:
215210
resolveRemoteFeatureFlagControllerStateMock,
216211
} = getMessengerMock();
217212

218-
let mockGetTransactionReceipt: jest.Mock;
219-
let mockSend: jest.Mock;
220-
let mockGetTransaction: jest.Mock;
221-
222213
beforeEach(() => {
223214
jest.resetAllMocks();
224215

225-
mockGetTransactionReceipt = jest.fn();
226-
mockSend = jest.fn();
227-
mockGetTransaction = jest.fn();
228-
229216
findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK);
217+
getNetworkConfigurationByChainIdMock.mockReturnValue(undefined);
230218
getNetworkClientByIdMock.mockReturnValue({
231219
provider: PROVIDER_MOCK,
232220
} as never);
@@ -255,16 +243,10 @@ describe('Fiat Utils', () => {
255243
allIgnoredTokens: {},
256244
allDetectedTokens: {},
257245
} as never);
258-
259-
(Web3Provider as unknown as jest.Mock).mockImplementation(() => ({
260-
getTransactionReceipt: mockGetTransactionReceipt,
261-
send: mockSend,
262-
getTransaction: mockGetTransaction,
263-
}));
264246
});
265247

266248
it('returns on-chain ERC-20 amount from receipt logs', async () => {
267-
mockGetTransactionReceipt.mockResolvedValue({
249+
PROVIDER_MOCK.request.mockResolvedValue({
268250
logs: [
269251
{
270252
address: ERC20_ADDRESS_MOCK,
@@ -297,11 +279,11 @@ describe('Fiat Utils', () => {
297279
});
298280

299281
expect(result).toBe('1500000');
300-
expect(mockGetTransactionReceipt).not.toHaveBeenCalled();
282+
expect(PROVIDER_MOCK.request).not.toHaveBeenCalled();
301283
});
302284

303285
it('falls back to cryptoAmount when receipt is null', async () => {
304-
mockGetTransactionReceipt.mockResolvedValue(null);
286+
PROVIDER_MOCK.request.mockResolvedValue(null);
305287

306288
const result = await resolveSourceAmountRaw({
307289
messenger: resolveMessenger,
@@ -314,7 +296,7 @@ describe('Fiat Utils', () => {
314296
});
315297

316298
it('falls back to cryptoAmount when on-chain read throws', async () => {
317-
mockGetTransactionReceipt.mockRejectedValue(new Error('Network error'));
299+
PROVIDER_MOCK.request.mockRejectedValue(new Error('Network error'));
318300

319301
const result = await resolveSourceAmountRaw({
320302
messenger: resolveMessenger,
@@ -327,7 +309,7 @@ describe('Fiat Utils', () => {
327309
});
328310

329311
it('returns native amount from debug_traceTransaction', async () => {
330-
mockSend.mockResolvedValue({
312+
PROVIDER_MOCK.request.mockResolvedValue({
331313
to: WALLET_ADDRESS_MOCK.toLowerCase(),
332314
value: '0x1bc16d674ec80000',
333315
calls: [],
@@ -344,11 +326,17 @@ describe('Fiat Utils', () => {
344326
});
345327

346328
it('falls back to tx.value for native when trace is unsupported', async () => {
347-
mockSend.mockRejectedValue(new Error('Method not found'));
348-
mockGetTransaction.mockResolvedValue({
349-
to: WALLET_ADDRESS_MOCK.toLowerCase(),
350-
value: { toString: () => '2000000000000000000' },
351-
});
329+
PROVIDER_MOCK.request.mockImplementation(
330+
({ method }: { method: string }) => {
331+
if (method === 'debug_traceTransaction') {
332+
return Promise.reject(new Error('Method not found'));
333+
}
334+
return Promise.resolve({
335+
to: WALLET_ADDRESS_MOCK.toLowerCase(),
336+
value: '0x1bc16d674ec80000',
337+
});
338+
},
339+
);
352340

353341
const result = await resolveSourceAmountRaw({
354342
messenger: resolveMessenger,

packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getRelayPollingInterval,
2121
getRelayPollingTimeout,
2222
} from '../../utils/feature-flags';
23+
import { getNetworkClientId } from '../../utils/provider';
2324
import {
2425
getLiveTokenBalance,
2526
normalizeTokenAddress,
@@ -508,10 +509,7 @@ async function submitViaRelayExecute(
508509
const { from, sourceChainId } = quote.request;
509510
const { requestId } = quote.original.steps[0];
510511

511-
const networkClientId = messenger.call(
512-
'NetworkController:findNetworkClientIdByChainId',
513-
sourceChainId,
514-
);
512+
const networkClientId = getNetworkClientId(messenger, sourceChainId);
515513

516514
const sourceCallTransaction = {
517515
...transaction,
@@ -600,10 +598,7 @@ async function submitViaTransactionController(
600598
const { from, sourceChainId, sourceTokenAddress } = quote.request;
601599
const { isPostQuote } = quote.request;
602600

603-
const networkClientId = messenger.call(
604-
'NetworkController:findNetworkClientIdByChainId',
605-
sourceChainId,
606-
);
601+
const networkClientId = getNetworkClientId(messenger, sourceChainId);
607602

608603
log('Adding transactions', {
609604
normalizedParams: allParams,

packages/transaction-pay-controller/src/tests/messenger-mock.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger';
1616
import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller';
1717
import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller';
18+
import type { NetworkControllerGetNetworkConfigurationByChainIdAction } from '@metamask/network-controller';
1819
import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller';
1920
import type {
2021
TransactionControllerAddTransactionAction,
@@ -117,6 +118,10 @@ export function getMessengerMock({
117118
NetworkControllerGetNetworkClientByIdAction['handler']
118119
> = jest.fn();
119120

121+
const getNetworkConfigurationByChainIdMock: jest.MockedFn<
122+
NetworkControllerGetNetworkConfigurationByChainIdAction['handler']
123+
> = jest.fn();
124+
120125
const getDelegationTransactionMock: jest.MockedFn<
121126
TransactionPayControllerGetDelegationTransactionAction['handler']
122127
> = jest.fn();
@@ -255,6 +260,11 @@ export function getMessengerMock({
255260
getNetworkClientByIdMock,
256261
);
257262

263+
messenger.registerActionHandler(
264+
'NetworkController:getNetworkConfigurationByChainId',
265+
getNetworkConfigurationByChainIdMock,
266+
);
267+
258268
messenger.registerActionHandler(
259269
'TransactionPayController:getDelegationTransaction',
260270
getDelegationTransactionMock,
@@ -321,6 +331,7 @@ export function getMessengerMock({
321331
getGasFeeTokensMock,
322332
getKeyringControllerStateMock,
323333
getNetworkClientByIdMock,
334+
getNetworkConfigurationByChainIdMock,
324335
getRemoteFeatureFlagControllerStateMock,
325336
getStrategyMock,
326337
getTokenBalanceControllerStateMock,

packages/transaction-pay-controller/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
import type { Messenger } from '@metamask/messenger';
3232
import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller';
3333
import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller';
34+
import type { NetworkControllerGetNetworkConfigurationByChainIdAction } from '@metamask/network-controller';
3435
import type { Quote as RampsQuote } from '@metamask/ramps-controller';
3536
import type {
3637
RampsControllerGetOrderAction,
@@ -78,6 +79,7 @@ export type AllowedActions =
7879
| KeyringControllerSignTypedMessageAction
7980
| NetworkControllerFindNetworkClientIdByChainIdAction
8081
| NetworkControllerGetNetworkClientByIdAction
82+
| NetworkControllerGetNetworkConfigurationByChainIdAction
8183
| RampsControllerGetOrderAction
8284
| RampsControllerGetQuotesAction
8385
| RampsControllerGetStateAction

packages/transaction-pay-controller/src/utils/gas.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { TransactionPayControllerMessenger } from '..';
1111
import { createModuleLogger, projectLogger } from '../logger';
1212
import type { Amount } from '../types';
1313
import { getFallbackGas, getGasBuffer } from './feature-flags';
14+
import { getNetworkClientId } from './provider';
1415
import { getNativeToken, getTokenBalance, getTokenFiatRate } from './token';
1516

1617
const log = createModuleLogger(projectLogger, 'gas');
@@ -227,10 +228,7 @@ export async function estimateGasLimit({
227228
error?: unknown;
228229
}> {
229230
const gasBuffer = getGasBuffer(messenger, chainId);
230-
const networkClientId = messenger.call(
231-
'NetworkController:findNetworkClientIdByChainId',
232-
chainId,
233-
);
231+
const networkClientId = getNetworkClientId(messenger, chainId);
234232

235233
let estimateGasError: unknown;
236234
let simulationError: Error | undefined;

0 commit comments

Comments
 (0)