/** * End-to-end round-trip against a real EVM node. * * Spawns the ganache CLI as a child process on a random dev port, * deploys NotaryRegistry.sol compiled via in-process solc, and * exercises services/notaryChain.ts (`anchorPlan` + `finalizeAnchor`) * against it via ethers v6. This closes the * orchestrator-unit-tests-pass-but-the-adapter-to-reality-boundary- * is-uncovered gap flagged in gap-analysis v2 §7.9 / §8.5 — PR Q's * existing suite covers Postgres only. * * Gated on RUN_E2E=1 to stay out of the fast unit-test path. Runs on * CI via the `orchestrator-e2e` job (see .github/workflows/ci.yml). */ import { spawn, type ChildProcess } from "child_process"; import { JsonRpcProvider, Wallet, ContractFactory, Contract } from "ethers"; import { compileNotaryRegistry } from "./helpers/compileNotaryRegistry"; const RUN_E2E = process.env.RUN_E2E === "1"; const d = RUN_E2E ? describe : describe.skip; const DEPLOYER_PK = "0x4f3edf983ac636a65a842ce7c78d9aa706d3b113bce9c46f30d7d21715b23b1d"; async function waitForRpc(url: string, timeoutMs = 30_000): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", method: "eth_chainId", params: [], id: 1 }), }); if (r.ok) return; } catch { /* not ready yet */ } await new Promise((r) => setTimeout(r, 300)); } throw new Error(`RPC did not come up within ${timeoutMs}ms: ${url}`); } // eslint-disable-next-line @typescript-eslint/no-explicit-any type NotaryChainModule = typeof import("../../src/services/notaryChain"); d("NotaryRegistry chain round-trip (E2E)", () => { let ganacheProc: ChildProcess; let port: number; let rpcUrl: string; let contractAddress: string; let chain: NotaryChainModule; beforeAll(async () => { port = 18545 + Math.floor(Math.random() * 1000); rpcUrl = `http://127.0.0.1:${port}`; ganacheProc = spawn( "node_modules/.bin/ganache", [ "--port", String(port), "--chain.chainId", "1337", "--wallet.accounts", `${DEPLOYER_PK},1000000000000000000000`, "--logging.quiet", ], { stdio: "pipe", cwd: process.cwd() }, ); await waitForRpc(rpcUrl); const provider = new JsonRpcProvider(rpcUrl); const wallet = new Wallet(DEPLOYER_PK, provider); const { abi, bytecode } = compileNotaryRegistry(); // OZ v5 Ownable requires `initialOwner` in the constructor. const factory = new ContractFactory(abi, bytecode, wallet); const deployer = await wallet.getAddress(); const contract = (await factory.deploy(deployer)) as unknown as Contract; await contract.waitForDeployment(); contractAddress = await contract.getAddress(); // Wire the service under test to this chain. Import after the env // is set so the service's lazy loader picks it up. process.env.CHAIN_138_RPC_URL = rpcUrl; process.env.CHAIN_138_CHAIN_ID = "1337"; process.env.NOTARY_REGISTRY_ADDRESS = contractAddress; process.env.ORCHESTRATOR_PRIVATE_KEY = DEPLOYER_PK; jest.resetModules(); chain = await import("../../src/services/notaryChain"); }, 120_000); afterAll(async () => { if (ganacheProc && !ganacheProc.killed) { ganacheProc.kill("SIGTERM"); await new Promise((r) => setTimeout(r, 300)); } delete process.env.CHAIN_138_RPC_URL; delete process.env.CHAIN_138_CHAIN_ID; delete process.env.NOTARY_REGISTRY_ADDRESS; delete process.env.ORCHESTRATOR_PRIVATE_KEY; }); it("anchorPlan writes a PlanRegistered record on-chain", async () => { const plan = { plan_id: "e2e-plan-" + Date.now(), steps: [], created_at: new Date().toISOString(), }; const expectedHash = chain.computePlanHash(plan as never); const result = await chain.anchorPlan(plan as never); expect(result.mode).toBe("chain"); expect(result.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/); expect(result.blockNumber).toBeGreaterThan(0); expect(result.planHash).toBe(expectedHash); // Directly query the contract to prove the state transition landed. const provider = new JsonRpcProvider(rpcUrl); const { abi } = compileNotaryRegistry(); const readOnly = new Contract(contractAddress, abi, provider); const stored = await readOnly.getFunction("plans")(chain.planIdToBytes32(plan.plan_id)); // plans(bytes32) → (planHash, creator, registeredAt, finalizedAt, success, receiptHash) expect(stored[0]).toMatch(/^0x[0-9a-fA-F]{64}$/); expect(Number(stored[2])).toBeGreaterThan(0); // registeredAt expect(Number(stored[3])).toBe(0); // finalizedAt }, 60_000); it("finalizeAnchor writes a PlanFinalized record with a receipt hash", async () => { const plan = { plan_id: "e2e-finalize-" + Date.now(), steps: [], created_at: new Date().toISOString(), }; await chain.anchorPlan(plan as never); const result = await chain.finalizeAnchor(plan.plan_id, true); expect(result.mode).toBe("chain"); expect(result.txHash).toMatch(/^0x[0-9a-fA-F]{64}$/); expect(result.receiptHash).toMatch(/^0x[0-9a-fA-F]{64}$/); expect(result.blockNumber).toBeGreaterThan(0); }, 60_000); it("anchorPlan falls back to mock when envs are cleared", async () => { const saved = { rpc: process.env.CHAIN_138_RPC_URL, addr: process.env.NOTARY_REGISTRY_ADDRESS, pk: process.env.ORCHESTRATOR_PRIVATE_KEY, }; delete process.env.CHAIN_138_RPC_URL; delete process.env.NOTARY_REGISTRY_ADDRESS; delete process.env.ORCHESTRATOR_PRIVATE_KEY; try { jest.resetModules(); const mockOnly = await import("../../src/services/notaryChain"); const result = await mockOnly.anchorPlan({ plan_id: "mock-plan", steps: [], created_at: new Date().toISOString(), } as never); expect(result.mode).toBe("mock"); expect(result.txHash).toBeUndefined(); expect(result.planHash).toMatch(/^0x[0-9a-fA-F]{64}$/); } finally { if (saved.rpc) process.env.CHAIN_138_RPC_URL = saved.rpc; if (saved.addr) process.env.NOTARY_REGISTRY_ADDRESS = saved.addr; if (saved.pk) process.env.ORCHESTRATOR_PRIVATE_KEY = saved.pk; } }); });