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
213 lines
6.3 KiB
TypeScript
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,
|
|
};
|
|
}
|