Skip to content
Open
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
54 changes: 53 additions & 1 deletion coordinator/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ const configSchema = z.object({
},
z.boolean().default(false)
),
// Mainnet requires explicit audit confirmation to prevent accidental enablement
mainnetAuditConfirmed: z.preprocess(
(v) => {
if (typeof v === "boolean") return v;
if (typeof v === "string") {
const s = v.trim().toLowerCase();
return s === "true" || s === "1" || s === "yes" || s === "on";
}
return false;
},
z.boolean().default(false)
),
ethereum: z.object({
rpcUrl: z.string().url(),
chainId: z.number().int(),
Expand Down Expand Up @@ -71,6 +83,18 @@ export function loadConfig(): CoordinatorConfig {
const network = (process.env.NETWORK_MODE ?? "testnet") as Network;
const isMainnet = network === "mainnet";

// Mainnet requires explicit audit confirmation
if (isMainnet) {
const auditConfirmed = process.env.MAINNET_AUDIT_CONFIRMED === "true";
if (!auditConfirmed) {
throw new Error(
"MAINNET DEPLOYMENT BLOCKED: Set MAINNET_AUDIT_CONFIRMED=true only after " +
"completing the mainnet readiness checklist in docs/DEPLOYMENT.md. " +
"This includes audit completion, multisig ownership, and bug bounty."
);
}
}

const raw = {
network,
port: process.env.COORDINATOR_PORT ?? process.env.RELAYER_PORT ?? "3001",
Expand All @@ -86,6 +110,7 @@ export function loadConfig(): CoordinatorConfig {
// when the env var is unset; previously this branch substituted
// "false" and z.coerce.boolean() turned it into true.
demoFixtures: process.env.COORDINATOR_DEMO_FIXTURES,
mainnetAuditConfirmed: process.env.MAINNET_AUDIT_CONFIRMED,
ethereum: {
rpcUrl: resolveEthereumRpcUrl(isMainnet ? "mainnet" : "testnet"),
chainId: isMainnet ? 1 : 11_155_111,
Expand All @@ -106,5 +131,32 @@ export function loadConfig(): CoordinatorConfig {
timelockSafetyGapSeconds: 600
};

return configSchema.parse(raw);
const result = configSchema.parse(raw);

// Additional validation: testnet requires contract addresses
if (!isMainnet) {
const missingTestnetContracts = [];
if (!result.ethereum.htlcEscrow) {
missingTestnetContracts.push("ETH_HTLC_ESCROW_TESTNET");
}
if (!result.ethereum.resolverRegistry) {
missingTestnetContracts.push("ETH_RESOLVER_REGISTRY_TESTNET");
}
if (!result.soroban.htlcContract) {
missingTestnetContracts.push("SOROBAN_HTLC_TESTNET");
}
if (!result.soroban.resolverRegistry) {
missingTestnetContracts.push("SOROBAN_RESOLVER_REGISTRY_TESTNET");
}

if (missingTestnetContracts.length > 0) {
throw new Error(
`TESTNET DEPLOYMENT INCOMPLETE: Missing required testnet contract addresses: ` +
missingTestnetContracts.join(", ") +
". Deploy contracts first (see docs/DEPLOYMENT.md) or check env.example for variable names."
);
}
}

return result;
}
90 changes: 90 additions & 0 deletions coordinator/test/config-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadConfig } from '../src/config.js';

describe('Coordinator Config Validation', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
});

afterEach(() => {
process.env = originalEnv;
});

it('should load config successfully with valid testnet env', () => {
process.env.NETWORK_MODE = 'testnet';
process.env.ETH_HTLC_ESCROW_TESTNET = '0x1234567890123456789012345678901234567890';
process.env.ETH_RESOLVER_REGISTRY_TESTNET = '0x1234567890123456789012345678901234567890';
process.env.SOROBAN_HTLC_TESTNET = 'C1234567890123456789012345678901234567890';
process.env.SOROBAN_RESOLVER_REGISTRY_TESTNET = 'C1234567890123456789012345678901234567890';

const config = loadConfig();
expect(config.network).toBe('testnet');
expect(config.ethereum.htlcEscrow).toBe('0x1234567890123456789012345678901234567890');
});

it('should throw error when testnet contract addresses are missing', () => {
process.env.NETWORK_MODE = 'testnet';
// Missing contract addresses

expect(() => loadConfig()).toThrow('TESTNET DEPLOYMENT INCOMPLETE');
});

it('should throw error when mainnet is enabled without audit confirmation', () => {
process.env.NETWORK_MODE = 'mainnet';
// MAINNET_AUDIT_CONFIRMED is not set (defaults to false)

expect(() => loadConfig()).toThrow('MAINNET DEPLOYMENT BLOCKED');
});

it('should throw error when mainnet audit confirmation is false', () => {
process.env.NETWORK_MODE = 'mainnet';
process.env.MAINNET_AUDIT_CONFIRMED = 'false';

expect(() => loadConfig()).toThrow('MAINNET DEPLOYMENT BLOCKED');
});

it('should load config successfully with valid mainnet env and audit confirmation', () => {
process.env.NETWORK_MODE = 'mainnet';
process.env.MAINNET_AUDIT_CONFIRMED = 'true';
process.env.ETH_HTLC_ESCROW_MAINNET = '0x1234567890123456789012345678901234567890';
process.env.ETH_RESOLVER_REGISTRY_MAINNET = '0x1234567890123456789012345678901234567890';
process.env.SOROBAN_HTLC_MAINNET = 'C1234567890123456789012345678901234567890';
process.env.SOROBAN_RESOLVER_REGISTRY_MAINNET = 'C1234567890123456789012345678901234567890';

const config = loadConfig();
expect(config.network).toBe('mainnet');
expect(config.mainnetAuditConfirmed).toBe(true);
});

it('should throw error for invalid network mode', () => {
process.env.NETWORK_MODE = 'invalid';

expect(() => loadConfig()).toThrow('NETWORK_MODE must be \'testnet\' or \'mainnet\'');
});

it('should validate Ethereum RPC URL format', () => {
process.env.NETWORK_MODE = 'testnet';
process.env.ETH_HTLC_ESCROW_TESTNET = '0x1234567890123456789012345678901234567890';
process.env.ETH_RESOLVER_REGISTRY_TESTNET = '0x1234567890123456789012345678901234567890';
process.env.SOROBAN_HTLC_TESTNET = 'C1234567890123456789012345678901234567890';
process.env.SOROBAN_RESOLVER_REGISTRY_TESTNET = 'C1234567890123456789012345678901234567890';
process.env.SEPOLIA_RPC_URL = 'not-a-valid-url';

// Should throw due to invalid URL
expect(() => loadConfig()).toThrow();
});

it('should accept valid Ethereum RPC URL', () => {
process.env.NETWORK_MODE = 'testnet';
process.env.ETH_HTLC_ESCROW_TESTNET = '0x1234567890123456789012345678901234567890';
process.env.ETH_RESOLVER_REGISTRY_TESTNET = '0x1234567890123456789012345678901234567890';
process.env.SOROBAN_HTLC_TESTNET = 'C1234567890123456789012345678901234567890';
process.env.SOROBAN_RESOLVER_REGISTRY_TESTNET = 'C1234567890123456789012345678901234567890';
process.env.SEPOLIA_RPC_URL = 'https://sepolia.infura.io/v3/abc123';

const config = loadConfig();
expect(config.ethereum.rpcUrl).toBe('https://sepolia.infura.io/v3/abc123');
});
});
72 changes: 72 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ contained inconsistencies flagged in v1 review feedback.
- A funded Ethereum deployer key (Sepolia or mainnet)
- A funded Stellar account (Soroban testnet or public)

## Environment Validation

All OverSync services now perform **strict environment validation at startup**:

- **Coordinator**: Validates config with Zod schema, requires testnet contract addresses, blocks mainnet without audit confirmation
- **Resolver**: Validates config with Zod schema, requires testnet contract addresses, blocks mainnet without audit confirmation
- **Relayer**: Validates RPC URLs, contract addresses, private keys, and timeout settings
- **Frontend**: Validates contract addresses and network configuration at **build time** (Vite)

**Services will fail to start if required environment variables are missing or malformed.**

## 1. Copy and fill in the env file

```bash
Expand All @@ -31,6 +42,8 @@ SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
ETHERSCAN_API_KEY=<key>
```

**⚠️ CRITICAL: Do NOT set `MAINNET_AUDIT_CONFIRMED=true` until you have completed the mainnet readiness checklist below.**

Testnet asset identifier mappings (native ETH ↔ XLM, Sepolia USDC ↔ Stellar USDC)
are centralized in `packages/sdk/src/assets/index.ts` and exported from
`@oversync/sdk`.
Expand Down Expand Up @@ -165,6 +178,15 @@ Before setting `VITE_MAINNET_ENABLED=true` and flipping backend
- [ ] Public bug bounty announced
- [ ] Sepolia run with $1k+ in TVL for a continuous 14-day window without incidents

**Once all items are checked:**

1. Set `MAINNET_AUDIT_CONFIRMED=true` in backend `.env` (coordinator, resolver, relayer)
2. Set `VITE_MAINNET_AUDIT_CONFIRMED=true` in Vercel environment variables
3. Set `VITE_MAINNET_ENABLED=true` in Vercel environment variables
4. Flip backend `NETWORK_MODE=mainnet` and deploy

**This three-step process prevents accidental mainnet enablement.**

## Rolling back

If a serious bug is found post-launch, the HTLCEscrow contract has
Expand Down Expand Up @@ -204,8 +226,58 @@ the CI address-consistency check stays green:
And the frontend Vercel env vars:
```
VITE_ETH_HTLC_ESCROW_TESTNET=0x<new>
VITE_ETH_RESOLVER_REGISTRY_TESTNET=0x<new>
VITE_SOROBAN_HTLC_TESTNET=C<new>
VITE_SOROBAN_RESOLVER_REGISTRY_TESTNET=C<new>
```

5. **Verify**: run `pnpm verify:addresses` locally before pushing. The same
check runs in CI (`address-verify` workflow) on every PR that touches these
files and will block the merge if any value drifts.

## Environment Variable Reference

See [`env.example`](../env.example) for a complete list of all environment variables
with descriptions. Key variables by service:

### Coordinator (`coordinator/src/config.ts`)
- `NETWORK_MODE`, `MAINNET_AUDIT_CONFIRMED` (mainnet only)
- `ETH_HTLC_ESCROW_TESTNET` / `ETH_HTLC_ESCROW_MAINNET`
- `ETH_RESOLVER_REGISTRY_TESTNET` / `ETH_RESOLVER_REGISTRY_MAINNET`
- `SOROBAN_HTLC_TESTNET` / `SOROBAN_HTLC_MAINNET`
- `SOROBAN_RESOLVER_REGISTRY_TESTNET` / `SOROBAN_RESOLVER_REGISTRY_MAINNET`
- `DATABASE_URL`, `COORDINATOR_PORT`, `LOG_LEVEL`

### Resolver (`resolver/src/config.ts`)
- Same contract address variables as coordinator
- `RESOLVER_ETH_PRIVATE_KEY`, `RESOLVER_STELLAR_SECRET` (required for production)
- `COORDINATOR_URL`, `RESOLVER_POLL_INTERVAL_MS`

### Relayer (`relayer/src/index.ts`)
- `RELAYER_PRIVATE_KEY`, `RELAYER_STELLAR_SECRET`, `RELAYER_STELLAR_PUBLIC`
- `RELAYER_RPC_TIMEOUT_MS`, `RELAYER_RETRY_ATTEMPTS`
- `ETHEREUM_NETWORK`, `STELLAR_NETWORK`
- `GAS_PRICE_GWEI`, `GAS_LIMIT`

### Frontend (`frontend/src/config/networks.ts`)
- `VITE_NETWORK_MODE`, `VITE_MAINNET_ENABLED`, `VITE_MAINNET_AUDIT_CONFIRMED`
- `VITE_ETH_HTLC_ESCROW_TESTNET` / `VITE_ETH_HTLC_ESCROW_MAINNET`
- `VITE_ETH_RESOLVER_REGISTRY_TESTNET` / `VITE_ETH_RESOLVER_REGISTRY_MAINNET`
- `VITE_SOROBAN_HTLC_TESTNET` / `VITE_SOROBAN_HTLC_MAINNET`
- `VITE_SOROBAN_RESOLVER_REGISTRY_TESTNET` / `VITE_SOROBAN_RESOLVER_REGISTRY_MAINNET`
- `VITE_API_BASE_URL`, `VITE_SEPOLIA_RPC_URL`, `VITE_MAINNET_RPC_URL`

## Troubleshooting

### "MAINNET DEPLOYMENT BLOCKED" error
This is a safety feature. Complete the mainnet readiness checklist in `env.example`
and set `MAINNET_AUDIT_CONFIRMED=true`.

### "TESTNET DEPLOYMENT INCOMPLETE" error
Deploy the testnet contracts first (see Section 2 and 3 above), then set the
contract address environment variables.

### Frontend build fails with "Frontend environment validation failed"
Check that all `VITE_*` contract address variables are set in your Vercel
environment variables. The build will fail if testnet/mainnet contract addresses
are missing when the corresponding network mode is selected.
Loading
Loading