Skip to content

EIP-1898 object selectors fail before downstream RPC in eth-json-rpc-middleware 26.0.0 #11219

Open

Description

@jonnyp2p1-art

Summary

eth_call with { blockHash, requireCanonical: true } or { blockNumber } can fail in the block-cache/retry-on-empty middleware before the request reaches downstream RPC. The retry path treats the block reference as a string (blockRef.slice). Both middleware orders are covered by the regression.

Versions and source

  • @metamask/eth-json-rpc-middleware 26.0.0
  • @metamask/network-controller 37.1.1 (HTTP integration)
  • Node 24.22.1, Yarn 4.17.1
  • Current core source checked at commit 4c3af5af945a1e6496819c90a8b59ff77e815071: the two affected source files are byte-identical to the pinned 26.0.0 source.
  • This is a reproducible library issue. It does not establish the exact stack inside any installed extension binary.

Request shape

{
  jsonrpc: '2.0', id: 1, method: 'eth_call',
  params: [
    { to: '0x' + '11'.repeat(20), data: '0x313ce567' },
    { blockHash: '0x' + 'ab'.repeat(32), requireCanonical: true }
  ]
}

The address and block hash are synthetic. No live chain, account, key or wallet is needed. Wire the middleware to a controlled downstream that checks the selector unchanged and returns a test value; the included native regression contains the full harness.

Expected

Object selectors reach the downstream validator/executor unchanged. Distinct hashes and canonical flags must not share a numeric/latest cache bucket. Downstream rejection must remain observable. Invalid selectors may be rejected downstream; this proposal does not make middleware a complete EIP-1898 validator.

Actual and measured regression

  • Native typed regression with both guards removed: 27 expected FAIL, 0 PASS.
  • Native custom/Infura-branch HTTP harness: baseline 4 PASS / 16 FAIL; candidate 20 PASS / 0 FAIL. Baseline swapped exactly two published npm 26.0.0 dist files into the candidate network-controller environment; it was not a pristine rebuild of the whole controller.
  • The Infura branch uses the native URL construction, then an injected fetch adapter confines traffic to an owned loopback HTTP server. No real Infura connection or credential is used. Retries/failover are disabled in this diagnostic profile.

Proposed minimal change

  1. In retry-on-empty, bypass numeric block handling for non-string block selectors.
  2. In block-cache, do not place non-string block selectors into numeric/latest cache buckets.

The separate native regression covers object selectors across five RPC methods, both middleware orders, downstream errors and hash/canonical separation. The HTTP harness adds inflight coalescing, downstream recovery, numeric-cache and local-chainId controls.

Validation scope

Previously completed candidate checks: middleware suite 483 PASS, engine suite 260 PASS / 1 original SKIP; target semantic TypeScript, coverage thresholds, root build and root aggregate lint passed. Root TypeScript lint is a ratchet: raw root tsc still reports 1628 tracked baseline diagnostics. These suites overlap earlier runs and must not be summed.

Not established: full monorepo suite, extension IPC/TLS integration, default retry/failover behavior, installed wallet compatibility or real transaction execution. No signatures or sends were performed.

Please confirm whether this minimal selector-preservation approach belongs in core and advise on the appropriate regression/release path.

Proposed runtime diff (review proposal, not an official release)

--- a/packages/eth-json-rpc-middleware/src/block-cache.ts
+++ b/packages/eth-json-rpc-middleware/src/block-cache.ts
@@ -89,6 +89,11 @@
     }
     // check blockTag
     const blockTag = blockTagForRequest(request);
