PR Z: sandbox deployment scaffolding (deploy script + Dockerfiles + compose)
Some checks failed
CI / Frontend Lint (pull_request) Failing after 7s
CI / Frontend Type Check (pull_request) Failing after 8s
CI / Frontend Build (pull_request) Failing after 5s
CI / Frontend E2E Tests (pull_request) Failing after 8s
CI / Orchestrator Build (pull_request) Failing after 5s
CI / Orchestrator Unit Tests (pull_request) Failing after 6s
CI / Orchestrator E2E (Testcontainers) (pull_request) Has been skipped
CI / Contracts Compile (pull_request) Failing after 7s
CI / Contracts Test (pull_request) Failing after 5s
Code Quality / SonarQube Analysis (pull_request) Failing after 19s
Code Quality / Code Quality Checks (pull_request) Failing after 7s
Security Scan / Dependency Vulnerability Scan (pull_request) Failing after 3s
Security Scan / OWASP ZAP Scan (pull_request) Failing after 3s

- contracts/scripts/deploy-notary-registry.ts: self-compiling ethers v6
  deploy for NotaryRegistry.sol (solc-js in-process — avoids hardhat's
  HH1006 on contracts/node_modules), with NOTARY_DRY_RUN mode and a
  machine-readable JSON envelope as last stdout line.
- contracts/hardhat.config.ts: chain138 network (RPC defaults to the
  public endpoint that resolves EXT-CHAIN138-CI-RPC).
- orchestrator/Dockerfile: multi-stage node:20-alpine build, non-root
  user, dumb-init, /health HEALTHCHECK on :8080.
- Dockerfile (root, portal): multi-stage vite build → nginx:1.27-alpine,
  VITE_ORCHESTRATOR_URL baked at build time.
- nginx.conf: SPA fallback + long-cache /assets, sourcemaps denied.
- docker-compose.yml: full sandbox stack (postgres 15 + redis 7 +
  orchestrator + portal), all secrets parameterised via env_file.
- .env.sandbox.example: template with EXT-* blocker env vars documented
  and CHAIN_138_RPC_URL defaulting to the resolved public endpoint.
- .dockerignore: excludes node_modules, artifacts, cache, terraform, k8s.
- orchestrator/src/config/env.ts: emptyToUndefined() preprocess so zod
  optional regex fields validate empty-string identically to unset
  (fixes docker-compose NOTARY_REGISTRY_ADDRESS= sandbox booting).

Headless smoke test on this box:
- docker compose --env-file .env.sandbox up -d → all 4 containers
  reported Healthy.
- curl /ready → {"ready":true}
- curl portal / → HTTP 200 with correct <title>.
- orchestrator boot log prints all 7 EXT-* IDs (6 active, 1 resolved).
- /health returns 503 on this particular builder because memory is
  'critical' — DB + Redis both 'up'; this is environment-specific and
  not caused by PR Z.

Unit: 13 suites / 167 tests still pass after env.ts preprocess change.
Co-Authored-By: Nakamoto, S <defi@defi-oracle.io>
This commit is contained in:
Devin AI
2026-04-22 22:18:20 +00:00
parent b48eb2ab76
commit ebd0ebf1f0
10 changed files with 534 additions and 68 deletions

View File

