PR U: NotaryRegistry E2E round-trip via ganache CLI + in-process solc
Some checks failed
CI / Frontend Lint (pull_request) Failing after 7s
CI / Frontend Type Check (pull_request) Failing after 6s
CI / Frontend Build (pull_request) Failing after 7s
CI / Frontend E2E Tests (pull_request) Failing after 8s
CI / Orchestrator Build (pull_request) Failing after 7s
CI / Orchestrator Unit Tests (pull_request) Failing after 5s
CI / Orchestrator E2E (Testcontainers) (pull_request) Has been skipped
CI / Contracts Compile (pull_request) Failing after 6s
CI / Contracts Test (pull_request) Failing after 6s
Code Quality / SonarQube Analysis (pull_request) Failing after 20s
Code Quality / Code Quality Checks (pull_request) Failing after 5s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 5s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 4s

- 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).
This commit is contained in:
Devin AI
2026-04-22 21:03:11 +00:00
parent 21d49595d0
commit a7b355beef
5 changed files with 353 additions and 2 deletions

View File

@@ -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
*/

View File

@@ -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",

View File

@@ -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!,

View File

@@ -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<string, unknown>;
export interface CompiledArtifact {
abi: AbiFragment[];
bytecode: string;
}
interface SolcSource {
content: string;
}
interface SolcInput {
language: "Solidity";
sources: Record<string, SolcSource>;
settings: {
optimizer: { enabled: true; runs: number };
outputSelection: Record<string, Record<string, string[]>>;
};
}
interface SolcOutput {
errors?: Array<{ severity: "error" | "warning"; formattedMessage: string }>;
contracts: Record<
string,
Record<string, { abi: AbiFragment[]; evm: { bytecode: { object: string } } }>
>;
}
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<string, SolcSource> {
const sources: Record<string, SolcSource> = {};
const stack: string[] = [entryPath];
const seen = new Set<string>();
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,
};
}

View File

@@ -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<void> {
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;
}
});
});