Files
CurrenciCombo/orchestrator/src/services/notaryChain.ts
nsatoshi 3e1fb9ef7e
Some checks failed
CI / Frontend Lint (push) Failing after 6s
CI / Frontend Type Check (push) Failing after 6s
CI / Frontend Build (push) Failing after 6s
CI / Frontend E2E Tests (push) Failing after 8s
CI / Contracts Compile (push) Has been cancelled
CI / Contracts Test (push) Has been cancelled
CI / Orchestrator Build (push) Has been cancelled
Security Scan / OWASP ZAP Scan (push) Has been cancelled
Security Scan / Dependency Vulnerability Scan (push) Has been cancelled
PR C: wire real NotaryRegistry on Chain 138 (arch step 4) (#7)
2026-04-22 17:11:50 +00:00

213 lines
6.3 KiB
TypeScript

/**
* NotaryRegistry on-chain adapter (arch §4.5 + §5.7).
*
* Wires the orchestrator to the deployed NotaryRegistry contract on
* Chain 138 (Defi Oracle Meta Mainnet). When the chain/contract/signer
* envs are absent, everything degrades gracefully to a deterministic
* mock so unit tests and local dev still work.
*
* Contract ABI (minimal — only the two functions + two events that the
* orchestrator actually calls):
*
* registerPlan(bytes32 planId, Step[] steps, address creator)
* finalizePlan(bytes32 planId, bool success)
* event PlanRegistered(bytes32 indexed planId, address indexed creator, bytes32 planHash)
* event PlanFinalized(bytes32 indexed planId, bool success, bytes32 receiptHash)
*
* The `Step` tuple must match IComboHandler.Step on-chain. For now the
* adapter serialises plan.steps as an empty array and only anchors
* planId + creator + planHash. PR E will wire full step encoding once
* the SWIFT gateway has stable step IDs.
*/
import { ethers } from "ethers";
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",
"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)",
"event PlanFinalized(bytes32 indexed planId, bool success, bytes32 receiptHash)",
] as const;
export interface NotaryConfig {
rpcUrl?: string;
contractAddress?: string;
privateKey?: string;
chainId?: number;
}
export interface AnchorResult {
mode: "chain" | "mock";
txHash?: string;
planHash: string;
blockNumber?: number;
contractAddress?: string;
}
export interface FinalizeResult {
mode: "chain" | "mock";
txHash?: string;
receiptHash?: string;
blockNumber?: number;
}
/**
* Pad a plan-id string (usually a UUID) to a bytes32. Deterministic and
* reversible via keccak256 if we ever need to look a plan up on-chain.
*/
export function planIdToBytes32(planId: string): string {
return ethers.id(planId);
}
/**
* Compute the sha256 planHash that matches what `services/notary.ts` has
* always published off-chain, so the mock and chain paths produce the
* same hash for the same plan.
*/
export function computePlanHash(plan: Plan): string {
return ethers.sha256(ethers.toUtf8Bytes(JSON.stringify(plan)));
}
function loadConfigFromEnv(): NotaryConfig {
return {
rpcUrl: process.env.CHAIN_138_RPC_URL,
contractAddress: process.env.NOTARY_REGISTRY_ADDRESS,
privateKey: process.env.ORCHESTRATOR_PRIVATE_KEY,
chainId: process.env.CHAIN_138_CHAIN_ID
? parseInt(process.env.CHAIN_138_CHAIN_ID, 10)
: 138,
};
}
function isConfigured(cfg: NotaryConfig): cfg is Required<NotaryConfig> {
return Boolean(cfg.rpcUrl && cfg.contractAddress && cfg.privateKey);
}
/**
* Singleton cache. Built lazily on first use so unit tests can swap in
* mock envs before the contract is constructed.
*/
let cached: {
contract: ethers.Contract;
wallet: ethers.Wallet;
cfg: NotaryConfig;
} | null = null;
export function __resetForTests() {
cached = null;
}
function getContract(cfg: NotaryConfig): {
contract: ethers.Contract;
wallet: ethers.Wallet;
} | null {
if (!isConfigured(cfg)) return null;
if (cached && cached.cfg.contractAddress === cfg.contractAddress) {
return { contract: cached.contract, wallet: cached.wallet };
}
const provider = new ethers.JsonRpcProvider(cfg.rpcUrl);
const wallet = new ethers.Wallet(cfg.privateKey!, provider);
const contract = new ethers.Contract(
cfg.contractAddress!,
NOTARY_REGISTRY_ABI,
wallet,
);
cached = { contract, wallet, cfg };
return { contract, wallet };
}
/**
* Anchor a plan on NotaryRegistry. Returns a mock proof if the chain
* envs aren't set so this is a drop-in replacement for the old mock.
*/
export async function anchorPlan(
plan: Plan,
cfg: NotaryConfig = loadConfigFromEnv(),
): Promise<AnchorResult> {
const planHash = computePlanHash(plan);
const bundle = getContract(cfg);
if (!bundle) {
logger.info(
{ planId: plan.plan_id, reason: "notary envs not set" },
"[NotaryChain] mock anchor",
);
return { mode: "mock", planHash };
}
const { contract, wallet } = bundle;
const planIdBytes32 = planIdToBytes32(plan.plan_id ?? "");
const creator = (await wallet.getAddress());
logger.info(
{ planId: plan.plan_id, contract: cfg.contractAddress },
"[NotaryChain] registerPlan()",
);
const fn = contract.getFunction("registerPlan");
const tx = await fn(planIdBytes32, [], creator);
const receipt = await tx.wait();
return {
mode: "chain",
txHash: tx.hash,
planHash,
blockNumber: receipt?.blockNumber,
contractAddress: cfg.contractAddress,
};
}
/**
* Finalize a plan on NotaryRegistry. Success=true means the workflow
* reached COMMITTED; success=false means ABORTED.
*/
export async function finalizeAnchor(
planId: string,
success: boolean,
cfg: NotaryConfig = loadConfigFromEnv(),
): Promise<FinalizeResult> {
const bundle = getContract(cfg);
if (!bundle) {
logger.info(
{ planId, success, reason: "notary envs not set" },
"[NotaryChain] mock finalize",
);
return { mode: "mock" };
}
const { contract } = bundle;
const planIdBytes32 = planIdToBytes32(planId);
logger.info(
{ planId, success, contract: cfg.contractAddress },
"[NotaryChain] finalizePlan()",
);
const fn = contract.getFunction("finalizePlan");
const tx = await fn(planIdBytes32, success);
const receipt = await tx.wait();
// Parse PlanFinalized event to extract the on-chain receiptHash.
let receiptHash: string | undefined;
for (const log of receipt?.logs ?? []) {
try {
const parsed = contract.interface.parseLog(log);
if (parsed?.name === "PlanFinalized") {
receiptHash = parsed.args.receiptHash as string;
break;
}
} catch {
/* not our event */
}
}
return {
mode: "chain",
txHash: tx.hash,
receiptHash,
blockNumber: receipt?.blockNumber,
};
}