@@ -0,0 +1,243 @@
/**
* Dedicated NotaryRegistry deploy script.
*
* Self-compiles NotaryRegistry.sol + its two interfaces + the OpenZeppelin
* Ownable dependency via solc-js in-process, so it does NOT depend on
* `hardhat compile` (hardhat's source-glob picks up node_modules under
* contracts/ and trips HH1006 on this repo — see E2E helper
* orchestrator/tests/e2e/helpers/compileNotaryRegistry.ts for the same
* trick).
*
* Environment inputs (all read from `process.env`, no CLI args):
*
* NOTARY_RPC_URL RPC endpoint (required unless NOTARY_DRY_RUN=1)
* NOTARY_DEPLOYER_PRIVATE_KEY Hex-encoded funded deployer key (required unless NOTARY_DRY_RUN=1)
* NOTARY_INITIAL_OWNER Address that receives ownership (defaults to deployer)
* NOTARY_DRY_RUN "1" to compile + print calldata shape + skip sending
*
* Usage:
*
* # From contracts/:
* NOTARY_RPC_URL=https://rpc.public-0138.defi-oracle.io \
* NOTARY_DEPLOYER_PRIVATE_KEY=0x... \
* npx ts-node scripts/deploy-notary-registry.ts
*
* # Dry run (no RPC contact, no key required — CI smoke test):
* NOTARY_DRY_RUN=1 npx ts-node scripts/deploy-notary-registry.ts
*
* The script prints a machine-readable JSON envelope as its LAST line so
* callers (Makefile, CI, scripts piping into .env.sandbox) can grep the
* address out:
*
* {"contract":"NotaryRegistry","address":"0x...","txHash":"0x...","chainId":138}
*/
import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { ContractFactory, JsonRpcProvider, Wallet, isAddress } from "ethers";
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const solc = require("solc");
const CONTRACTS_ROOT = resolve(__dirname, "..");
const OZ_ROOT = join(CONTRACTS_ROOT, "node_modules", "@openzeppelin");
type AbiFragment = Record<string, unknown>;
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 findImports(requestedPath: string): { contents: string } | { error: string } {
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}` };
}
}
try {
return { contents: readFileSync(join(CONTRACTS_ROOT, requestedPath), "utf8") };
} catch (e) {
return { error: (e as Error).message };
}
}
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 };
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("../")) {
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;
}
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) {
throw new Error(
`[deploy-notary-registry] solc compile failed:\n${fatal
.map((e) => e.formattedMessage)
.join("\n")}`,
);
}
const artifact = output.contracts[entry]?.NotaryRegistry;
if (!artifact) {
throw new Error(
"[deploy-notary-registry] solc did not emit NotaryRegistry artifact",
);
}
return {
abi: artifact.abi,
bytecode: "0x" + artifact.evm.bytecode.object,
};
}
function require1(name: string): string {
const v = process.env[name];
if (!v) {
throw new Error(`[deploy-notary-registry] ${name} is required`);
}
return v;
}
async function main(): Promise<void> {
const dryRun = process.env.NOTARY_DRY_RUN === "1";
const artifact = compileNotaryRegistry();
if (dryRun) {
const initialOwner =
process.env.NOTARY_INITIAL_OWNER ||
"0x0000000000000000000000000000000000000001";
if (!isAddress(initialOwner)) {
throw new Error(
`[deploy-notary-registry] NOTARY_INITIAL_OWNER is not a valid address: ${initialOwner}`,
);
}
const factory = new ContractFactory(artifact.abi, artifact.bytecode);
const deployTx = await factory.getDeployTransaction(initialOwner);
const envelope = {
contract: "NotaryRegistry",
dryRun: true,
initialOwner,
bytecodeLength: artifact.bytecode.length,
calldataLength: (deployTx.data as string).length,
abiEntryCount: artifact.abi.length,
};
console.log(JSON.stringify(envelope));
return;
}
const rpcUrl = require1("NOTARY_RPC_URL");
const pk = require1("NOTARY_DEPLOYER_PRIVATE_KEY");
const provider = new JsonRpcProvider(rpcUrl, undefined, {
staticNetwork: true,
cacheTimeout: -1,
});
const wallet = new Wallet(pk, provider);
const deployerAddr = await wallet.getAddress();
const initialOwner = process.env.NOTARY_INITIAL_OWNER || deployerAddr;
if (!isAddress(initialOwner)) {
throw new Error(
`[deploy-notary-registry] NOTARY_INITIAL_OWNER is not a valid address: ${initialOwner}`,
);
}
const net = await provider.getNetwork();
const bal = await provider.getBalance(deployerAddr);
console.error(
`[deploy-notary-registry] deployer=${deployerAddr} chainId=${net.chainId} balance=${bal} initialOwner=${initialOwner}`,
);
if (bal === BigInt(0)) {
throw new Error(
`[deploy-notary-registry] deployer ${deployerAddr} has zero balance on chainId=${net.chainId}. Fund the account before deploying.`,
);
}
const factory = new ContractFactory(artifact.abi, artifact.bytecode, wallet);
const contract = await factory.deploy(initialOwner);
const receipt = await contract.deploymentTransaction()?.wait();
const address = await contract.getAddress();
const envelope = {
contract: "NotaryRegistry",
address,
txHash: receipt?.hash,
chainId: Number(net.chainId),
initialOwner,
};
console.log(JSON.stringify(envelope));
}
main().catch((err) => {
console.error(err);
process.exit(1);
});