From 498df5d295e1cc0570ec62f8a6416b510929edbf Mon Sep 17 00:00:00 2001 From: Jurij Skornik Date: Tue, 4 Aug 2026 00:38:33 +0200 Subject: [PATCH] fix(chain): select at most two provider origins so shipped RPC pools construct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC64's finalized-VM precommit could not construct its snapshot scope on ANY shipped EVM network. Measured against the merged validator: testnet pool=3 THROWS -> Strict current-finalized RPC requires 1..2 distinct endpoints mainnet-base pool=3 THROWS -> ... mainnet-gnosis pool=3 THROWS -> ... The finalized-read pool is `resolveRpcUrls(chain.rpcUrl, chain.rpcUrls)` — primary PLUS backups (`dkg-agent-rfc64-catalog.ts:777`) — which is three URLs on every EVM network. `snapshotNormalizedEndpoints` rejects more than `CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1 = 2` distinct endpoints, and the precommit passes the pool through unsliced. No test covered it: grepping the error message across the chain and agent suites returned zero hits. The fix is what plan §5.2 always specified — session selection. A new `selectStrictFinalizedEndpointSessionV1` reduces the pool to at most two distinct provider ORIGINS before the ceiling applies, so the ceiling is satisfied by construction rather than by rejection. Provider identity is core's `normalizeEndpointOrigin` — imported, not copied. It is the same predicate `canonicalPageProof` uses to decide `dual-origin-corroborated`, so the two layers cannot drift. (A working revision of this branch forked that rule into chain on the theory that core was not importable; it never reached a commit, so the diff below is purely additive. Core exports the function from the package root and chain already depends on core in 26 files, so the premise was simply wrong.) Note what this is NOT: base never computed an origin at all. `url.href` was the DEDUP key, and that href dedup is still here, unchanged. Origin identity is a coarser layer added on top of it, not a replacement. What inheriting core's rule actually changes, through the only production caller, is the byte bound and the error class below — NOT parsing strictness. The config's `normalizeEndpoint` runs `input.trim()` then `new URL(...)` and returns `url.href`, so the selector only ever sees an already-parsed canonical href; endpoints prefixed with U+00A0/U+FEFF/U+2028/U+3000 all normalize to `https://a.example.com/` and reach selection identically before and after. Those inputs could only have mattered to a direct caller of the exported selector, which is surface this change removes. The attempt ceiling is asserted where it is attested. `CONTROL_EIP1271_MAX_ATTEMPTS_V1` is verified at `current-finalized-evm-call.ts:182`, but it is NOT self-enforcing: the runner never reads `maxAttempts` and instead attempts one endpoint per entry of `profile.endpoints` (`strict-current-finalized-evm-lifecycle.ts:104`). Since this change removes the validator's count rejection, the config keeps an explicit postcondition on the selection result — otherwise an attested "2" would depend on a `>=` inside another module. (A separate constant, `CONTROL_EIP1271_ENDPOINT_ATTEMPT_POLICY_V1`, checked at `:185`, is the policy STRING; an earlier version of this message and both docblocks conflated the two.) The bound is NOT a parameter. Only one caller exists and it always passes the canonical constant, so a caller-supplied bound bought nothing while introducing a reordering failure mode reachable only ABOVE the shipped value — which then needed an explicit ordering repair to fix. Reading the constant directly deletes the parameter, its validation guard, the ordering repair, and that whole failure mode. At a fixed bound of 2 the repair is provably unnecessary: pass 2 runs only when pass 1 selected fewer than the bound, which means the pool held exactly one distinct origin, so pass 1 holds index 0 and pass 2 appends index 1. Ordering is the contract: selection runs AFTER `assertConfigDataProperties` and BEFORE normalization. Before descriptor validation it would re-execute attacker-supplied getters (the hole closed in #2051); after normalization it would never run, because normalization already threw. Both directions have a test and a mutant. Core's predicate is handed the ORIGIN, never the full dial URL. Core bounds its input at the canonical scalar limit, so passing the whole URL imposed a 4096-byte cap that RPC endpoints never had — a config carrying a long path or query credential constructed at base and would have begun throwing. Deriving the origin from the already-parsed URL keeps core as the single source of the origin rule while confining its bound to a string that is always short. Regression-tested in both directions, and a mutant that reinstates the full-URL form dies on it. No bound is placed on the RAW configured array. An earlier revision capped it at 32 entries, which was a compatibility break: base deduplicated by href BEFORE its count check, so 33 identical URLs — or 40 entries collapsing to two — normalized to a valid pool and constructed. A raw cap turns those into a construction failure and silently narrows the contract from "at most two distinct normalized endpoints" to "at most N raw entries". Both cases are now regression-tested, and the performance motive for the cap is gone: dedup is a `Set` (O(n)) and selection is a fixed two-slot scan, so an oversized array costs a linear pass. Endpoint normalization is the single boundary. `normalizeEndpoint` now yields `{ href, origin }` — the dial URL and provider identity from one parse — and the selector is policy over that model. It performs no validation, no parsing and no deduplication, because the states a second validator would defend against cannot reach it. That removed the duplicate URL parse, the defensive branches and the error-translation the selector previously carried. Selection is a TWO-SLOT policy, not a generic selector: first configured URL, then the first later URL with a different origin, else the second configured URL. The bound is fixed, there is one caller, and a caller-supplied bound bought nothing while introducing a reordering failure mode reachable only above the shipped value. The slot count is a LITERAL, not an alias of the attempt ceiling — aliasing would export a value that can change while the behaviour does not — and a COMPILE-TIME assertion fails `tsc` if the ceiling stops matching it. The constant is declared `= 2`, so its type is the literal `2`; raising it makes the assertion a type error. No runtime cost, no import-time side effect, and it reports at the place the mistake is made. Proven by flipping the ceiling to 3 and confirming `tsc` errors, then restoring byte-identically. Preferring a distinct origin CAN skip an earlier same-origin URL — `[KEY_A, KEY_B, backup]` selects `[KEY_A, backup]` and never attempts `KEY_B`. That is a deliberate trade, now pinned by test rather than left implicit: two credentials behind one load balancer share a provider, so a provider outage or account-level rate limit takes both out together and leaves a same-origin pair with nothing working, while a distinct origin survives it. With two slots and three endpoints, every possible policy skips some configured URL. Consequences stated rather than buried: * This WIDENS accepted configs. A 3-URL pool with two same-origin aliases was rejected and now constructs. Asserted deliberately. * Selection backfills spare slots with same-origin URLs rather than shrinking the pool, so a load-balancer config carrying per-key URLs keeps its failover. Callers needing real provider diversity must count `selectedOrigins`, never `selected.length`. This function deliberately exposes NO diversity scalar: an earlier revision returned `distinctOriginCount` computed over the whole pool BEFORE truncation while the module header pointed corroboration consumers straight at it — on every shipped EVM network that reported 3 for a session that dials 2 origins, and `canonicalPageProof` requires EXACTLY two distinct origins. A derived value that can disagree with `selectedOrigins` is a trap, so the array is now the single truth. * `selectStrictFinalizedEndpointSessionV1` is NOT exported from the package root. Every consumer imports it by relative path, so exporting it would have published surface with no caller. * The third shipped endpoint (`*.drpc.org`) is now permanently unreachable by strict finalized reads. Selection is health-blind and configuration-ordered, so a degraded primary is still dialled first. Pinned by assertion in the new agent test so it is a known limitation rather than a silent one. * `/api/status` still reports `resolveRpcUrls(...).length` = 3 while a strict session dials 2. Not reconciled here. This does NOT enable dual-origin corroboration. A scope still pins one endpoint for its lifetime; holding two under one lease needs a runner change, deferred to the page-transport PR so §10.3 is not planned against a capability that does not exist. `strict-current-finalized-evm-rpc.unit.test.ts` had one case asserting a 3-endpoint config throws — that case encoded the defect. It is now a positive assertion that selection happens; its other five rejection cases are unchanged. No `instanceof TypeError` rethrow guard sits in the conversion: core routes every fault through `fail()`, including the `new URL()` parse failure it catches and re-fails, so there is no native `TypeError` path to preserve. A guard for it was dead code — deleting it left the suite green. Element faults are reported as `TypeError`. Core throws `VmUpdateConvergenceError`, which extends `Error`, and the config's translating try/catch closes BEFORE the selection call — so without conversion, core's error escaped a validator whose entire documented contract is `TypeError`, carrying a `vm-update:` message for an RPC-config fault. The pre-existing `toThrow(TypeError)` assertion could not catch this: no fixture in any suite reached the 4096-byte bound. The new case is message-pinned rather than type-pinned for that reason. Only ONE fault class actually reaches that conversion from the config. Chain's own `normalizeEndpoint` catches non-strings, null/undefined, objects and unparseable URLs inside the loop before selection runs, each with its own `TypeError`; measured end-to-end, the byte bound is the only element fault that gets through. So the conversion is defense-in-depth rather than the contract's load-bearing layer — and the message-pinned oversize row is not merely good practice, it is the ONLY end-to-end assertion that exercises the conversion at all. Without it the conversion would be covered solely by direct-selector tests. The conversion is at the core call site rather than a pre-check in chain's own `normalizeEndpoint`, which would avoid the conversion entirely by rejecting oversize endpoints before core sees them. That was considered and rejected: core's `MAX_SCALAR_BYTES` is module-private, so a pre-check must hardcode 4096 and would silently disagree the moment core changes it — reintroducing the exact drift class that importing core's function removes. Converting whatever core decides needs no knowledge of the bound. An empty pool now fails closed too; it was the one input the function did not guard. RFC64 precommit fixtures are shared, not copied. The shipped-pool regression varies exactly one field (`rpcEndpoints`), so the plan, accepted-policy snapshot and base options now live in `test/support/rfc64-finalized-vm-precommit-fixture.ts` and both precommit suites consume them: -161 lines across the two test files. Previously a change to the plan or policy shape needed synchronized edits in two places before either suite's actual assertion could run. Verification: * Chain unit lane 44 files, 882 tests, 1 skipped. A rotating handful of real-timer loopback tests fail under machine load: FIVE different tests observed across runs, each passing on a later run. They are load-dependent rather than lane-dependent — running the file alone does not reliably fix them, so "passes solo" is not the argument. Not attributable here for two independent reasons. First, selection is the IDENTITY function for every config involved, checked individually: three carry a single endpoint, one carries two distinct-port origins, and `applies the two-endpoint ceiling after normalized endpoint deduplication` carries three entries that href-dedup to two distinct origins. None exceeds the bound, so no truncation occurs and the endpoint list is byte-identical before and after. Second, independently reproduced in review: the file passes at `fa9dca15e` (30/30) and at `c557f1a61` (31/31) on one machine minutes apart, which rules the change out directly rather than arguing from timing. Later commits add only input guards and touch no timing path. * Nine mutants at this revision (fifteen across the earlier rounds, two of which targeted code this simplification deletes), each killed by its NAMED test, every restore byte-identical, baseline green before each — plus independent auditor mutants from review. Three assertions added in the final round because a mutant proved they could not fail: the pool cap was one-sided (any value in [3,32] was indistinguishable), the `{ cause }` fix was entirely unasserted (deleting it left the suite green), and the default-port test asserted only `toHaveLength(2)`, which backfill guarantees whether or not the port is part of provider identity. * The runner refuses to start unless the tree is green first. It fired: a twelve-mutant run aborted with "tree not green before mutation" because a concurrent lane had pushed a load-sensitive test to 14.3s. Without it that run would have produced six results indistinguishable from real kills. * Agent seam suites green against a force-rebuilt `dist` (`tsbuildinfo` removed — `composite: true` otherwise skips emit and can leave stale or mutated code in `dist` while reporting success). * New agent test drives the REAL precommit with the REAL shipped testnet pool and stubs only global `fetch`. Fail-before proved by reverting the config to its base body: reverted -> exit 1 with "requires 1..2 distinct endpoints" and zero dials; restored byte-identical -> exit 0, two endpoints dialled. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01SaXdkmteqStxGBpXcUPGd1 --- ...64-finalized-vm-agent-precommit-v1.test.ts | 82 +---- ...inalized-vm-precommit-shipped-pool.test.ts | 95 ++++++ .../rfc64-finalized-vm-precommit-fixture.ts | 120 +++++++ packages/agent/vitest.rfc64-unit-tests.ts | 1 + .../strict-current-finalized-evm-config.ts | 108 ++++++- .../src/strict-finalized-endpoint-session.ts | 108 +++++++ ...ict-current-finalized-evm-rpc.unit.test.ts | 41 ++- ...ndpoint-ceiling-postcondition.unit.test.ts | 81 +++++ ...ct-finalized-endpoint-session.unit.test.ts | 304 ++++++++++++++++++ 9 files changed, 848 insertions(+), 92 deletions(-) create mode 100644 packages/agent/test/rfc64-finalized-vm-precommit-shipped-pool.test.ts create mode 100644 packages/agent/test/support/rfc64-finalized-vm-precommit-fixture.ts create mode 100644 packages/chain/src/strict-finalized-endpoint-session.ts create mode 100644 packages/chain/test/strict-finalized-endpoint-ceiling-postcondition.unit.test.ts create mode 100644 packages/chain/test/strict-finalized-endpoint-session.unit.test.ts diff --git a/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts b/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts index bb560b3bfb..568ac00ffc 100644 --- a/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts +++ b/packages/agent/test/rfc64-finalized-vm-agent-precommit-v1.test.ts @@ -1,30 +1,17 @@ -import { - CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, - type AuthorCatalogScopeV1, - type ContextGraphPolicyV1, - type Digest32V1, -} from '@origintrail-official/dkg-core'; -import { OxigraphStore } from '@origintrail-official/dkg-storage'; import { describe, expect, it, vi } from 'vitest'; -import type { AcceptedRfc64CatalogAccessSnapshotV1 } from '../src/rfc64/catalog-access-policy-v1.js'; import { createRfc64FinalizedVmAgentPrecommitV1 } from '../src/rfc64/finalized-vm-agent-precommit-v1.js'; -import type { Rfc64PublicCatalogNativeBeforeAppliedHeadCommitPlanV1 } from '../src/rfc64/public-catalog-native-receiver-v1.js'; import { - RFC64_VM_AUTHOR, - RFC64_VM_BLOCK_HASH, - RFC64_VM_CG_STORAGE, RFC64_VM_CHAIN_ID, RFC64_VM_CONTEXT_GRAPH_NAME, - RFC64_VM_KAV10, RFC64_VM_KA_STORAGE, - RFC64_VM_NETWORK_ID, RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, - RFC64_VM_POLICY_DIGEST, } from './support/rfc64-finalized-vm-placement-fixture.js'; +import { + rfc64FinalizedVmPrecommitOptions as baseOptions, + rfc64FinalizedVmPrecommitPlan as plan, +} from './support/rfc64-finalized-vm-precommit-fixture.js'; -const CATALOG_HEAD_DIGEST = `0x${'91'.repeat(32)}` as Digest32V1; -const INVENTORY_DIGEST = `0x${'92'.repeat(32)}` as Digest32V1; describe('RFC-64 finalized VM agent precommit', () => { it('rejects when the cleartext catalog lane has no numeric on-chain binding', async () => { @@ -101,66 +88,5 @@ describe('RFC-64 finalized VM agent precommit', () => { }); }); -function baseOptions() { - return { - acceptedPolicySnapshotForCatalogScope: () => acceptedPolicy(), - rpcEndpoints: ['http://127.0.0.1:8545'], - getOnChainContextGraphId: async () => RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, - getEvmChainId: async () => BigInt(RFC64_VM_CHAIN_ID), - getKnowledgeAssetStorageAddress: async () => RFC64_VM_KA_STORAGE, - getKnowledgeAssetsLifecycleAddress: async () => RFC64_VM_KAV10, - store: new OxigraphStore(), - } as const; -} -function plan(): Readonly { - return Object.freeze({ - catalogScope: Object.freeze({ - networkId: RFC64_VM_NETWORK_ID, - contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, - governanceChainId: RFC64_VM_CHAIN_ID, - governanceContractAddress: RFC64_VM_CG_STORAGE, - ownershipTransitionDigest: null, - subGraphName: null, - authorAddress: RFC64_VM_AUTHOR, - era: '0', - bucketCount: '1', - } satisfies AuthorCatalogScopeV1), - catalogHeadDigest: CATALOG_HEAD_DIGEST, - inventoryDigest: INVENTORY_DIGEST, - rows: Object.freeze([]), - }); -} -function acceptedPolicy(): AcceptedRfc64CatalogAccessSnapshotV1 { - const policy = Object.freeze({ - networkId: RFC64_VM_NETWORK_ID, - contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, - governanceChainId: RFC64_VM_CHAIN_ID, - governanceContractAddress: RFC64_VM_CG_STORAGE, - ownershipTransitionDigest: null, - era: '0', - version: '0', - previousPolicyDigest: null, - accessPolicy: 0, - publishPolicy: 1, - publishAuthority: null, - publishAuthorityAccountId: '0', - projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, - administrativeDelegationDigest: null, - source: { - kind: 'finalized-chain', - chainId: RFC64_VM_CHAIN_ID, - contractAddress: RFC64_VM_CG_STORAGE, - blockNumber: '123', - blockHash: RFC64_VM_BLOCK_HASH, - }, - effectiveAt: '1700000000000', - issuedAt: '1700000000000', - } satisfies ContextGraphPolicyV1); - return Object.freeze({ - policy, - policyDigest: RFC64_VM_POLICY_DIGEST, - roster: null, - }); -} diff --git a/packages/agent/test/rfc64-finalized-vm-precommit-shipped-pool.test.ts b/packages/agent/test/rfc64-finalized-vm-precommit-shipped-pool.test.ts new file mode 100644 index 0000000000..f9350ec7b2 --- /dev/null +++ b/packages/agent/test/rfc64-finalized-vm-precommit-shipped-pool.test.ts @@ -0,0 +1,95 @@ +/** + * The integration seam this change exists to fix. + * + * The chain-package tests prove that `snapshotStrictCurrentFinalizedEvmConfigV1` + * accepts a three-endpoint pool. They do not prove the thing the change claims: + * that RFC64's finalized-VM precommit — the only production caller — can now + * construct its snapshot scope on a shipped network. That claim was previously + * demonstrated nowhere. The existing precommit suite hardcodes ONE endpoint + * (`rfc64-finalized-vm-agent-precommit-v1.test.ts`), so it constructed fine both + * before and after; the defect lived exactly in the gap between the two suites. + * + * So this drives the REAL precommit with the REAL shipped pool + * (`resolveRpcUrls(chain.rpcUrl, chain.rpcUrls)` from `network/testnet.json`, + * three distinct origins) and stubs only the outermost boundary — global + * `fetch` — so the endpoints actually dialled are observable. + * + * Before the fix this rejected with "requires 1..2 distinct endpoints" and + * dialled nothing. Both assertions below therefore fail on the unfixed code: the + * first on the message, the second on an empty dial log. + */ +import { resolveRpcUrls } from '@origintrail-official/dkg-chain'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createRfc64FinalizedVmAgentPrecommitV1 } from '../src/rfc64/finalized-vm-agent-precommit-v1.js'; +import { + rfc64FinalizedVmPrecommitOptions, + rfc64FinalizedVmPrecommitPlan, +} from './support/rfc64-finalized-vm-precommit-fixture.js'; + +const REPO_ROOT = join(import.meta.dirname, '..', '..', '..'); + +/** Exactly what the daemon hands the precommit: primary PLUS backups. */ +function shippedTestnetPool(): string[] { + const config = JSON.parse( + readFileSync(join(REPO_ROOT, 'network', 'testnet.json'), 'utf8'), + ); + return resolveRpcUrls(config.chain.rpcUrl, config.chain.rpcUrls); +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('RFC-64 finalized VM precommit on a shipped RPC pool', () => { + it('constructs its snapshot scope and dials the selected endpoints', async () => { + const pool = shippedTestnetPool(); + // Guards the fixture itself: if `network/testnet.json` ever ships two URLs, + // this test would silently stop covering the oversized-pool case. + expect(pool.length).toBe(3); + expect(new Set(pool.map((url) => new URL(url).origin)).size).toBe(3); + + const dialled: string[] = []; + vi.stubGlobal('fetch', async (input: unknown) => { + dialled.push(String(input)); + // Fail the transport, not the construction. Preflight exhausting every + // selected endpoint is the shortest deterministic path that still proves + // the scope was built and handed real URLs. + throw new Error('stubbed transport failure'); + }); + + // Only `rpcEndpoints` varies — that is the whole point of this regression. + const precommit = createRfc64FinalizedVmAgentPrecommitV1( + rfc64FinalizedVmPrecommitOptions({ rpcEndpoints: pool }), + ); + + // The precommit still fails — there is no chain behind the stub — but it must + // fail at the TRANSPORT, downstream of scope construction. Every selected + // endpoint failing preflight retryably surfaces the last retryable failure, + // which is the JSON-RPC one; the pre-fix failure was a TypeError about the + // endpoint count, raised before any dial happened at all. + await expect( + precommit(rfc64FinalizedVmPrecommitPlan(), new AbortController().signal), + ).rejects.toThrow(/JSON-RPC eth_chainId transport failed/i); + + // The load-bearing assertion: selection reached the wire. Two of the three + // shipped endpoints were dialled, in configuration order. + // + // Compared as URLs, not as raw strings: the config stores endpoints after + // `new URL(...)` normalization, so `https://sepolia.base.org` arrives at + // `fetch` with a trailing slash. Comparing `href` on both sides keeps the + // assertion about WHICH endpoint was dialled instead of quietly restating + // the normalizer's spelling rules. + const href = (url: string) => new URL(url).href; + expect([...new Set(dialled)]).toEqual([href(pool[0]!), href(pool[1]!)]); + // And the third is stranded — stated as a tested fact rather than left as a + // silent consequence. Selection is health-blind and configuration-ordered, + // so `base-sepolia.drpc.org` is never reached by a strict finalized read even + // when the primary is degraded. Tracked as a follow-up, not fixed here. + expect(dialled).not.toContain(href(pool[2]!)); + }); +}); + + diff --git a/packages/agent/test/support/rfc64-finalized-vm-precommit-fixture.ts b/packages/agent/test/support/rfc64-finalized-vm-precommit-fixture.ts new file mode 100644 index 0000000000..6352d17b51 --- /dev/null +++ b/packages/agent/test/support/rfc64-finalized-vm-precommit-fixture.ts @@ -0,0 +1,120 @@ +/** + * Shared fixtures for the RFC-64 finalized-VM agent precommit tests. + * + * Extracted because two suites need the same plan, accepted-policy snapshot and + * base options while varying one field each — the shipped-pool regression only + * varies `rpcEndpoints`. Keeping a second copy meant a change to the plan or + * policy shape required synchronized edits across files before either suite's + * actual assertion could run. + * + * The digests are exported so a caller can assert against them rather than + * re-deriving the literals. + */ +import { + CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + type AuthorCatalogScopeV1, + type ContextGraphPolicyV1, + type Digest32V1, +} from '@origintrail-official/dkg-core'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; + +import type { AcceptedRfc64CatalogAccessSnapshotV1 } from '../../src/rfc64/catalog-access-policy-v1.js'; +import type { Rfc64FinalizedVmAgentPrecommitOptionsV1 } from '../../src/rfc64/finalized-vm-agent-precommit-v1.js'; +import type { Rfc64PublicCatalogNativeBeforeAppliedHeadCommitPlanV1 } from '../../src/rfc64/public-catalog-native-receiver-v1.js'; +import { + RFC64_VM_AUTHOR, + RFC64_VM_BLOCK_HASH, + RFC64_VM_CG_STORAGE, + RFC64_VM_CHAIN_ID, + RFC64_VM_CONTEXT_GRAPH_NAME, + RFC64_VM_KAV10, + RFC64_VM_KA_STORAGE, + RFC64_VM_NETWORK_ID, + RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, + RFC64_VM_POLICY_DIGEST, +} from './rfc64-finalized-vm-placement-fixture.js'; + +export const RFC64_VM_CATALOG_HEAD_DIGEST = `0x${'91'.repeat(32)}` as Digest32V1; +export const RFC64_VM_INVENTORY_DIGEST = `0x${'92'.repeat(32)}` as Digest32V1; + +/** The before-applied-head commit plan the precommit is driven with. */ +export function rfc64FinalizedVmPrecommitPlan(): +Readonly { + return Object.freeze({ + catalogScope: Object.freeze({ + networkId: RFC64_VM_NETWORK_ID, + contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, + governanceChainId: RFC64_VM_CHAIN_ID, + governanceContractAddress: RFC64_VM_CG_STORAGE, + ownershipTransitionDigest: null, + subGraphName: null, + authorAddress: RFC64_VM_AUTHOR, + era: '0', + bucketCount: '1', + } satisfies AuthorCatalogScopeV1), + catalogHeadDigest: RFC64_VM_CATALOG_HEAD_DIGEST, + inventoryDigest: RFC64_VM_INVENTORY_DIGEST, + rows: Object.freeze([]), + }); +} + +/** One accepted, public, finalized-chain policy snapshot. */ +export function acceptedRfc64VmPolicySnapshot(): AcceptedRfc64CatalogAccessSnapshotV1 { + const policy = Object.freeze({ + networkId: RFC64_VM_NETWORK_ID, + contextGraphId: RFC64_VM_CONTEXT_GRAPH_NAME, + governanceChainId: RFC64_VM_CHAIN_ID, + governanceContractAddress: RFC64_VM_CG_STORAGE, + ownershipTransitionDigest: null, + era: '0', + version: '0', + previousPolicyDigest: null, + accessPolicy: 0, + publishPolicy: 1, + publishAuthority: null, + publishAuthorityAccountId: '0', + projectionId: CONTEXT_GRAPH_SHARED_PROJECTION_ID_V1, + administrativeDelegationDigest: null, + source: { + kind: 'finalized-chain', + chainId: RFC64_VM_CHAIN_ID, + contractAddress: RFC64_VM_CG_STORAGE, + blockNumber: '123', + blockHash: RFC64_VM_BLOCK_HASH, + }, + effectiveAt: '1700000000000', + issuedAt: '1700000000000', + } satisfies ContextGraphPolicyV1); + return Object.freeze({ + policy, + policyDigest: RFC64_VM_POLICY_DIGEST, + roster: null, + }); +} + +/** + * Base precommit options. Each suite overrides the one field it is about — a + * single resolver for the noncanonical-input cases, `rpcEndpoints` for the + * shipped-pool regression. + * + * A fresh `OxigraphStore` per call: sharing one across tests would let state + * from an earlier case leak into a later assertion. + * + * Overrides are `Partial` rather than a + * loose record, so a misspelled key or a wrongly-shaped value is a type error at + * the call site that introduces it, not a silently-ignored property. + */ +export function rfc64FinalizedVmPrecommitOptions( + overrides: Partial = {}, +): Rfc64FinalizedVmAgentPrecommitOptionsV1 { + return { + acceptedPolicySnapshotForCatalogScope: () => acceptedRfc64VmPolicySnapshot(), + rpcEndpoints: ['http://127.0.0.1:8545'], + getOnChainContextGraphId: async () => RFC64_VM_ON_CHAIN_CONTEXT_GRAPH_ID, + getEvmChainId: async () => BigInt(RFC64_VM_CHAIN_ID), + getKnowledgeAssetStorageAddress: async () => RFC64_VM_KA_STORAGE, + getKnowledgeAssetsLifecycleAddress: async () => RFC64_VM_KAV10, + store: new OxigraphStore(), + ...overrides, + }; +} diff --git a/packages/agent/vitest.rfc64-unit-tests.ts b/packages/agent/vitest.rfc64-unit-tests.ts index 491b0eb465..440cc32076 100644 --- a/packages/agent/vitest.rfc64-unit-tests.ts +++ b/packages/agent/vitest.rfc64-unit-tests.ts @@ -11,6 +11,7 @@ export const RFC64_UNIT_TESTS = [ "test/rfc64-finalized-vm-composer-v1.test.ts", "test/rfc64-finalized-vm-runtime-v1.test.ts", "test/rfc64-finalized-vm-agent-precommit-v1.test.ts", + "test/rfc64-finalized-vm-precommit-shipped-pool.test.ts", "test/rfc64-agent-inventory-lifecycle.test.ts", "test/rfc64-author-catalog-producer.test.ts", "test/rfc64-control-object-store-v1.test.ts", diff --git a/packages/chain/src/strict-current-finalized-evm-config.ts b/packages/chain/src/strict-current-finalized-evm-config.ts index c9f5f5e8f9..8d063278a7 100644 --- a/packages/chain/src/strict-current-finalized-evm-config.ts +++ b/packages/chain/src/strict-current-finalized-evm-config.ts @@ -1,7 +1,13 @@ import { assertCanonicalChainId } from '@origintrail-official/dkg-core'; import { CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1 } from './current-finalized-evm-read-profile.js'; +import { normalizeEndpointOrigin } from '@origintrail-official/dkg-core'; + import { snapshotDenseDataArray } from './strict-local-data.js'; +import { + selectStrictFinalizedEndpointSessionV1, + type StrictFinalizedEndpointV1, +} from './strict-finalized-endpoint-session.js'; import { FINALIZED_CHAIN_READ_OWNERS, type FinalizedChainReadOwnerV1, @@ -136,29 +142,101 @@ function assertConfigDataProperties( } function snapshotNormalizedEndpoints(input: unknown): readonly string[] { - const normalized: string[] = []; + const normalized: StrictFinalizedEndpointV1[] = []; + const seen = new Set(); try { const endpoints = snapshotDenseDataArray(input, { label: 'Strict current-finalized RPC endpoints', minLength: 1, + // Deliberately NOT bounded on the raw array. Base deduplicated by href + // BEFORE applying its count check, so a config of 33 identical URLs — or + // 40 entries collapsing to two — normalized to a valid pool and + // constructed. A cap on the raw array turns those into a construction + // failure and silently narrows the published contract from "at most two + // distinct normalized endpoints" to "at most N raw entries". The dedup + // below is O(n) via a Set and selection is a fixed two-slot scan, so an + // oversized array costs a linear pass, not quadratic work; the attempt + // count is bounded by selection and the postcondition regardless. }); for (const entry of endpoints) { const endpoint = normalizeEndpoint(entry); - if (!normalized.includes(endpoint)) normalized.push(endpoint); + if (seen.has(endpoint.href)) continue; + seen.add(endpoint.href); + normalized.push(endpoint); } } catch (cause) { if (cause instanceof TypeError) throw cause; - throw new TypeError('Strict current-finalized endpoints must be a dense data-only array'); + // `cause` preserved: this catch flattens whatever `snapshotDenseDataArray` + // reports into a single shape complaint, so the specific detail is otherwise + // unrecoverable by a caller. The message is left alone because tests pin it. + throw new TypeError( + 'Strict current-finalized endpoints must be a dense data-only array', + { cause }, + ); } - if (normalized.length === 0 || normalized.length > CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { + // No emptiness check here: `snapshotDenseDataArray(..., { minLength: 1 })` + // above already rejects an empty pool, and a non-empty pool either throws in + // `normalizeEndpoint` or yields at least one entry. A second check would be + // unreachable, and its old message advertised the `1..2` contract this change + // deliberately replaced. + + // Session selection. Every endpoint above has already been validated and + // deduplicated by URL, so an invalid entry anywhere in the pool still fails + // closed — selection only decides which of the VALID ones this session uses. + // + // Before this, a pool larger than the attempt ceiling was fatal: + // `resolveRpcUrls(chain.rpcUrl, chain.rpcUrls)` is three URLs on testnet, + // mainnet-base and mainnet-gnosis, so RFC64's finalized-VM precommit could not + // construct a snapshot scope on any shipped EVM network. Selecting at most two + // distinct provider ORIGINS satisfies the ceiling by construction, which is + // why the ceiling itself is left untouched. + const selected = selectStrictFinalizedEndpointSessionV1(normalized); + + // Postcondition, not paranoia. The attempt ceiling used to be enforced HERE by + // rejecting oversized pools; that rejection is what this change removes. But + // the runner does not read `maxAttempts` — it attempts one endpoint per entry + // of this list (`strict-current-finalized-evm-lifecycle.ts:104`) — so the + // value attested as `CONTROL_EIP1271_MAX_ATTEMPTS_V1` and verified at + // `current-finalized-evm-call.ts:182` is truthful ONLY while this list is + // bounded. Leaving that to a `>=` inside another module would make an attested + // claim depend on a remote implementation detail; a caller passing a bad bound + // there would silently widen the attempt count instead of failing closed. + // + // This guards the GENERIC constant, not the attested one, and that is the + // correct layering: `current-finalized-evm-read-profile.ts` states these limits + // belong to the generic finalized-read boundary and that EIP-1271 is one + // specialization which must not implicitly redefine unrelated finalized reads. + // Importing the control constant here would invert that dependency. The guard + // therefore protects the attested value via the alias at + // `control-object-signature-verifier.ts:56`, and that alias is already pinned + // by `strict-current-finalized-evm-rpc.unit.test.ts` ("keeps the EIP-1271 + // specialization pinned to the generic finalized-read profile"), so it cannot + // be broken silently — verified by mutating the alias to its own literal, + // which that test kills. + if (selected.length > CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1) { throw new TypeError( - `Strict current-finalized RPC requires 1..${CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1} distinct endpoints`, + 'Strict current-finalized endpoint selection exceeded the attested attempt ceiling', ); } - return Object.freeze(normalized); + return Object.freeze([...selected]); } -function normalizeEndpoint(input: unknown): string { +/** + * The canonical endpoint boundary: one pass that yields BOTH the dial URL and + * the provider-origin identity selection needs. + * + * Deriving the origin here rather than in the selector means the URL is parsed + * once, and the selector becomes a policy over an already-valid model instead of + * a second validator defending against states this function has already made + * impossible. + * + * The origin comes from core's `normalizeEndpointOrigin` — the same predicate + * `canonicalPageProof` uses to decide `dual-origin-corroborated`, so the two + * layers cannot drift. It is handed `url.origin`, never the full dial URL: core + * bounds its input at the canonical scalar limit, and applying that to the whole + * URL would impose a length limit RPC endpoints never had. + */ +function normalizeEndpoint(input: unknown): StrictFinalizedEndpointV1 { if (typeof input !== 'string' || input.trim() === '') { throw new TypeError('Strict current-finalized RPC endpoint must be a nonempty URL string'); } @@ -171,7 +249,21 @@ function normalizeEndpoint(input: unknown): string { if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.hash !== '') { throw new TypeError('Strict current-finalized RPC endpoint must use HTTP(S) without a fragment'); } - return url.href; + let origin: string; + try { + origin = normalizeEndpointOrigin(url.origin, 'strict current-finalized RPC endpoint origin'); + } catch (cause) { + // Core throws `VmUpdateConvergenceError`, which extends `Error`, while every + // rejection in this module is a `TypeError`. http(s) URLs always have a + // tuple origin so this is not reachable from the scheme check above, but the + // conversion keeps the module's error contract total rather than resting on + // that argument. + throw new TypeError( + 'Strict current-finalized RPC endpoint has no usable provider origin', + { cause }, + ); + } + return Object.freeze({ href: url.href, origin }); } function isPlainRecord(value: unknown): value is Record { diff --git a/packages/chain/src/strict-finalized-endpoint-session.ts b/packages/chain/src/strict-finalized-endpoint-session.ts new file mode 100644 index 0000000000..50bce23288 --- /dev/null +++ b/packages/chain/src/strict-finalized-endpoint-session.ts @@ -0,0 +1,108 @@ +/** + * Endpoint policy for strict finalized reads: two slots, distinct provider + * origins preferred. + * + * Before this existed, a runtime RPC pool larger than the attempt ceiling was + * fatal rather than merely inconvenient: `resolveRpcUrls(chain.rpcUrl, + * chain.rpcUrls)` yields **three** URLs on testnet, mainnet-base and + * mainnet-gnosis, while `snapshotNormalizedEndpoints` rejected more than + * `CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1 = 2` distinct endpoints — so + * RFC64's finalized-VM precommit could not construct a snapshot scope on any + * shipped EVM network. + * + * This module is POLICY ONLY. Validation, parsing, origin derivation and + * deduplication all belong to the endpoint boundary in + * `strict-current-finalized-evm-config.ts`, which hands over an already-valid + * `{ href, origin }` model. That is why there is no input validation here: the + * states a second validator would defend against cannot reach it. + * + * **Why prefer a distinct origin over configuration order.** The selected array + * is consumed as an ordered failover list, so preferring a distinct origin can + * skip an earlier same-origin URL: `[KEY_A, KEY_B, backup]` selects + * `[KEY_A, backup]` and never attempts `KEY_B`. That is a deliberate trade and + * not a free one. Two credentials behind one load balancer share a provider, so + * the dominant failure mode — that provider down, or rate-limiting the account — + * takes both out together and leaves a same-origin pair with no working endpoint + * at all. A distinct origin is the only selection that survives it. The case the + * trade loses is narrower: KEY_A degraded, KEY_B healthy, AND the distinct + * origin also down. With two slots and three endpoints some configured URL is + * skipped under every possible policy; this picks the one that keeps failover + * across providers. + * + * **Backfill preserves base behaviour.** When no second origin exists, the + * second configured URL is used anyway. At base a two-URL same-origin pool + * constructed as two dialable endpoints, and collapsing it to one would halve + * failover for a deliberate operator config. + * + * Order is always the caller's configuration order — both slots are taken from + * the pool front-to-back, so no ordering repair is needed or performed. + */ +import { + CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1, +} from './current-finalized-evm-read-profile.js'; + +/** + * The number of slots this policy fills, as a LITERAL rather than an alias of + * the attempt ceiling. + * + * The algorithm is genuinely fixed at two — first URL, then the first later URL + * with a different origin — so aliasing the ceiling would export a value that + * can change while the behaviour does not. Raising the ceiling to 3 would have + * made this constant read 3 while selection still returned two endpoints. + */ +const SLOTS = 2; + +// COMPILE-TIME assertion that the ceiling this policy is written against has not +// moved. `CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1` is declared as `= 2`, so +// its inferred type is the literal `2`; raising it makes this line a type error +// and `tsc` fails the build. +// +// Deliberately a type, not a runtime check. An earlier revision threw at module +// load, which enforced the same invariant but made a constant change surface as +// a package that will not import, and forced the test for it to mock module +// loading. This costs nothing at runtime, cannot be skipped the way a test can, +// and reports at the place the mistake is made. +// The assigned value must be `true`, NOT a cast. An earlier revision wrote +// `undefined as never`, which type-checks against `never` and made the whole +// assertion decorative — it could not fail. Verified by flipping the ceiling to +// 3 and confirming `tsc` errors here. +type AssertPolicyMatchesCeiling = + [typeof CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1] extends [typeof SLOTS] ? true : never; +const _assertPolicyMatchesCeiling: AssertPolicyMatchesCeiling = true; +void _assertPolicyMatchesCeiling; + +/** One validated endpoint: what to dial, and which provider it belongs to. */ +export interface StrictFinalizedEndpointV1 { + /** Normalized absolute URL, as produced by the config's endpoint boundary. */ + readonly href: string; + /** Provider identity — core's origin rule, NOT the URL. */ + readonly origin: string; +} + +/** + * Pick the endpoints one strict finalized session may dial, in configuration + * order: the first endpoint, then the first later endpoint with a DIFFERENT + * provider origin, falling back to the second when the pool offers only one + * origin. + * + * Expects a non-empty, already-validated, already-deduplicated pool. + */ +export function selectStrictFinalizedEndpointSessionV1( + endpoints: readonly StrictFinalizedEndpointV1[], +): readonly string[] { + const first = endpoints[0]; + if (first === undefined) { + // Not reachable from the config, whose `snapshotDenseDataArray(..., { + // minLength: 1 })` rejects an empty pool first. Kept because an empty + // selection returned as a valid session would dial nothing, and a caller + // could read a zero-attempt result as a successful one. + throw new TypeError('Strict finalized endpoint selection requires at least one endpoint'); + } + + const distinctOrigin = endpoints + .slice(1) + .find((endpoint) => endpoint.origin !== first.origin); + const second = distinctOrigin ?? endpoints[1]; + + return Object.freeze(second === undefined ? [first.href] : [first.href, second.href]); +} diff --git a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts index f1f6588098..fa4b936881 100644 --- a/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts +++ b/packages/chain/test/strict-current-finalized-evm-rpc.unit.test.ts @@ -4,6 +4,8 @@ import { } from '@origintrail-official/dkg-core'; import { afterEach, describe, expect, it } from 'vitest'; +import { snapshotStrictCurrentFinalizedEvmConfigV1 } from '../src/strict-current-finalized-evm-config.js'; + import { CONTROL_EIP1271_ATTEMPT_TIMEOUT_MS_V1, CONTROL_EIP1271_CALL_FROM_V1, @@ -873,18 +875,13 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { expect(second.calls).toHaveLength(4); }, 12_000); - it('rejects unsafe configuration and more than two distinct normalized endpoints', () => { + it('rejects unsafe configuration', () => { const third = 'http://127.0.0.1:3'; for (const config of [ { chainId: '020430', endpoints: ['http://127.0.0.1:1'] }, { chainId: CHAIN_ID, endpoints: [] }, { chainId: CHAIN_ID, endpoints: ['ftp://127.0.0.1/a'] }, { chainId: CHAIN_ID, endpoints: ['http://127.0.0.1/a#fragment'] }, - { chainId: CHAIN_ID, endpoints: [ - 'http://127.0.0.1:1', - 'http://127.0.0.1:2', - third, - ] }, { chainId: CHAIN_ID, endpoints: ['http://127.0.0.1:1'], peerEndpoint: third }, ]) { expect(() => createStrictCurrentFinalizedEvmChainAdapterV1( @@ -892,6 +889,38 @@ describe('RFC-64 strict current-finalized raw JSON-RPC transport', () => { )).toThrow(TypeError); } }); + + it('SELECTS the first two origins from a larger pool instead of rejecting it', () => { + // Behaviour change, and the point of the change. This case previously sat in + // the rejection list above — which is precisely why RFC64's finalized-VM + // precommit could not construct a scope on any shipped EVM network, where + // `resolveRpcUrls(rpcUrl, rpcUrls)` yields three URLs. + // + // The two-endpoint ATTEMPT ceiling is unchanged; selection just makes sure + // no more than two ever reach it. The dedup-then-ceiling test above still + // pins that ceiling. + // Assert WHICH two, not merely that it constructed — "returns a function" + // would pass just as green if selection kept the last two, or one, or all. + // Driven through the CONFIG boundary rather than the selector directly: the + // config is what derives provider identity, so this exercises the real + // pipeline instead of hand-built records. + const selection = snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: CHAIN_ID, + endpoints: ['http://127.0.0.1:1', 'http://127.0.0.1:2', 'http://127.0.0.1:3'], + } as never).endpoints; + // Distinct ports are distinct origins, so this session really does carry two + // providers — and the third is dropped, not merged. + expect(selection).toEqual(['http://127.0.0.1:1/', 'http://127.0.0.1:2/']); + + expect(() => createStrictCurrentFinalizedEvmChainAdapterV1({ + chainId: CHAIN_ID, + endpoints: [ + 'http://127.0.0.1:1', + 'http://127.0.0.1:2', + 'http://127.0.0.1:3', + ], + } as StrictCurrentFinalizedEvmRpcConfigV1)).not.toThrow(); + }); }); interface SuccessfulHandlerOptions { diff --git a/packages/chain/test/strict-finalized-endpoint-ceiling-postcondition.unit.test.ts b/packages/chain/test/strict-finalized-endpoint-ceiling-postcondition.unit.test.ts new file mode 100644 index 0000000000..0846a82375 --- /dev/null +++ b/packages/chain/test/strict-finalized-endpoint-ceiling-postcondition.unit.test.ts @@ -0,0 +1,81 @@ +/** + * The attempt ceiling used to be enforced by the config validator rejecting any + * pool larger than `CURRENT_FINALIZED_EVM_READ_MAX_ATTEMPTS_V1`. Session + * selection replaced that rejection, so the ceiling is now satisfied by + * construction — and the config keeps a postcondition on the result. + * + * That postcondition is UNREACHABLE from production callers: the config passes + * the ceiling itself as the bound, so a correct selector can never exceed it. + * An unreachable guard is normally a check that cannot fail, which is why it is + * worth stating what it is actually for and proving it discriminates. + * + * What it is for: the runner never reads `maxAttempts`. It attempts one endpoint + * per entry of `profile.endpoints` (`strict-current-finalized-evm-lifecycle.ts`), + * while `CONTROL_EIP1271_MAX_ATTEMPTS_V1 = 2` is attested in the control + * envelope and verified at `current-finalized-evm-call.ts:182`. So the attested + * attempt count is truthful only while something bounds this list. Without the + * postcondition that "something" lives entirely inside another module, and a + * regression there would widen the real attempt count while the attestation kept + * claiming two. + * + * These tests stub the selector to a value a broken selector could produce, and + * assert the config fails closed rather than forwarding it. Mocking the + * collaborator is the only way to reach the branch — the real one is correct. + */ +import { describe, expect, it, vi } from 'vitest'; + +const selectStrictFinalizedEndpointSessionV1 = vi.hoisted(() => vi.fn()); + +vi.mock('../src/strict-finalized-endpoint-session.js', () => ({ + selectStrictFinalizedEndpointSessionV1, +})); + +const { snapshotStrictCurrentFinalizedEvmConfigV1 } = await import( + '../src/strict-current-finalized-evm-config.js' +); + +const CHAIN_ID = '84532'; +const POOL = Object.freeze([ + 'https://a.example.com/rpc', + 'https://b.example.com/rpc', + 'https://c.example.com/rpc', +]); + +function selection(selected: readonly string[]) { + return Object.freeze([...selected]); +} + +describe('the attested attempt ceiling is asserted where it is attested', () => { + + it('fails closed when the selector returns more endpoints than the ceiling', () => { + // Exactly what a regressed bound would produce: the whole pool, unsliced. + selectStrictFinalizedEndpointSessionV1.mockReturnValue(selection(POOL)); + + expect(() => snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: CHAIN_ID, + endpoints: POOL, + } as never)).toThrow('exceeded the attested attempt ceiling'); + }); + + it('accepts a selection at the ceiling, so the guard is a bound and not a ban', () => { + // The discriminator. Without this case the test above would also pass if the + // config rejected EVERY selection, which would break the product entirely. + selectStrictFinalizedEndpointSessionV1.mockReturnValue(selection(POOL.slice(0, 2))); + + const config = snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: CHAIN_ID, + endpoints: POOL, + } as never); + expect(config.endpoints).toEqual([POOL[0], POOL[1]]); + }); + + it('accepts a single endpoint, the other side of the bound', () => { + selectStrictFinalizedEndpointSessionV1.mockReturnValue(selection(POOL.slice(0, 1))); + + const config = snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: CHAIN_ID, + endpoints: POOL, + } as never); + expect(config.endpoints).toEqual([POOL[0]]); + }); +}); diff --git a/packages/chain/test/strict-finalized-endpoint-session.unit.test.ts b/packages/chain/test/strict-finalized-endpoint-session.unit.test.ts new file mode 100644 index 0000000000..723792ab03 --- /dev/null +++ b/packages/chain/test/strict-finalized-endpoint-session.unit.test.ts @@ -0,0 +1,304 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { ChainIdV1 } from '@origintrail-official/dkg-core'; +import { describe, expect, it } from 'vitest'; + +import { resolveRpcUrls } from '../src/evm-adapter-rpc.js'; +import { snapshotStrictCurrentFinalizedEvmConfigV1 } from '../src/strict-current-finalized-evm-config.js'; +import { selectStrictFinalizedEndpointSessionV1 } from '../src/strict-finalized-endpoint-session.js'; + +const REPO_ROOT = join(import.meta.dirname, '..', '..', '..'); + +/** The pool the product actually builds: primary + backups, deduped. */ +function shippedPool(network: string): string[] { + const cfg = JSON.parse(readFileSync(join(REPO_ROOT, 'network', `${network}.json`), 'utf8')); + return resolveRpcUrls(cfg.chain.rpcUrl, cfg.chain.rpcUrls); +} + +function numericChainId(network: string): ChainIdV1 { + const cfg = JSON.parse(readFileSync(join(REPO_ROOT, 'network', `${network}.json`), 'utf8')); + return String(cfg.chain.chainId).split(':').pop() as ChainIdV1; +} + +const EVM_NETWORKS = ['testnet', 'mainnet-base', 'mainnet-gnosis'] as const; + + +/** + * The selector is policy over an already-validated model, so these drive it with + * explicit `{ href, origin }` records. Origin DERIVATION is deliberately not + * tested here — it belongs to the config boundary that owns it, and asserting it + * from a hand-built record would only restate the rule. The config-level suite + * below proves derivation end-to-end through the real pipeline. + */ +const CHAIN = '8453' as ChainIdV1; + +function ep(href: string, origin: string) { + return { href, origin }; +} + +describe('the shipped-pool ceiling defect', () => { + /** + * REGRESSION TEST. Before selection existed this threw + * "Strict current-finalized RPC requires 1..2 distinct endpoints" on every + * shipped EVM network, so RFC64's finalized-VM precommit could not construct + * its snapshot scope at all. + * + * The fixture MUST come from `resolveRpcUrls(rpcUrl, rpcUrls)`. Reading + * `chain.rpcUrls` alone gives two endpoints, which constructs both before and + * after the fix — a test that cannot fail. + */ + it.each(EVM_NETWORKS.map((n) => [n] as const))( + '%s: a real 3-endpoint pool constructs', + (network) => { + const pool = shippedPool(network); + expect(pool.length).toBeGreaterThan(2); // the premise; if this drops to 2 the test is moot + expect(() => + snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: numericChainId(network), + endpoints: pool, + }), + ).not.toThrow(); + }, + ); + + it('selects exactly two endpoints from a shipped three-endpoint pool', () => { + const snapshot = snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: numericChainId('mainnet-base'), + endpoints: shippedPool('mainnet-base'), + }); + expect(snapshot.endpoints).toHaveLength(2); + }); + + it('neuroweb (single endpoint) still constructs and yields that endpoint', () => { + // Driven through the CONFIG, not the selector. An earlier revision passed the + // raw pool straight to the selector after its input became `{ href, origin }` + // records — so it read `.href` off a string, returned `[null]`, and + // `toHaveLength(1)` passed anyway. Asserting the exact URL through the real + // boundary is what makes this case able to fail. + const cfg = JSON.parse( + readFileSync(join(REPO_ROOT, 'network', 'mainnet-neuroweb.json'), 'utf8'), + ); + const pool = resolveRpcUrls(cfg.chain.rpcUrl, cfg.chain.rpcUrls); + expect(pool).toHaveLength(1); + const snapshot = snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: numericChainId('mainnet-neuroweb'), + endpoints: pool, + }); + expect(snapshot.endpoints).toEqual([new URL(pool[0]!).href]); + }); +}); + +describe('selectStrictFinalizedEndpointSessionV1 — two-slot policy', () => { + const A1 = ep('https://lb.provider.io/v2/KEY_A', 'https://lb.provider.io'); + const A2 = ep('https://lb.provider.io/v2/KEY_B', 'https://lb.provider.io'); + const B = ep('https://backup.example.com/', 'https://backup.example.com'); + const C = ep('https://third.example.com/', 'https://third.example.com'); + + it('takes the first endpoint and the first later DISTINCT origin', () => { + expect(selectStrictFinalizedEndpointSessionV1([A1, A2, B])).toEqual([A1.href, B.href]); + }); + + it('SKIPS an earlier same-origin URL to reach a distinct provider — stated trade', () => { + // The selected array is an ordered failover list, so this IS a priority + // inversion: A2 is configured second and never attempted. Pinned rather than + // left implicit. A1 and A2 share a provider, so a provider outage or an + // account-level rate limit takes both out together and the distinct origin + // is the only slot that survives it. With two slots and three endpoints, + // every possible policy skips someone. + expect(selectStrictFinalizedEndpointSessionV1([A1, A2, B])).not.toContain(A2.href); + }); + + it('BACKFILLS with a same-origin sibling rather than reducing failover', () => { + // Base constructed a two-URL same-origin pool as TWO dialable endpoints. + // Collapsing to one would halve failover for a deliberate operator config. + expect(selectStrictFinalizedEndpointSessionV1([A1, A2])).toEqual([A1.href, A2.href]); + }); + + it('yields one endpoint for a single-endpoint pool', () => { + expect(selectStrictFinalizedEndpointSessionV1([A1])).toEqual([A1.href]); + }); + + it('preserves configuration order rather than sorting', () => { + expect(selectStrictFinalizedEndpointSessionV1([C, B])).toEqual([C.href, B.href]); + }); + + it('fails CLOSED on an empty pool rather than returning an empty session', () => { + expect(() => selectStrictFinalizedEndpointSessionV1([])) + .toThrow(/requires at least one endpoint/); + }); + + it('is pure: same input, same output, no observable side effects', () => { + const input = Object.freeze([A1, B]); + expect(selectStrictFinalizedEndpointSessionV1(input)) + .toEqual(selectStrictFinalizedEndpointSessionV1(input)); + expect(input).toEqual([A1, B]); + }); +}); + +describe('origin identity, proven end-to-end through the config boundary', () => { + const sel = (endpoints: readonly string[]) => + snapshotStrictCurrentFinalizedEvmConfigV1({ chainId: CHAIN, endpoints } as never).endpoints; + + it('treats path/query/credential/case variants of one host as ONE provider', () => { + // Three elements so `endpoints` DISCRIMINATES: if these were three providers + // the second slot would be the second variant, not `b`. + // + // The CREDENTIAL variant is load-bearing and was missing at one point while + // this test's name still claimed it: `url.origin` excludes userinfo, so two + // tokens on one host are one provider. Without a credential-bearing URL here, + // an identity rule that folded userinfo in would pass green. + expect(sel([ + 'https://token-a@a.example.com/rpc', + 'https://token-b@A.EXAMPLE.COM/other?k=1', + 'https://b.example.com', + ])).toEqual(['https://token-a@a.example.com/rpc', 'https://b.example.com/']); + }); + + it('collapses an explicit default port with the portless form', () => { + expect(sel([ + 'https://a.example.com', + 'https://a.example.com:443', + 'https://b.example.com', + ])).toEqual(['https://a.example.com/', 'https://b.example.com/']); + }); + + it('keeps NON-default ports as distinct providers', () => { + // The discriminating half. A rule that dropped the port would collapse these + // two and pick `b` as slot two instead. + expect(sel([ + 'https://a.example.com:8545', + 'https://a.example.com:8546', + 'https://b.example.com', + ])).toEqual(['https://a.example.com:8545/', 'https://a.example.com:8546/']); + }); + + it('does NOT impose a length limit on the dial URL', () => { + // Core bounds its input at the canonical scalar limit, so handing it the + // whole URL would cap RPC endpoints at 4096 bytes — a limit they never had. + // Only the origin is bounded, and an origin is always short. + const longPath = `https://rpc.example.com/${'k'.repeat(4100)}`; + expect(sel([longPath])).toEqual([longPath]); + const multiByte = `https://rpc.example.com/${'é'.repeat(3000)}`; + expect(new URL(multiByte).href.length).toBeGreaterThan(4096); + expect(sel([multiByte])).toEqual([new URL(multiByte).href]); + }); +}); + +describe('pools that collapse to a valid session still construct', () => { + const sel = (endpoints: readonly string[]) => + snapshotStrictCurrentFinalizedEvmConfigV1({ chainId: CHAIN, endpoints } as never).endpoints; + + it('accepts many IDENTICAL urls, because base deduped before counting', () => { + // Regression. An earlier revision bounded the RAW array, which turned a + // config of duplicates into a construction failure even though it collapses + // to one endpoint. Base deduplicated by href BEFORE its count check, so this + // constructed; bounding the raw array silently narrowed the contract from + // "at most two distinct normalized endpoints" to "at most N raw entries". + expect(sel(Array.from({ length: 33 }, () => 'https://a.example.com/rpc'))) + .toEqual(['https://a.example.com/rpc']); + }); + + it('accepts many entries collapsing to two distinct endpoints', () => { + expect(sel([ + ...Array.from({ length: 20 }, () => 'https://a.example.com'), + ...Array.from({ length: 20 }, () => 'https://b.example.com'), + ])).toEqual(['https://a.example.com/', 'https://b.example.com/']); + }); + + it('preserves the underlying detail on `cause` when the array itself is rejected', () => { + // The translating catch flattens whatever `snapshotDenseDataArray` reports + // into one shape complaint, so the specific reason is recoverable only via + // `cause`. Without this assertion, dropping `{ cause }` leaves the suite + // green — it did, until a mutant said so. + let error: unknown; + try { + snapshotStrictCurrentFinalizedEvmConfigV1({ chainId: CHAIN, endpoints: [] } as never); + } catch (thrown) { + error = thrown; + } + expect(error).toBeInstanceOf(TypeError); + expect((error as Error).cause).toBeInstanceOf(Error); + expect(((error as Error).cause as Error).message).toMatch(/outside the accepted range/); + }); + + it('still fails closed on an invalid entry anywhere in a large pool', () => { + // Removing the raw bound must not turn the pool into an unvalidated region: + // every entry is normalized, not only the selected ones. + expect(() => sel([ + ...Array.from({ length: 40 }, () => 'https://a.example.com'), + 'ftp://c.example.com', + ])).toThrow(/HTTP\(S\) without a fragment/); + }); +}); + +describe('config behaviour that must NOT change', () => { + + it('a pool that constructs today still produces byte-identical output', () => { + const snapshot = snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: CHAIN, + endpoints: ['https://a.example.com', 'https://b.example.com'], + }); + expect(snapshot.endpoints).toEqual(['https://a.example.com/', 'https://b.example.com/']); + expect(snapshot.blockReferenceProfile).toBe('eip1898'); + }); + + it('keeps every existing negative rejection verbatim', () => { + const bad: Array<[unknown, RegExp]> = [ + [{ chainId: CHAIN, endpoints: ['not-a-url'] }, /absolute URL/], + [{ chainId: CHAIN, endpoints: ['ftp://a.example.com'] }, /HTTP\(S\) without a fragment/], + [{ chainId: CHAIN, endpoints: ['https://a.example.com#f'] }, /HTTP\(S\) without a fragment/], + // Empty hits the dense-array minLength check first — pre-existing ordering. + [{ chainId: CHAIN, endpoints: [] }, /dense data-only array/], + [{ chainId: CHAIN, endpoints: ['https://a.example.com'], surprise: 1 }, /unknown or missing fields/], + [{ chainId: 'not-decimal', endpoints: ['https://a.example.com'] }, /canonical decimal u256/], + // Message-pinned deliberately. The sibling `rejects unsafe configuration` + // asserts only `toThrow(TypeError)`, which stayed green while an oversize + // endpoint escaped as a `VmUpdateConvergenceError` — the type assertion + // could not fail because no fixture reached the bound. + ]; + for (const [input, pattern] of bad) { + expect(() => snapshotStrictCurrentFinalizedEvmConfigV1(input as never)).toThrow(pattern); + } + }); + + + it('an INVALID third endpoint still fails closed rather than being selected away', () => { + // Selection must not become a way to smuggle a malformed endpoint past + // validation just because it falls outside the first two origins. + expect(() => + snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: CHAIN, + endpoints: ['https://a.example.com', 'https://b.example.com', 'ftp://c.example.com'], + } as never), + ).toThrow(/HTTP\(S\) without a fragment/); + }); + + it('rejects an accessor-backed endpoints field with ZERO getter invocations', () => { + // Selection reads `endpoints`; it must run AFTER descriptor validation or it + // re-opens the accessor hole closed in #2051. + let reads = 0; + const config: Record = { chainId: CHAIN }; + Object.defineProperty(config, 'endpoints', { + enumerable: true, + configurable: true, + get() { + reads += 1; + return ['https://a.example.com']; + }, + }); + expect(() => snapshotStrictCurrentFinalizedEvmConfigV1(config as never)) + .toThrow(/enumerable data properties/); + expect(reads).toBe(0); + }); + + it('WIDENS: a 3-URL pool with two same-origin aliases now constructs', () => { + // Previously rejected (3 distinct hrefs). This is a deliberate consequence + // of origin identity, asserted so the direction of change is not a surprise. + const snapshot = snapshotStrictCurrentFinalizedEvmConfigV1({ + chainId: CHAIN, + endpoints: ['https://a.example.com/rpc', 'https://a.example.com/other', 'https://b.example.com'], + }); + expect(snapshot.endpoints).toHaveLength(2); + }); +});