From d1472667eb826e63ec61f075f6181e3469976919 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:58:16 +0000 Subject: [PATCH] PR X: E2E round-trip against public Chain 138 RPC Closes the CurrenciCombo side of proxmox blocker EXT-CHAIN138-CI-RPC now that https://rpc.public-0138.defi-oracle.io is live (verified: eth_chainId -> 0x8a / 138, eth_blockNumber returns a plausible positive height). Adds orchestrator/tests/e2e/notaryChainPublicRpc.e2e.test.ts -- a **read-only** E2E suite that targets the real public Chain 138 RPC instead of a locally-spawned ganache. We don't own a funded key on Chain 138 in CI and writing against mainnet-equivalent infra would be reckless, so the suite sticks to non-destructive calls: 1. provider.getNetwork() returns a descriptor 2. eth_chainId == 138 3. eth_blockNumber > 0 4. eth_getBlockByNumber returns a well-formed block with timestamp past 2020-09-13 5. plans(bytes32) call against NOTARY_REGISTRY_ADDRESS (if set) returns a zero'd record for a synthetic key -- proves the ABI matches the deployed contract without mutating state 6. orchestrator's services/notaryChain.ts gracefully mock-falls- back when the public RPC is configured but no signing key is set Gated on BOTH RUN_E2E=1 AND E2E_USE_PUBLIC_CHAIN138=1 so the default E2E path stays offline (existing ganache-based round-trip tests don't need network). CI: the orchestrator-e2e job now runs with E2E_USE_PUBLIC_CHAIN138=1, so the 'run-e2e' PR label triggers **both** the Postgres Testcontainers suite and the public Chain 138 suite. Verification: RUN_E2E=1 E2E_USE_PUBLIC_CHAIN138=1 npx jest --config=jest.e2e.config.js tests/e2e/notaryChainPublicRpc.e2e.test.ts -> 6/6 passing, ~3s wall-clock. tsc --noEmit clean. No UI. --- .github/workflows/ci.yml | 10 +- .../e2e/notaryChainPublicRpc.e2e.test.ts | 151 ++++++++++++++++++ 2 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 orchestrator/tests/e2e/notaryChainPublicRpc.e2e.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6348f2b..a4a0e20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,8 +146,16 @@ jobs: - name: Install dependencies working-directory: orchestrator run: npm ci - - name: E2E tests (Testcontainers Postgres) + - name: E2E tests (Testcontainers Postgres + public Chain 138 RPC) working-directory: orchestrator + # EXT-CHAIN138-CI-RPC resolved via the public endpoint at + # https://rpc.public-0138.defi-oracle.io — the read-only + # public-RPC suite exercises the orchestrator's ethers client + # against a real Chain 138 node alongside the ganache-based + # round-trip tests. The env var opts the public-RPC suite in; + # without it, those tests self-skip. + env: + E2E_USE_PUBLIC_CHAIN138: "1" run: npm run test:e2e # Smart Contracts CI diff --git a/orchestrator/tests/e2e/notaryChainPublicRpc.e2e.test.ts b/orchestrator/tests/e2e/notaryChainPublicRpc.e2e.test.ts new file mode 100644 index 0000000..0448396 --- /dev/null +++ b/orchestrator/tests/e2e/notaryChainPublicRpc.e2e.test.ts @@ -0,0 +1,151 @@ +/** + * Read-only E2E round-trip against the **public Chain 138 RPC**. + * + * Whereas `notaryChainRoundtrip.e2e.test.ts` spins up ganache locally + * and exercises both writes and reads, this suite targets the real + * public endpoint (`https://rpc.public-0138.defi-oracle.io`) and + * closes the proxmox `EXT-CHAIN138-CI-RPC` blocker on the + * CurrenciCombo side. + * + * It does **not** perform any writes: + * - we don't own a funded key on Chain 138 in CI; + * - writes against mainnet-equivalent infra would be reckless and + * non-deterministic. + * + * What it does do: + * 1. Prove the orchestrator's ethers client can reach the public RPC. + * 2. Verify `eth_chainId` matches the expected Chain 138. + * 3. Verify `eth_blockNumber` returns a plausible current height. + * 4. If `NOTARY_REGISTRY_ADDRESS` is set, read a synthetic + * `plans(bytes32)` key and assert the contract responded (zeros + * are fine — the call succeeding is the point). + * 5. Build an orchestrator notaryChain config pointed at the real + * chain and confirm the module still gracefully mock-falls-back + * when the orchestrator's signing key isn't set. + * + * Gated on **BOTH** `RUN_E2E=1` and `E2E_USE_PUBLIC_CHAIN138=1` so the + * default E2E path stays offline. + */ + +import { JsonRpcProvider, Contract, id as keccakId, ZeroHash } from "ethers"; +import { compileNotaryRegistry } from "./helpers/compileNotaryRegistry"; + +const RUN_E2E = process.env.RUN_E2E === "1"; +const USE_PUBLIC = process.env.E2E_USE_PUBLIC_CHAIN138 === "1"; +const d = RUN_E2E && USE_PUBLIC ? describe : describe.skip; + +const DEFAULT_PUBLIC_RPC = "https://rpc.public-0138.defi-oracle.io"; +const EXPECTED_CHAIN_ID = 138n; + +function getPublicRpcUrl(): string { + // If the caller set CHAIN_138_RPC_URL, honour it (matches how the + // orchestrator's own services pick up config); otherwise use the + // documented public endpoint. + return process.env.CHAIN_138_RPC_URL || DEFAULT_PUBLIC_RPC; +} + +d("NotaryRegistry read-only round-trip against public Chain 138", () => { + let rpcUrl: string; + let provider: JsonRpcProvider; + + beforeAll(() => { + rpcUrl = getPublicRpcUrl(); + // staticNetwork=true skips the network discovery handshake every + // call; cacheTimeout=-1 disables the 250ms response cache so + // subsequent JSON-RPC calls see fresh data. + provider = new JsonRpcProvider(rpcUrl, undefined, { + staticNetwork: true, + cacheTimeout: -1, + }); + }); + + it("resolves a network descriptor", async () => { + const net = await provider.getNetwork(); + expect(net).toBeDefined(); + expect(typeof net.chainId).toBe("bigint"); + }, 30_000); + + it("eth_chainId matches Chain 138", async () => { + const net = await provider.getNetwork(); + expect(net.chainId).toBe(EXPECTED_CHAIN_ID); + }, 30_000); + + it("eth_blockNumber returns a positive current height", async () => { + const blockNumber = await provider.getBlockNumber(); + expect(blockNumber).toBeGreaterThan(0); + }, 30_000); + + it("eth_getBlockByNumber returns a well-formed block", async () => { + const latest = await provider.getBlockNumber(); + const block = await provider.getBlock(latest); + expect(block).not.toBeNull(); + if (block) { + expect(block.number).toBe(latest); + expect(block.hash).toMatch(/^0x[0-9a-fA-F]{64}$/); + expect(typeof block.timestamp).toBe("number"); + expect(block.timestamp).toBeGreaterThan(1_600_000_000); + } + }, 30_000); + + it("reads plans(bytes32) if NOTARY_REGISTRY_ADDRESS is set", async () => { + const addr = process.env.NOTARY_REGISTRY_ADDRESS; + if (!addr) { + // Not a failure — this is the current CI state until the + // deployed NotaryRegistry address is published to the + // environment. Document it instead of failing. + expect(addr).toBeUndefined(); + return; + } + const { abi } = compileNotaryRegistry(); + const readOnly = new Contract(addr, abi, provider); + // Synthetic id — we expect an empty / zero record but the call + // itself must succeed (proves ABI matches deployed contract). + const syntheticKey = keccakId("e2e-public-read-only-" + Date.now()); + const record = await readOnly.getFunction("plans")(syntheticKey); + // plans() returns (planHash, creator, registeredAt, finalizedAt, success, receiptHash) + expect(record).toBeDefined(); + expect(Array.isArray(record) || typeof record === "object").toBe(true); + // Either a fresh key → zeros, or an already-used key — both are OK. + // We only assert the types match the tuple shape. + const [planHash, , registeredAt, finalizedAt] = record as readonly [ + string, string, bigint, bigint, boolean, string, + ]; + expect(typeof planHash).toBe("string"); + expect(typeof registeredAt).toBe("bigint"); + expect(typeof finalizedAt).toBe("bigint"); + // For a synthetic key, every field should be zero. + expect(planHash).toBe(ZeroHash); + expect(registeredAt).toBe(0n); + expect(finalizedAt).toBe(0n); + }, 60_000); + + it("orchestrator notaryChain module mock-falls-back when signing key is absent", async () => { + const saved = { + rpc: process.env.CHAIN_138_RPC_URL, + addr: process.env.NOTARY_REGISTRY_ADDRESS, + pk: process.env.ORCHESTRATOR_PRIVATE_KEY, + }; + // Point at the public RPC but leave the signing key unset. + process.env.CHAIN_138_RPC_URL = rpcUrl; + delete process.env.ORCHESTRATOR_PRIVATE_KEY; + + try { + jest.resetModules(); + const chain = await import("../../src/services/notaryChain"); + const result = await chain.anchorPlan({ + plan_id: "public-rpc-readonly-" + Date.now(), + steps: [], + created_at: new Date().toISOString(), + } as never); + // With no signer, isConfigured() returns false → mock path. + expect(result.mode).toBe("mock"); + expect(result.planHash).toMatch(/^0x[0-9a-fA-F]{64}$/); + expect(result.txHash).toBeUndefined(); + } finally { + if (saved.rpc !== undefined) process.env.CHAIN_138_RPC_URL = saved.rpc; + else delete process.env.CHAIN_138_RPC_URL; + if (saved.addr !== undefined) process.env.NOTARY_REGISTRY_ADDRESS = saved.addr; + if (saved.pk !== undefined) process.env.ORCHESTRATOR_PRIVATE_KEY = saved.pk; + } + }, 30_000); +}); -- 2.34.1