PR U: NotaryRegistry E2E round-trip via ganache CLI + in-process solc #25
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!,
|
||||
|
||||
163
orchestrator/tests/e2e/helpers/compileNotaryRegistry.ts
Normal file
163
orchestrator/tests/e2e/helpers/compileNotaryRegistry.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
173
orchestrator/tests/e2e/notaryChainRoundtrip.e2e.test.ts
Normal file
173
orchestrator/tests/e2e/notaryChainRoundtrip.e2e.test.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user