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

Aperta Adatta ai principianti
#11,219 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

I maintainer di solito rispondono entro 1 giorno

Nessuno ha ancora preso questa issue.

Valutazione

Difficoltà
2/5
Tempo stimato
1-3 ore
Idoneità per principianti
62/100
Tipo di issue
Bug
Chiarezza
Specificata chiaramente
Stato di attività
Attiva
Stack tecnologico
typescript
Ambito
api, blockchain

Direzione di ricerca

Il bug si trova in packages/eth-json-rpc-middleware/src/retryOnEmpty.ts, dove un riferimento a blocco che non è una stringa viene trattato come un numero, e in src/block-cache.ts, dove tali selettori possono finire nei bucket di cache numerici o latest. Inizia leggendo entrambi i file intorno alla gestione dei riferimenti a blocco, quindi esegui il src/eip1898-regression.test.ts proposto. È completato quando i casi con selettori oggetto passano in entrambi gli ordini dei middleware e la suite di test esistente del middleware resta verde.

Scritto dal modello di indicizzazione a partire dal testo della issue.

Descrizione

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 });
+  });
+});
Lingua principale
TypeScript
Stelle
420
Fork
313
Merge medio
1g 12h
PR unite (30g)
384

Preparare l'ambiente

  • Nessun Dockerfile né file Docker Compose
  • Ha un modello di pull request
  • Nessuna guida per i contributori

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di MetaMask/core

Tutte le issue di MetaMask/core

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.