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