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); + }); +});