+
+    // Object selectors must not share numeric/latest cache buckets.
+    if (blockTag !== undefined && typeof blockTag !== 'string') {
+      return false;
+    }
 
     if (blockTag === 'pending') {
       return false;
--- a/packages/eth-json-rpc-middleware/src/retryOnEmpty.ts
+++ b/packages/eth-json-rpc-middleware/src/retryOnEmpty.ts
@@ -64,6 +64,11 @@
         : undefined;
     // omitted blockRef implies "latest"
     blockRef ??= 'latest';
+
+    // Preserve EIP-1898 selectors for downstream validation and execution.
+    if (typeof blockRef !== 'string') {
+      return next();
+    }
 
     // skip if non-number block reference
     if (['latest', 'pending'].includes(blockRef)) {
Native typed regression patch (27 cases)
--- /dev/null
+++ b/packages/eth-json-rpc-middleware/src/eip1898-regression.test.ts
@@ -0,0 +1,159 @@
+import { PollingBlockTracker } from '@metamask/eth-block-tracker';
+import { InternalProvider } from '@metamask/eth-json-rpc-provider';
+import { JsonRpcEngineV2 } from '@metamask/json-rpc-engine/v2';
+import type { JsonRpcMiddleware } from '@metamask/json-rpc-engine/v2';
+import type { Json, JsonRpcRequest } from '@metamask/utils';
+
+import {
+  createRetryOnEmptyMiddleware,
+  createBlockCacheMiddleware,
+} from './index.js';
+
+type Counts = { tracker: number; terminal: number };
+type Harness = {
+  counts: Counts;
+  invoke: (method: string, params: Json[]) => Promise<Json>;
+};
+
+// Dropping either guard must fail at least one regression.
+describe('EIP-1898 selector preservation', () => {
+  const address = `0x${'11'.repeat(20)}`;
+  const hashOne = `0x${'ab'.repeat(32)}`;
+  const hashTwo = `0x${'cd'.repeat(32)}`;
+  const selectors: Json[] = [
+    { blockHash: hashOne, requireCanonical: true },
+    { blockNumber: '0x123' },
+  ];
+  const owned: (() => Promise<void>)[] = [];
+
+  afterEach(async () => {
+    for (const cleanup of owned.splice(0).reverse()) {
+      await cleanup();
+    }
+  });
+
+  function pipeline(
+    order: string[],
+    terminal: (request: Readonly<JsonRpcRequest>) => Json,
+  ): Harness {
+    const counts: Counts = { tracker: 0, terminal: 0 };
+    const remote = JsonRpcEngineV2.create<
+      JsonRpcMiddleware<JsonRpcRequest, Json>
+    >({
+      middleware: [
+        async (): Promise<Json> => {
+          counts.tracker += 1;
+          throw Error('OBJECT_SELECTOR_REACHED_NUMERIC_TRACKER');
+        },
+      ],
+    });
+    const provider = new InternalProvider({ engine: remote });
+    const tracker = new PollingBlockTracker({
+      provider,
+      keepEventLoopActive: false,
+    });
+    const stages: Record<string, JsonRpcMiddleware<JsonRpcRequest, Json>> = {
+      cache: createBlockCacheMiddleware({ blockTracker: tracker }),
+      retry: createRetryOnEmptyMiddleware({ provider, blockTracker: tracker }),
+    };
+    const engine = JsonRpcEngineV2.create<
+      JsonRpcMiddleware<JsonRpcRequest, Json>
+    >({
+      middleware: [
+        ...order.map((key) => stages[key]),
+        async ({ request }): Promise<Json> => {
+          counts.terminal += 1;
+          return terminal(request);
+        },
+      ],
+    });
+    owned.push(async (): Promise<void> => {
+      await tracker.destroy();
+      await engine.destroy();
+      await remote.destroy();
+    });
+    let requestId = 0;
+    return {
+      counts,
+      invoke: (method: string, params: Json[]): Promise<Json> => {
+        requestId += 1;
+        return engine.handle({ jsonrpc: '2.0', id: requestId, method, params });
+      },
+    };
+  }
+
+  for (const order of [
+    ['cache', 'retry'],
+    ['retry', 'cache'],
+  ]) {
+    for (const method of [
+      'eth_call',
+      'eth_getBalance',
+      'eth_getCode',
+      'eth_getTransactionCount',
+      'eth_getStorageAt',
+    ]) {
+      for (const selector of selectors) {
+        it(`${order.join('→')} forwards selector for ${method} ${JSON.stringify(selector)}`, async () => {
+          let params: Json[];
+          if (method === 'eth_call') {
+            params = [{ to: address, data: '0x313ce567' }, selector];
+          } else if (method === 'eth_getStorageAt') {
+            params = [address, '0x0', selector];
+          } else {
+            params = [address, selector];
+          }
+          const harness = pipeline(order, (request) => {
+            expect(request.params).toStrictEqual(params);
+            return '0x1234';
+          });
+          expect(await harness.invoke(method, params)).toBe('0x1234');
+          expect(harness.counts).toStrictEqual({ tracker: 0, terminal: 1 });
+        });
+      }
+    }
+    for (const reason of ['NON_CANONICAL', 'UNKNOWN_BLOCK', 'RATE_LIMIT']) {
+      it(`${order.join('→')} preserves downstream ${reason} without retry/cache`, async () => {
+        const error = Object.assign(new Error(reason), { code: -32000 });
+        let fail = true;
+        const harness = pipeline(order, () => {
+          if (fail) {
+            throw error;
+          }
+          return '0xbeef';
+        });
+        const params: Json[] = [
+          { to: address },
+          { blockHash: hashOne, requireCanonical: true },
+        ];
+        await expect(harness.invoke('eth_call', params)).rejects.toBe(error);
+        fail = false;
+        expect(await harness.invoke('eth_call', params)).toBe('0xbeef');
+        expect(harness.counts).toStrictEqual({ tracker: 0, terminal: 2 });
+      });
+    }
+  }
+
+  it('block cache keeps hash/canonical responses separate across repeated calls', async () => {
+    let index = 0;
+    const responses = ['0xaa', '0xbb', '0xcc'];
+    const harness = pipeline(['cache'], () => {
+      const result = responses[index];
+      index += 1;
+      if (result === undefined) {
+        throw Error('UNEXPECTED_EXTRA_DOWNSTREAM_REQUEST');
+      }
+      return result;
+    });
+    for (const [selector, expected] of [
+      [{ blockHash: hashOne, requireCanonical: true }, '0xaa'],
+      [{ blockHash: hashOne, requireCanonical: false }, '0xbb'],
+      [{ blockHash: hashTwo, requireCanonical: true }, '0xcc'],
+    ] as const) {
+      expect(
+        await harness.invoke('eth_call', [{ to: address }, selector]),
+      ).toBe(expected);
+    }
+    expect(harness.counts).toStrictEqual({ tracker: 0, terminal: 3 });
+  });
+});

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions