Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions coordinator/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,18 @@ const configSchema = z.object({
htlcContract: z.string().optional().transform((v) => v ?? null),
resolverRegistry: z.string().optional().transform((v) => v ?? null)
})
});
.superRefine((cfg, ctx) => {
// Mainnet requires explicit audit confirmation.
if (cfg.network === "mainnet" && !cfg.mainnetAuditConfirmed) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["mainnetAuditConfirmed"],
message:
"NETWORK_MODE=mainnet requires MAINNET_AUDIT_CONFIRMED=true. " +
"Read docs/DEPLOYMENT.md#mainnet-rollout-checklist before enabling.",
});
}
});

export type CoordinatorConfig = z.infer<typeof configSchema>;

Expand All @@ -50,6 +61,7 @@ export function loadConfig(): CoordinatorConfig {

const raw = {
network,
mainnetAuditConfirmed: process.env.MAINNET_AUDIT_CONFIRMED === "true",
port: process.env.COORDINATOR_PORT ?? process.env.RELAYER_PORT ?? "3001",
databaseUrl: process.env.DATABASE_URL ?? "file:./oversync.db",
logLevel: process.env.LOG_LEVEL ?? "info",
Expand All @@ -59,21 +71,38 @@ export function loadConfig(): CoordinatorConfig {
ethereum: {
rpcUrl: resolveEthereumRpcUrl(isMainnet ? "mainnet" : "testnet"),
chainId: isMainnet ? 1 : 11_155_111,
htlcEscrow: process.env[isMainnet ? "ETH_HTLC_ESCROW_MAINNET" : "ETH_HTLC_ESCROW_TESTNET"] ?? "",
htlcEscrow:
process.env[isMainnet ? "ETH_HTLC_ESCROW_MAINNET" : "ETH_HTLC_ESCROW_TESTNET"] ?? "",
resolverRegistry:
process.env[isMainnet ? "ETH_RESOLVER_REGISTRY_MAINNET" : "ETH_RESOLVER_REGISTRY_TESTNET"] ?? ""
process.env[
isMainnet ? "ETH_RESOLVER_REGISTRY_MAINNET" : "ETH_RESOLVER_REGISTRY_TESTNET"
] ?? "",
},
soroban: {
rpcUrl: process.env.SOROBAN_RPC_URL ?? (isMainnet ? "https://mainnet.sorobanrpc.com" : "https://soroban-testnet.stellar.org"),
horizonUrl: process.env.STELLAR_HORIZON_URL ?? (isMainnet ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org"),
rpcUrl:
process.env.SOROBAN_RPC_URL ??
(isMainnet ? "https://mainnet.sorobanrpc.com" : "https://soroban-testnet.stellar.org"),
horizonUrl:
process.env.STELLAR_HORIZON_URL ??
(isMainnet ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org"),
networkPassphrase: isMainnet
? "Public Global Stellar Network ; September 2015"
: "Test SDF Network ; September 2015",
htlcContract: process.env[isMainnet ? "SOROBAN_HTLC_MAINNET" : "SOROBAN_HTLC_TESTNET"],
htlcContract:
process.env[isMainnet ? "SOROBAN_HTLC_MAINNET" : "SOROBAN_HTLC_TESTNET"],
resolverRegistry:
process.env[isMainnet ? "SOROBAN_RESOLVER_REGISTRY_MAINNET" : "SOROBAN_RESOLVER_REGISTRY_TESTNET"]
}
process.env[
isMainnet ? "SOROBAN_RESOLVER_REGISTRY_MAINNET" : "SOROBAN_RESOLVER_REGISTRY_TESTNET"
],
},
};

return configSchema.parse(raw);
const result = configSchema.safeParse(raw);
if (!result.success) {
const messages = result.error.issues
.map((i) => ` [${i.path.join(".")}] ${i.message}`)
.join("\n");
throw new Error(`Coordinator configuration invalid:\n${messages}`);
}
return result.data;
}
81 changes: 81 additions & 0 deletions coordinator/test/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect } from "vitest";
import { loadConfig } from "../src/config.js";

/**
* Temporarily override process.env keys, run fn(), then restore.
* loadConfig() reads process.env at call-time so this is safe.
*/
function withEnv<T>(overrides: Record<string, string | undefined>, fn: () => T): T {
const saved: Record<string, string | undefined> = {};
for (const [k, v] of Object.entries(overrides)) {
saved[k] = process.env[k];
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
try {
return fn();
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
}

// Minimal env that should always parse cleanly on testnet.
const BASE: Record<string, string | undefined> = {
NETWORK_MODE: "testnet",
MAINNET_AUDIT_CONFIRMED: "false",
SEPOLIA_RPC_URL: "https://sepolia.infura.io/v3/testkey",
MAINNET_RPC_URL: undefined,
SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org",
STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org",
};

describe("coordinator loadConfig() — env validation", () => {
it("parses a valid testnet config without throwing", () => {
withEnv(BASE, () => {
const cfg = loadConfig();
expect(cfg.network).toBe("testnet");
expect(cfg.mainnetAuditConfirmed).toBe(false);
});
});

it("throws when NETWORK_MODE=mainnet and MAINNET_AUDIT_CONFIRMED is missing", () => {
withEnv(
{
...BASE,
NETWORK_MODE: "mainnet",
MAINNET_AUDIT_CONFIRMED: "false",
MAINNET_RPC_URL: "https://mainnet.infura.io/v3/testkey",
SEPOLIA_RPC_URL: undefined,
},
() => {
expect(() => loadConfig()).toThrow(/MAINNET_AUDIT_CONFIRMED/);
}
);
});

it("accepts NETWORK_MODE=mainnet when MAINNET_AUDIT_CONFIRMED=true", () => {
withEnv(
{
...BASE,
NETWORK_MODE: "mainnet",
MAINNET_AUDIT_CONFIRMED: "true",
MAINNET_RPC_URL: "https://mainnet.infura.io/v3/testkey",
SEPOLIA_RPC_URL: undefined,
},
() => {
const cfg = loadConfig();
expect(cfg.network).toBe("mainnet");
expect(cfg.mainnetAuditConfirmed).toBe(true);
}
);
});

it("throws a descriptive error when Soroban RPC URL is malformed", () => {
withEnv({ ...BASE, SOROBAN_RPC_URL: "not-a-url" }, () => {
expect(() => loadConfig()).toThrow(/configuration invalid/i);
});
});
});
63 changes: 59 additions & 4 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,65 @@ pnpm exec hardhat verify --network sepolia <ESCROW_ADDRESS> <REGISTRY_ADDRESS> <
pnpm exec hardhat verify --network sepolia <REGISTRY_ADDRESS> <STAKE_ASSET> <MIN_STAKE> <SLASH_BENEFICIARY> <OWNER>
```

## Mainnet rollout checklist

Before setting `VITE_MAINNET_ENABLED=true` and flipping backend
`NETWORK_MODE=mainnet`:
## Environment validation

Every service validates its configuration at startup and **exits immediately** with a
descriptive error if required vars are missing or malformed. This prevents silent
misconfiguration in CI/CD pipelines.

| Service | Validation point | Behaviour on failure |
|---|---|---|
| coordinator | `loadConfig()` in `src/config.ts` | throws, `main()` calls `process.exit(1)` |
| resolver | `loadConfig()` in `src/config.ts` | throws, `program.parseAsync()` bubbles it |
| relayer | `validateRelayerEnv()` in `src/env-validation.ts` called at module load | `process.exit(1)` |
| frontend | `envValidationPlugin` in `vite.config.ts` (build) + `src/config/env.ts` (runtime) | Vite build aborts |

### Required variables per service

**Coordinator** (`coordinator/`)

| Variable | Required | Notes |
|---|---|---|
| `NETWORK_MODE` | no (default `testnet`) | `testnet` or `mainnet` |
| `MAINNET_AUDIT_CONFIRMED` | **yes when mainnet** | must be `true` |
| `SEPOLIA_RPC_URL` / `INFURA_API_KEY` | yes | Ethereum RPC source |
| `COORDINATOR_PORT` | no (default `3001`) | |
| `DATABASE_URL` | no (default SQLite) | |

**Resolver** (`resolver/`)

| Variable | Required | Notes |
|---|---|---|
| `NETWORK_MODE` | no (default `testnet`) | |
| `MAINNET_AUDIT_CONFIRMED` | **yes when mainnet** | |
| `RESOLVER_ETH_PRIVATE_KEY` | **yes** | 0x-prefixed 32-byte key |
| `SEPOLIA_RPC_URL` / `INFURA_API_KEY` | yes | |

**Relayer** (`relayer/`)

| Variable | Required | Notes |
|---|---|---|
| `NETWORK_MODE` | no (default `testnet`) | |
| `MAINNET_AUDIT_CONFIRMED` | **yes when mainnet** | |
| `SEPOLIA_RPC_URL` / `MAINNET_RPC_URL` / `INFURA_API_KEY` | **yes** | at least one |
| `RELAYER_PRIVATE_KEY` | **yes** | 0x-prefixed 32-byte key |
| `RELAYER_STELLAR_SECRET` | **yes** | Stellar secret key |
| `RELAYER_STELLAR_PUBLIC` | **yes** | Stellar public key |
| `RELAYER_RPC_TIMEOUT_MS` | no (default `30000`) | |

**Frontend** (`frontend/`)

| Variable | Required | Notes |
|---|---|---|
| `VITE_API_BASE_URL` | **yes** | e.g. `http://localhost:3001` |
| `VITE_NETWORK` | **yes** | `testnet` or `mainnet` |
| `VITE_MAINNET_ENABLED` | no (default `false`) | |
| `VITE_MAINNET_AUDIT_CONFIRMED` | **yes when mainnet enabled** | must accompany `VITE_MAINNET_ENABLED=true` |

### Mainnet rollout checklist

Before setting `VITE_MAINNET_ENABLED=true` / `VITE_MAINNET_AUDIT_CONFIRMED=true` and
flipping `NETWORK_MODE=mainnet` / `MAINNET_AUDIT_CONFIRMED=true`:

- [ ] Both HTLC contracts independently audited (see [`SECURITY.md`](SECURITY.md))
- [ ] `ResolverRegistry.owner` transferred to a 2/3 multisig
Expand Down
21 changes: 20 additions & 1 deletion env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ LOG_LEVEL=info
# uses Ethereum mainnet + Stellar public network.
NETWORK_MODE=testnet

# ──────────────────────────────────────────────────────────────────────────────
# MAINNET GATE
# ──────────────────────────────────────────────────────────────────────────────
# Mainnet is DISABLED until the v2 security audit is complete.
# To enable mainnet across all services set BOTH to 'true' only after completing
# the checklist in docs/DEPLOYMENT.md#mainnet-rollout-checklist:
# - Both HTLC contracts independently audited (see docs/SECURITY.md)
# - ResolverRegistry.owner transferred to a 2/3 multisig
# - At least 3 community resolvers registered
# - 14-day testnet soak with $1k+ TVL without incidents
#
# Setting MAINNET_AUDIT_CONFIRMED=false (or omitting it) while NETWORK_MODE=mainnet
# will cause coordinator, resolver, and relayer to exit at startup with a clear error.
MAINNET_AUDIT_CONFIRMED=false

# Canonical Ethereum ↔ Stellar asset mappings live in @oversync/sdk
# (packages/sdk/src/assets/index.ts). Extend there for custom testnet pairs.

Expand Down Expand Up @@ -117,8 +132,12 @@ ETHERSCAN_API_KEY=
VITE_API_BASE_URL=http://localhost:3001
# Production API (DigitalOcean): https://oversync-k36vx.ondigitalocean.app — proxied via vercel.json /api/*
VITE_NETWORK_MODE=testnet
# Set to true to re-enable mainnet toggle (v2 mainnet post-audit)
# Set to true to re-enable mainnet toggle (v2 mainnet post-audit).
# Requires VITE_MAINNET_AUDIT_CONFIRMED=true as well — see MAINNET GATE above.
VITE_MAINNET_ENABLED=false
# Must be 'true' together with VITE_MAINNET_ENABLED to unlock mainnet in the UI.
# Both flags must be set to prevent accidental production exposure.
VITE_MAINNET_AUDIT_CONFIRMED=false
# Frontend wallet RPC (Vercel). Use full URL or the same Infura key.
# Restrict the key by HTTP referrer in the Infura dashboard.
VITE_SEPOLIA_RPC_URL=
Expand Down
70 changes: 70 additions & 0 deletions frontend/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* Frontend environment validation.
*
* Validates required Vite env vars at module-load time (i.e. at build or
* during dev server start). Import this module early — ideally in main.tsx —
* so misconfigured builds fail loudly before any UI renders.
*
* Mainnet enablement requires BOTH:
* VITE_MAINNET_ENABLED=true
* VITE_MAINNET_AUDIT_CONFIRMED=true
* to prevent accidental production deployments.
*/

function e(key: string): string | undefined {
return (import.meta as any).env?.[key]?.trim() || undefined;
}

/** Collect all validation errors rather than throwing on the first. */
function validateEnv(): void {
const errors: string[] = [];

// Required: API base URL
const apiBase = e("VITE_API_BASE_URL");
if (!apiBase) {
errors.push("VITE_API_BASE_URL is required (e.g. http://localhost:3001)");
} else if (!apiBase.startsWith("http://") && !apiBase.startsWith("https://")) {
errors.push(`VITE_API_BASE_URL must be a valid HTTP(S) URL (got "${apiBase}")`);
}

// Required: network mode
const networkMode = e("VITE_NETWORK") ?? e("VITE_NETWORK_MODE");
if (!networkMode) {
errors.push("VITE_NETWORK is required (testnet or mainnet)");
} else if (networkMode !== "testnet" && networkMode !== "mainnet") {
errors.push(`VITE_NETWORK must be 'testnet' or 'mainnet' (got "${networkMode}")`);
}

// Mainnet double-confirmation gate.
// VITE_MAINNET_ENABLED=true alone is NOT sufficient — operators must also
// set VITE_MAINNET_AUDIT_CONFIRMED=true after completing the checklist in
// docs/DEPLOYMENT.md#mainnet-rollout-checklist.
const mainnetEnabled = e("VITE_MAINNET_ENABLED") === "true";
const mainnetAuditConfirmed = e("VITE_MAINNET_AUDIT_CONFIRMED") === "true";

if (mainnetEnabled && !mainnetAuditConfirmed) {
errors.push(
"VITE_MAINNET_ENABLED=true requires VITE_MAINNET_AUDIT_CONFIRMED=true. " +
"Complete the checklist in docs/DEPLOYMENT.md#mainnet-rollout-checklist first."
);
}

if (errors.length > 0) {
const msg =
"Frontend environment misconfigured:\n" +
errors.map((e) => ` - ${e}`).join("\n");
throw new Error(msg);
}
}

// Run at module load time (build + dev).
validateEnv();

/** Resolved, validated environment values for use in the app. */
export const ENV = {
apiBaseUrl: e("VITE_API_BASE_URL") as string,
networkMode: (e("VITE_NETWORK") ?? e("VITE_NETWORK_MODE") ?? "testnet") as "testnet" | "mainnet",
mainnetEnabled:
e("VITE_MAINNET_ENABLED") === "true" &&
e("VITE_MAINNET_AUDIT_CONFIRMED") === "true",
} as const;
Loading
Loading