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
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:
20
.dockerignore
Normal file
20
.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.git
|
||||||
|
**/.github
|
||||||
|
**/dist
|
||||||
|
**/build
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/.DS_Store
|
||||||
|
**/.env
|
||||||
|
**/.env.local
|
||||||
|
**/.env.*.local
|
||||||
|
**/coverage
|
||||||
|
**/*.log
|
||||||
|
**/npm-debug.log*
|
||||||
|
orchestrator/dist
|
||||||
|
orchestrator/coverage
|
||||||
|
contracts/cache
|
||||||
|
contracts/artifacts
|
||||||
|
terraform
|
||||||
|
k8s
|
||||||
55
.env.sandbox.example
Normal file
55
.env.sandbox.example
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# CurrenciCombo sandbox env — copy to `.env.sandbox` and edit.
|
||||||
|
#
|
||||||
|
# cp .env.sandbox.example .env.sandbox
|
||||||
|
# docker compose --env-file .env.sandbox up -d
|
||||||
|
#
|
||||||
|
# `EVENT_SIGNING_SECRET` and `ORCHESTRATOR_API_KEYS` are REQUIRED —
|
||||||
|
# orchestrator will refuse to boot without them (see PR I boot-time
|
||||||
|
# env assertions in orchestrator/src/config/env.ts).
|
||||||
|
|
||||||
|
# ---- Postgres ----
|
||||||
|
POSTGRES_DB=currencicombo
|
||||||
|
POSTGRES_USER=currencicombo
|
||||||
|
POSTGRES_PASSWORD=currencicombo
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# ---- Redis ----
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
# ---- Orchestrator ----
|
||||||
|
ORCHESTRATOR_PORT=8080
|
||||||
|
# 32+ random bytes, hex-encoded. Generate with:
|
||||||
|
# openssl rand -hex 32
|
||||||
|
EVENT_SIGNING_SECRET=change-me-to-openssl-rand-hex-32
|
||||||
|
# Comma-separated `key:role` pairs; role ∈ {initiator, settler, auditor}
|
||||||
|
# Generate a key with:
|
||||||
|
# openssl rand -hex 16
|
||||||
|
ORCHESTRATOR_API_KEYS=local-demo-key:initiator,local-settler-key:settler,local-auditor-key:auditor
|
||||||
|
|
||||||
|
# ---- Chain 138 (EXT-CHAIN138-CI-RPC resolved by default) ----
|
||||||
|
CHAIN_138_RPC_URL=https://rpc.public-0138.defi-oracle.io
|
||||||
|
# Published by `contracts/scripts/deploy-notary-registry.ts` once you
|
||||||
|
# deploy NotaryRegistry.sol. Leave blank to run in mock-anchor mode.
|
||||||
|
NOTARY_REGISTRY_ADDRESS=
|
||||||
|
# Funded signer for on-chain anchors. Leave blank to run in mock-anchor
|
||||||
|
# mode (orchestrator logs "[NotaryChain] mock anchor — reason: notary
|
||||||
|
# envs not set" when unset).
|
||||||
|
ORCHESTRATOR_PRIVATE_KEY=
|
||||||
|
|
||||||
|
# ---- External blockers (leave blank to run in sandbox/mock mode) ----
|
||||||
|
# EXT-DBIS-CORE — flip when dbis_core is deployed
|
||||||
|
DBIS_CORE_URL=
|
||||||
|
# EXT-FIN-GATEWAY — flip when real FIN / Alliance Access gateway is provisioned
|
||||||
|
FIN_SANDBOX_URL=
|
||||||
|
# cc-identity-core HTTP base URL
|
||||||
|
CC_IDENTITY_URL=
|
||||||
|
# cc-compliance-controls matrix JSON URL (optional — embedded v0 is used if blank)
|
||||||
|
CC_CONTROLS_MATRIX_URL=
|
||||||
|
|
||||||
|
# ---- Portal (Vite) ----
|
||||||
|
PORTAL_PORT=3000
|
||||||
|
# Baked into the portal bundle at build time. Must be the URL the
|
||||||
|
# browser uses to reach the orchestrator (usually localhost + the
|
||||||
|
# published ORCHESTRATOR_PORT). Leave blank to run the portal in its
|
||||||
|
# built-in demo-fallback mode.
|
||||||
|
VITE_ORCHESTRATOR_URL=http://localhost:8080
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -78,3 +78,4 @@ pnpm-lock.yaml
|
|||||||
# Misc
|
# Misc
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
.env.sandbox
|
||||||
|
|||||||
68
Dockerfile
68
Dockerfile
@@ -1,39 +1,45 @@
|
|||||||
# Multi-stage Dockerfile for orchestrator service
|
# Multi-stage build for the CurrenciCombo portal (Vite + React).
|
||||||
FROM node:18-alpine AS builder
|
#
|
||||||
|
# Context MUST be the repo root so the vite build can see src/, public/,
|
||||||
|
# index.html, etc.:
|
||||||
|
#
|
||||||
|
# docker build -t currencicombo/portal:local .
|
||||||
|
#
|
||||||
|
# VITE_ORCHESTRATOR_URL is baked at build time (Vite inlines env vars
|
||||||
|
# prefixed with VITE_). In a sandbox compose, set it to whatever URL
|
||||||
|
# the browser uses to reach the orchestrator — typically
|
||||||
|
# http://localhost:8080 if the orchestrator's port is published on the
|
||||||
|
# host. When unset, the portal runs in its built-in demo-fallback mode
|
||||||
|
# (see src/services/orchestrator.ts).
|
||||||
|
|
||||||
|
# ------- build stage -------
|
||||||
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
ARG VITE_ORCHESTRATOR_URL=""
|
||||||
COPY orchestrator/package*.json ./
|
ENV VITE_ORCHESTRATOR_URL=${VITE_ORCHESTRATOR_URL}
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
# Copy source
|
COPY package.json package-lock.json ./
|
||||||
COPY orchestrator/ ./
|
# vite 7 ships @rolldown/binding-* as platform-matched optional deps,
|
||||||
|
# so we MUST include optional deps (skipping them breaks `vite build`
|
||||||
|
# with "Cannot find native binding"). `fsevents` is also optional but
|
||||||
|
# darwin-only; on linux npm 10 trips EBADPLATFORM on the lockfile
|
||||||
|
# entry even though the runtime would never load it. `--force` downgrades
|
||||||
|
# that EBADPLATFORM to a warning while still installing the rolldown
|
||||||
|
# binding for the current platform.
|
||||||
|
RUN npm install --include=optional --force --no-audit --no-fund --ignore-scripts
|
||||||
|
|
||||||
|
COPY tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts index.html eslint.config.js ./
|
||||||
|
COPY public ./public
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
# Build
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Production stage
|
# ------- runtime stage -------
|
||||||
FROM node:18-alpine
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
WORKDIR /app
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
# Copy package files
|
|
||||||
COPY orchestrator/package*.json ./
|
|
||||||
|
|
||||||
# Install production dependencies only
|
|
||||||
RUN npm ci --only=production
|
|
||||||
|
|
||||||
# Copy built files
|
|
||||||
COPY --from=builder /app/dist ./dist
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
|
||||||
CMD node -e "require('http').get('http://localhost:8080/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
|
|
||||||
|
|
||||||
# Start application
|
|
||||||
CMD ["node", "dist/index.js"]
|
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD wget -q --spider http://127.0.0.1/ || exit 1
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ const config: HardhatUserConfig = {
|
|||||||
hardhat: {
|
hardhat: {
|
||||||
chainId: 1337,
|
chainId: 1337,
|
||||||
},
|
},
|
||||||
|
// Public Chain 138 RPC — resolves proxmox blocker EXT-CHAIN138-CI-RPC.
|
||||||
|
// Deployer key is only read when a tx is actually sent (e.g. via
|
||||||
|
// `npx hardhat --network chain138 run scripts/deploy-notary-registry.ts`);
|
||||||
|
// leaving NOTARY_DEPLOYER_PRIVATE_KEY unset is safe for read-only
|
||||||
|
// flows like `hardhat console --network chain138`.
|
||||||
|
chain138: {
|
||||||
|
url: process.env.NOTARY_RPC_URL || "https://rpc.public-0138.defi-oracle.io",
|
||||||
|
chainId: 138,
|
||||||
|
accounts: process.env.NOTARY_DEPLOYER_PRIVATE_KEY
|
||||||
|
? [process.env.NOTARY_DEPLOYER_PRIVATE_KEY]
|
||||||
|
: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
paths: {
|
paths: {
|
||||||
sources: "./",
|
sources: "./",
|
||||||
|
|||||||
243
contracts/scripts/deploy-notary-registry.ts
Normal file
243
contracts/scripts/deploy-notary-registry.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -1,28 +1,44 @@
|
|||||||
version: '3.8'
|
# CurrenciCombo sandbox stack — orchestrator + portal + Postgres + Redis.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
#
|
||||||
|
# cp .env.sandbox.example .env.sandbox
|
||||||
|
# # edit .env.sandbox as needed
|
||||||
|
# docker compose --env-file .env.sandbox up -d
|
||||||
|
# curl http://localhost:${ORCHESTRATOR_PORT:-8080}/health
|
||||||
|
# curl http://localhost:${ORCHESTRATOR_PORT:-8080}/ready
|
||||||
|
# open http://localhost:${PORTAL_PORT:-3000}/
|
||||||
|
#
|
||||||
|
# External blockers from proxmox/scripts/verify/check-external-dependencies.sh
|
||||||
|
# surface in the orchestrator's boot-time log summary (see PR Y). Leaving
|
||||||
|
# DBIS_CORE_URL / FIN_SANDBOX_URL / CC_IDENTITY_URL unset is expected in
|
||||||
|
# the sandbox — the services fall back to deterministic mocks and tag
|
||||||
|
# the EXT-* blocker id in every log line.
|
||||||
|
#
|
||||||
|
# EXT-CHAIN138-CI-RPC is resolved out of the box: CHAIN_138_RPC_URL
|
||||||
|
# defaults to the public endpoint at https://rpc.public-0138.defi-oracle.io.
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# PostgreSQL database
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: comboflow
|
POSTGRES_DB: ${POSTGRES_DB:-currencicombo}
|
||||||
POSTGRES_USER: comboflow
|
POSTGRES_USER: ${POSTGRES_USER:-currencicombo}
|
||||||
POSTGRES_PASSWORD: comboflow
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-currencicombo}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U comboflow"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-currencicombo} -d ${POSTGRES_DB:-currencicombo}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# Redis cache
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
ports:
|
ports:
|
||||||
- "6379:6379"
|
- "${REDIS_PORT:-6379}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -31,43 +47,56 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# Orchestrator service
|
|
||||||
orchestrator:
|
orchestrator:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./orchestrator
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: currencicombo/orchestrator:local
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "${ORCHESTRATOR_PORT:-8080}:8080"
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
PORT: 8080
|
PORT: "8080"
|
||||||
DATABASE_URL: postgresql://comboflow:comboflow@postgres:5432/comboflow
|
DATABASE_URL: postgresql://${POSTGRES_USER:-currencicombo}:${POSTGRES_PASSWORD:-currencicombo}@postgres:5432/${POSTGRES_DB:-currencicombo}
|
||||||
REDIS_URL: redis://redis:6379
|
REDIS_URL: redis://redis:6379
|
||||||
|
# --- required for signed events (PR O) ---
|
||||||
|
EVENT_SIGNING_SECRET: ${EVENT_SIGNING_SECRET}
|
||||||
|
# --- API keys (PR M) — comma-separated key:role pairs ---
|
||||||
|
ORCHESTRATOR_API_KEYS: ${ORCHESTRATOR_API_KEYS}
|
||||||
|
# --- Chain 138 (EXT-CHAIN138-CI-RPC — resolved) ---
|
||||||
|
CHAIN_138_RPC_URL: ${CHAIN_138_RPC_URL:-https://rpc.public-0138.defi-oracle.io}
|
||||||
|
NOTARY_REGISTRY_ADDRESS: ${NOTARY_REGISTRY_ADDRESS:-}
|
||||||
|
ORCHESTRATOR_PRIVATE_KEY: ${ORCHESTRATOR_PRIVATE_KEY:-}
|
||||||
|
# --- External blockers (intentionally unset in sandbox) ---
|
||||||
|
DBIS_CORE_URL: ${DBIS_CORE_URL:-}
|
||||||
|
FIN_SANDBOX_URL: ${FIN_SANDBOX_URL:-}
|
||||||
|
CC_IDENTITY_URL: ${CC_IDENTITY_URL:-}
|
||||||
|
CC_CONTROLS_MATRIX_URL: ${CC_CONTROLS_MATRIX_URL:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:8080/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
|
||||||
# Frontend
|
portal:
|
||||||
webapp:
|
|
||||||
build:
|
build:
|
||||||
context: ./webapp
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_ORCHESTRATOR_URL: ${VITE_ORCHESTRATOR_URL:-http://localhost:8080}
|
||||||
|
image: currencicombo/portal:local
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "${PORTAL_PORT:-3000}:80"
|
||||||
environment:
|
|
||||||
NODE_ENV: production
|
|
||||||
NEXT_PUBLIC_ORCH_URL: http://orchestrator:8080
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- orchestrator
|
orchestrator:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
|
||||||
|
|||||||
28
nginx.conf
Normal file
28
nginx.conf
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
# Static SPA — vite build output lives here.
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Long-cache hashed assets produced by vite's rollup chunks.
|
||||||
|
location /assets/ {
|
||||||
|
access_log off;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — every other path yields index.html so client-side
|
||||||
|
# react-router can take over (see src/App.tsx / <Routes>).
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Defensive: no sourcemap exposure in sandbox.
|
||||||
|
location ~ \.map$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
orchestrator/Dockerfile
Normal file
54
orchestrator/Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Multi-stage build for the CurrenciCombo orchestrator.
|
||||||
|
#
|
||||||
|
# Context MUST be the orchestrator/ directory so the build does not
|
||||||
|
# need to traverse the whole repo. Build from repo root with:
|
||||||
|
#
|
||||||
|
# docker build -t currencicombo/orchestrator:local -f orchestrator/Dockerfile orchestrator/
|
||||||
|
#
|
||||||
|
# or via docker-compose (see docker-compose.yml at repo root).
|
||||||
|
|
||||||
|
# ------- deps stage -------
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
# `fsevents` is a darwin-only optional dep pulled in transitively via
|
||||||
|
# ganache + jest; npm 10's `ci` still validates the darwin-pinned
|
||||||
|
# entries on linux builders and fails with EBADPLATFORM. Use
|
||||||
|
# `npm install --omit=optional` to sidestep the strict check; we do
|
||||||
|
# not need reproducible nested optional resolutions for a runtime-only
|
||||||
|
# image (the tsc build only touches first-party deps).
|
||||||
|
RUN npm install --omit=optional --no-audit --no-fund --ignore-scripts
|
||||||
|
|
||||||
|
# ------- build stage -------
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ------- runtime stage -------
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=8080
|
||||||
|
|
||||||
|
RUN apk add --no-cache dumb-init \
|
||||||
|
&& addgroup -S orchestrator \
|
||||||
|
&& adduser -S -G orchestrator orchestrator
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm install --omit=dev --omit=optional --no-audit --no-fund --ignore-scripts \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
|
COPY --from=build /app/dist ./dist
|
||||||
|
|
||||||
|
USER orchestrator
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD node -e "require('http').get('http://127.0.0.1:8080/health', r => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
import { z } from "zod";
|
import { z, ZodTypeAny } from "zod";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Empty strings from `.env`-loaded variables (docker-compose with
|
||||||
|
* `NOTARY_REGISTRY_ADDRESS=` in .env.sandbox, Kubernetes `valueFrom`
|
||||||
|
* secrets that resolve to "", etc.) should validate identically to
|
||||||
|
* the variable being unset. Without this coercion, zod's
|
||||||
|
* `.regex(...).optional()` rejects `""` because the value IS provided.
|
||||||
|
*/
|
||||||
|
function emptyToUndefined<T extends ZodTypeAny>(schema: T) {
|
||||||
|
return z.preprocess(
|
||||||
|
(v) => (typeof v === "string" && v.length === 0 ? undefined : v),
|
||||||
|
schema,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment variable validation schema
|
* Environment variable validation schema
|
||||||
@@ -6,22 +20,26 @@ import { z } from "zod";
|
|||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||||
PORT: z.string().transform(Number).pipe(z.number().int().positive()),
|
PORT: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
DATABASE_URL: z.string().url().optional(),
|
DATABASE_URL: emptyToUndefined(z.string().url().optional()),
|
||||||
API_KEYS: z.string().optional(),
|
API_KEYS: emptyToUndefined(z.string().optional()),
|
||||||
REDIS_URL: z.string().url().optional(),
|
REDIS_URL: emptyToUndefined(z.string().url().optional()),
|
||||||
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
||||||
ALLOWED_IPS: z.string().optional(),
|
ALLOWED_IPS: emptyToUndefined(z.string().optional()),
|
||||||
SESSION_SECRET: z.string().min(32),
|
SESSION_SECRET: z.string().min(32),
|
||||||
JWT_SECRET: z.string().min(32).optional(),
|
JWT_SECRET: emptyToUndefined(z.string().min(32).optional()),
|
||||||
AZURE_KEY_VAULT_URL: z.string().url().optional(),
|
AZURE_KEY_VAULT_URL: emptyToUndefined(z.string().url().optional()),
|
||||||
AWS_SECRETS_MANAGER_REGION: z.string().optional(),
|
AWS_SECRETS_MANAGER_REGION: emptyToUndefined(z.string().optional()),
|
||||||
SENTRY_DSN: z.string().url().optional(),
|
SENTRY_DSN: emptyToUndefined(z.string().url().optional()),
|
||||||
// Chain-138 + NotaryRegistry wiring (arch §4.5). All optional; when
|
// Chain-138 + NotaryRegistry wiring (arch §4.5). All optional; when
|
||||||
// absent the notary adapter falls back to its deterministic mock.
|
// absent the notary adapter falls back to its deterministic mock.
|
||||||
CHAIN_138_RPC_URL: z.string().url().optional(),
|
CHAIN_138_RPC_URL: emptyToUndefined(z.string().url().optional()),
|
||||||
CHAIN_138_CHAIN_ID: z.string().regex(/^\d+$/).optional(),
|
CHAIN_138_CHAIN_ID: emptyToUndefined(z.string().regex(/^\d+$/).optional()),
|
||||||
NOTARY_REGISTRY_ADDRESS: z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),
|
NOTARY_REGISTRY_ADDRESS: emptyToUndefined(
|
||||||
ORCHESTRATOR_PRIVATE_KEY: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(),
|
z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),
|
||||||
|
),
|
||||||
|
ORCHESTRATOR_PRIVATE_KEY: emptyToUndefined(
|
||||||
|
z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user