From a7b355beef19fc7960ee9e46e990d500644f8998 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:03:11 +0000 Subject: [PATCH] PR U: NotaryRegistry E2E round-trip via ganache CLI + in-process solc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Spawn ganache CLI as child process on a random dev port; deploy NotaryRegistry.sol compiled via in-process solc-js (no hardhat). - Add OZ v5 Ownable initialOwner constructor to NotaryRegistry.sol (required by OpenZeppelin v5; contract was written pre-v5 and would not compile otherwise). - Fix Step tuple ABI in services/notaryChain.ts — the prior shape (uint8, address, uint256, bytes) diverged from IComboHandler.Step (uint8, bytes, address, uint256), producing a different function selector and silently reverting every on-chain call. - Disable JsonRpcProvider response cache (cacheTimeout=-1) so back-to-back anchor+finalize calls on fast chains no longer read a stale getTransactionCount and collide on nonce. - Gated on RUN_E2E=1 to stay out of the fast unit-test path. 3/3 tests pass: anchor on-chain write, finalize with receipt hash, graceful mock fallback when envs cleared. Full unit suite: 10/10 (128 tests) still green, tsc --noEmit clean. Closes gap-analysis v3 §7.9 / §8.5 (chain round-trip coverage). --- contracts/NotaryRegistry.sol | 2 + orchestrator/package.json | 2 + orchestrator/src/services/notaryChain.ts | 15 +- .../e2e/helpers/compileNotaryRegistry.ts | 163 +++++++++++++++++ .../e2e/notaryChainRoundtrip.e2e.test.ts | 173 ++++++++++++++++++ 5 files changed, 353 insertions(+), 2 deletions(-) create mode 100644 orchestrator/tests/e2e/helpers/compileNotaryRegistry.ts create mode 100644 orchestrator/tests/e2e/notaryChainRoundtrip.e2e.test.ts diff --git a/contracts/NotaryRegistry.sol b/contracts/NotaryRegistry.sol index 24db30a..4d6ddf5 100644 --- a/contracts/NotaryRegistry.sol +++ b/contracts/NotaryRegistry.sol @@ -16,6 +16,8 @@ contract NotaryRegistry is INotaryRegistry, Ownable { event PlanFinalized(bytes32 indexed planId, bool success, bytes32 receiptHash); event CodehashRegistered(address indexed contractAddress, bytes32 codehash, string version); + constructor(address initialOwner) Ownable(initialOwner) {} + /** * @notice Register a plan with notary */ diff --git a/orchestrator/package.json b/orchestrator/package.json index cdf20b4..924ccb4 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -36,7 +36,9 @@ "@types/pg": "^8.10.9", "@types/supertest": "^7.2.0", "@types/uuid": "^9.0.6", + "ganache": "^7.9.2", "jest": "^30.3.0", + "solc": "^0.8.20", "supertest": "^7.2.2", "testcontainers": "^11.14.0", "ts-jest": "^29.4.9", diff --git a/orchestrator/src/services/notaryChain.ts b/orchestrator/src/services/notaryChain.ts index 68b788e..1a0bee6 100644 --- a/orchestrator/src/services/notaryChain.ts +++ b/orchestrator/src/services/notaryChain.ts @@ -25,7 +25,12 @@ import { logger } from "../logging/logger"; import type { Plan } from "../types/plan"; const NOTARY_REGISTRY_ABI = [ - "function registerPlan(bytes32 planId, tuple(uint8 stepType, address target, uint256 amount, bytes data)[] steps, address creator) external", + // Step tuple order must match IComboHandler.Step exactly: + // (StepType stepType, bytes data, address target, uint256 value) + // Any divergence changes the canonical signature and therefore the + // function selector — the call would silently miss and the contract + // would revert with no revert data. + "function registerPlan(bytes32 planId, tuple(uint8 stepType, bytes data, address target, uint256 value)[] steps, address creator) external", "function finalizePlan(bytes32 planId, bool success) external", "function getPlan(bytes32 planId) view returns (tuple(bytes32 planHash, address creator, uint256 registeredAt, uint256 finalizedAt, bool success, bytes32 receiptHash))", "event PlanRegistered(bytes32 indexed planId, address indexed creator, bytes32 planHash)", @@ -108,7 +113,13 @@ function getContract(cfg: NotaryConfig): { if (cached && cached.cfg.contractAddress === cfg.contractAddress) { return { contract: cached.contract, wallet: cached.wallet }; } - const provider = new ethers.JsonRpcProvider(cfg.rpcUrl); + // cacheTimeout=-1 disables the 250ms response cache — otherwise + // back-to-back anchor+finalize calls read a stale getTransactionCount + // and collide on nonce, particularly on fast (ganache/hardhat) chains. + const provider = new ethers.JsonRpcProvider(cfg.rpcUrl, cfg.chainId, { + staticNetwork: true, + cacheTimeout: -1, + }); const wallet = new ethers.Wallet(cfg.privateKey!, provider); const contract = new ethers.Contract( cfg.contractAddress!, diff --git a/orchestrator/tests/e2e/helpers/compileNotaryRegistry.ts b/orchestrator/tests/e2e/helpers/compileNotaryRegistry.ts new file mode 100644 index 0000000..4e91154 --- /dev/null +++ b/orchestrator/tests/e2e/helpers/compileNotaryRegistry.ts @@ -0,0 +1,163 @@ +/** + * Helper: compile contracts/NotaryRegistry.sol + its two interfaces + * + @openzeppelin/contracts Ownable using solc-js in-process. + * + * Keeps the E2E suite self-contained — no dependence on a prior + * `hardhat compile` step, no new workspace wiring. + */ + +import { readFileSync } from "fs"; +import { dirname, join, resolve } from "path"; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires +const solc = require("solc"); + +const REPO_ROOT = resolve(__dirname, "..", "..", "..", ".."); +const CONTRACTS_ROOT = join(REPO_ROOT, "contracts"); +const OZ_ROOT = join(CONTRACTS_ROOT, "node_modules", "@openzeppelin"); + +// ethers v6 accepts any JsonFragment-shaped array here. Declaring the +// element type loosely keeps us decoupled from ethers' private type +// exports while still being strictly typed against `unknown`. +export type AbiFragment = Record; + +export interface CompiledArtifact { + abi: AbiFragment[]; + bytecode: string; +} + +interface SolcSource { + content: string; +} + +interface SolcInput { + language: "Solidity"; + sources: Record; + settings: { + optimizer: { enabled: true; runs: number }; + outputSelection: Record>; + }; +} + +interface SolcOutput { + errors?: Array<{ severity: "error" | "warning"; formattedMessage: string }>; + contracts: Record< + string, + Record + >; +} + +function readFromRoots(rel: string, roots: string[]): string { + for (const root of roots) { + try { + return readFileSync(join(root, rel), "utf8"); + } catch { + // try next root + } + } + throw new Error(`Could not resolve import ${rel} against roots ${roots.join(",")}`); +} + +function findImports(requestedPath: string): { contents: string } | { error: string } { + // @openzeppelin/... → contracts/node_modules/@openzeppelin/... + if (requestedPath.startsWith("@openzeppelin/")) { + const rel = requestedPath.replace("@openzeppelin/", ""); + try { + return { contents: readFileSync(join(OZ_ROOT, rel), "utf8") }; + } catch (e) { + return { error: `Could not read ${requestedPath}: ${(e as Error).message}` }; + } + } + // Local ./interfaces/... paths resolve against contracts/ + try { + return { contents: readFromRoots(requestedPath, [CONTRACTS_ROOT]) }; + } catch (e) { + return { error: (e as Error).message }; + } +} + +/** + * Recursively pull in all `import "..."` references starting from + * NotaryRegistry.sol and return the full `sources` object solc needs. + */ +function collectSources(entryPath: string): Record { + const sources: Record = {}; + const stack: string[] = [entryPath]; + const seen = new Set(); + + while (stack.length > 0) { + const cur = stack.pop()!; + if (seen.has(cur)) continue; + seen.add(cur); + + let content: string; + if (cur === entryPath) { + content = readFileSync(join(CONTRACTS_ROOT, "NotaryRegistry.sol"), "utf8"); + } else { + const resolved = findImports(cur); + if ("error" in resolved) { + throw new Error(`Unresolved import: ${cur} (${resolved.error})`); + } + content = resolved.contents; + } + sources[cur] = { content }; + + // Parse `import "..."` statements. Interfaces may use relative paths + // that we normalise back into keys solc expects. + const importRe = /^\s*import\s+(?:\{[^}]+\}\s+from\s+)?"([^"]+)";/gm; + let m: RegExpExecArray | null; + while ((m = importRe.exec(content)) !== null) { + const rawImport = m[1]; + let normalised: string; + if (rawImport.startsWith("@openzeppelin/")) { + normalised = rawImport; + } else if (rawImport.startsWith("./") || rawImport.startsWith("../")) { + // Relative import — resolve against the dir of `cur`. + const curDir = cur.includes("/") ? dirname(cur) : "."; + const joined = join(curDir, rawImport); + normalised = joined.startsWith(".") ? joined.slice(2) : joined; + } else { + normalised = rawImport; + } + if (!seen.has(normalised)) stack.push(normalised); + } + } + + return sources; +} + +export function compileNotaryRegistry(): CompiledArtifact { + const entry = "NotaryRegistry.sol"; + const sources = collectSources(entry); + + const input: SolcInput = { + language: "Solidity", + sources, + settings: { + optimizer: { enabled: true, runs: 200 }, + outputSelection: { + "*": { "*": ["abi", "evm.bytecode.object"] }, + }, + }, + }; + + const output: SolcOutput = JSON.parse( + solc.compile(JSON.stringify(input), { import: findImports }), + ); + + const fatal = (output.errors ?? []).filter((e) => e.severity === "error"); + if (fatal.length > 0) { + const msg = fatal.map((e) => e.formattedMessage).join("\n"); + throw new Error(`solc compile failed:\n${msg}`); + } + + const artifact = output.contracts[entry]?.NotaryRegistry; + if (!artifact) { + throw new Error("NotaryRegistry not found in solc output"); + } + + return { + abi: artifact.abi, + bytecode: "0x" + artifact.evm.bytecode.object, + }; +} diff --git a/orchestrator/tests/e2e/notaryChainRoundtrip.e2e.test.ts b/orchestrator/tests/e2e/notaryChainRoundtrip.e2e.test.ts new file mode 100644 index 0000000..a1ed33a --- /dev/null +++ b/orchestrator/tests/e2e/notaryChainRoundtrip.e2e.test.ts @@ -0,0 +1,173 @@ +/** + * 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; + } + }); +}); -- 2.34.1