From eac2b8d9194b73ed1a3ed30b4d9f0ea4da3935d4 Mon Sep 17 00:00:00 2001 From: nikkybel Date: Wed, 24 Jun 2026 12:52:48 +0100 Subject: [PATCH 1/3] feat(sdk): typed Soroban HTLC order reads - Add SorobanOrderStatus and SorobanOrderData types to packages/sdk/src/types/index.ts - Update SorobanHTLCClient.getOrder() to return Promise - Export parseSorobanOrder() pure helper for normalising raw scValToNative payloads - Handle Uint8Array/Buffer/string hex for byte fields, bigint coercion for numerics - Treat empty preimage bytes (Funded state) as empty string - Add 14 tests covering funded, claimed, refunded, null, malformed, and edge cases --- packages/sdk/src/index.ts | 1 + packages/sdk/src/soroban/index.ts | 114 ++++++++++++++++- packages/sdk/src/types/index.ts | 33 +++++ packages/sdk/test/soroban-order.test.ts | 162 ++++++++++++++++++++++++ 4 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/test/soroban-order.test.ts diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d18717a..7f46ee0 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,6 +12,7 @@ export { export { SorobanHTLCClient, makeKeypairSigner, + parseSorobanOrder, type SorobanHTLCClientOptions, type SorobanCreateOrderInput, type SorobanSigner diff --git a/packages/sdk/src/soroban/index.ts b/packages/sdk/src/soroban/index.ts index 1802214..256cba2 100644 --- a/packages/sdk/src/soroban/index.ts +++ b/packages/sdk/src/soroban/index.ts @@ -10,6 +10,7 @@ import { type Keypair, type Transaction } from "@stellar/stellar-sdk"; +import type { SorobanOrderData, SorobanOrderStatus } from "../types/index.js"; export interface SorobanHTLCClientOptions { /** Soroban RPC endpoint, e.g. https://soroban-testnet.stellar.org */ @@ -127,7 +128,7 @@ export class SorobanHTLCClient { return this.simulateSignSubmit(callerAccountId, op, signer); } - async getOrder(orderId: bigint): Promise { + async getOrder(orderId: bigint): Promise { const op = this.contract.call( "get_order", nativeToScVal(orderId, { type: "u64" }) @@ -147,7 +148,8 @@ export class SorobanHTLCClient { } const result = (sim as any).result; if (!result || !result.retval) return null; - return scValToNative(result.retval); + const native = scValToNative(result.retval); + return parseSorobanOrder(native); } private async simulateSignSubmit( @@ -200,3 +202,111 @@ export function makeKeypairSigner(keypair: Keypair): SorobanSigner { return tx.toXDR(); }; } + +// --------------------------------------------------------------- +// Soroban order parsing helpers +// --------------------------------------------------------------- + +const STATUS_MAP: Record = { + Funded: "Funded", + Claimed: "Claimed", + Refunded: "Refunded", +}; + +function bytesToHex(value: unknown): `0x${string}` { + if (value instanceof Uint8Array || Buffer.isBuffer(value as Buffer)) { + return ("0x" + + Array.from(value as Uint8Array, (b) => b.toString(16).padStart(2, "0")).join("")) as `0x${string}`; + } + if (typeof value === "string") { + // Already a hex string (some SDK versions surface as string). + return (value.startsWith("0x") ? value : "0x" + value) as `0x${string}`; + } + throw new Error(`parseSorobanOrder: cannot convert value to hex: ${typeof value}`); +} + +function toBigInt(value: unknown, field: string): bigint { + if (typeof value === "bigint") return value; + if (typeof value === "number") return BigInt(value); + if (typeof value === "string") { + try { + return BigInt(value); + } catch { + throw new Error(`parseSorobanOrder: field "${field}" is not a numeric type (got string "${value}")`); + } + } + throw new Error(`parseSorobanOrder: field "${field}" is not a numeric type (got ${typeof value})`); +} + +function toString(value: unknown, field: string): string { + if (typeof value === "string") return value; + throw new Error(`parseSorobanOrder: field "${field}" is not a string (got ${typeof value})`); +} + +/** + * Parse/normalise a raw `scValToNative` return value from the Soroban + * HTLC contract's `get_order` into a typed {@link SorobanOrderData}. + * + * Returns `null` when `raw` is null/undefined (order not found). + * Throws a descriptive error for malformed payloads. + * + * This is exported as a pure function so callers that obtain the raw + * value through other means (e.g. event streaming) can still benefit + * from the typed normalisation. + */ +export function parseSorobanOrder(raw: unknown): SorobanOrderData | null { + if (raw === null || raw === undefined) return null; + + if (typeof raw !== "object" || Array.isArray(raw)) { + throw new Error(`parseSorobanOrder: expected an object, got ${Array.isArray(raw) ? "array" : typeof raw}`); + } + + const r = raw as Record; + + const requiredFields = [ + "id", "sender", "beneficiary", "refund_address", "asset", + "amount", "safety_deposit", "hashlock", "timelock", + "status", "preimage", "created_at", "finalised_at", + ] as const; + + for (const f of requiredFields) { + if (!(f in r)) { + throw new Error(`parseSorobanOrder: missing required field "${f}"`); + } + } + + const rawStatus = r["status"]; + const status: SorobanOrderStatus = + typeof rawStatus === "string" && rawStatus in STATUS_MAP + ? STATUS_MAP[rawStatus] + : (() => { throw new Error(`parseSorobanOrder: unknown status value "${rawStatus}"`); })(); + + // preimage is an empty Bytes in Funded state; normalise to "". + let preimage: `0x${string}` | "" = ""; + const rawPreimage = r["preimage"]; + if ( + rawPreimage !== null && + rawPreimage !== undefined && + !(rawPreimage instanceof Uint8Array && rawPreimage.length === 0) && + !(Buffer.isBuffer(rawPreimage) && (rawPreimage as Buffer).length === 0) && + rawPreimage !== "" + ) { + preimage = bytesToHex(rawPreimage); + } + + return { + id: toBigInt(r["id"], "id"), + sender: toString(r["sender"], "sender"), + beneficiary: toString(r["beneficiary"], "beneficiary"), + refundAddress: toString(r["refund_address"], "refund_address"), + asset: toString(r["asset"], "asset"), + amount: toBigInt(r["amount"], "amount"), + safetyDeposit: toBigInt(r["safety_deposit"], "safety_deposit"), + hashlock: bytesToHex(r["hashlock"]), + timelock: toBigInt(r["timelock"], "timelock"), + status, + preimage, + createdAt: toBigInt(r["created_at"], "created_at"), + finalisedAt: toBigInt(r["finalised_at"], "finalised_at"), + }; +} diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index 0cbf42b..de76d73 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -1,6 +1,39 @@ export type Chain = "ethereum" | "stellar"; export type Direction = "eth_to_xlm" | "xlm_to_eth"; +// --------------------------------------------------------------- +// Soroban HTLC order types +// --------------------------------------------------------------- + +/** Lifecycle status returned by the Soroban HTLC contract. */ +export type SorobanOrderStatus = "Funded" | "Claimed" | "Refunded"; + +/** + * Typed representation of a Soroban HTLC order, normalised from the + * raw `scValToNative` response. Field names use camelCase to match the + * Ethereum `OrderData` ergonomics. + */ +export interface SorobanOrderData { + id: bigint; + sender: string; + beneficiary: string; + refundAddress: string; + /** Stellar asset contract address. */ + asset: string; + /** Amount in the asset's smallest unit (stroops for XLM). */ + amount: bigint; + safetyDeposit: bigint; + /** sha256 hashlock as a 0x-prefixed hex string. */ + hashlock: `0x${string}`; + /** Absolute unix-second timestamp after which refund is valid. */ + timelock: bigint; + status: SorobanOrderStatus; + /** Revealed preimage as 0x-prefixed hex, empty string when not yet claimed. */ + preimage: `0x${string}` | ""; + createdAt: bigint; + finalisedAt: bigint; +} + export type OrderStatus = | "announced" | "src_locked" diff --git a/packages/sdk/test/soroban-order.test.ts b/packages/sdk/test/soroban-order.test.ts new file mode 100644 index 0000000..7a5d19b --- /dev/null +++ b/packages/sdk/test/soroban-order.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { parseSorobanOrder } from "../src/soroban/index.js"; +import type { SorobanOrderData } from "../src/types/index.js"; + +// Shared 32-byte buffers used across tests. +const HASHLOCK_BYTES = Buffer.from("a".repeat(64), "hex"); // 32-byte buffer +const PREIMAGE_BYTES = Buffer.from("b".repeat(64), "hex"); +const EMPTY_BYTES = Buffer.alloc(0); + +/** Minimal valid funded-order payload as scValToNative would return it. */ +function fundedRaw(overrides: Record = {}): Record { + return { + id: BigInt(1), + sender: "GABC1234", + beneficiary: "GDEF5678", + refund_address: "GHIJ9012", + asset: "CASSET0001", + amount: BigInt(5_000_000_000), + safety_deposit: BigInt(10_000_000), + hashlock: HASHLOCK_BYTES, + timelock: BigInt(1_800_000_000), + status: "Funded", + preimage: EMPTY_BYTES, + created_at: BigInt(1_700_000_000), + finalised_at: BigInt(0), + ...overrides, + }; +} + +describe("parseSorobanOrder", () => { + // ------------------------------------------------------------------ + // null / missing + // ------------------------------------------------------------------ + + it("returns null for null input", () => { + expect(parseSorobanOrder(null)).toBeNull(); + }); + + it("returns null for undefined input", () => { + expect(parseSorobanOrder(undefined)).toBeNull(); + }); + + // ------------------------------------------------------------------ + // Funded order + // ------------------------------------------------------------------ + + it("parses a funded order with all expected fields", () => { + const result = parseSorobanOrder(fundedRaw()) as SorobanOrderData; + + expect(result).not.toBeNull(); + expect(result.id).toBe(BigInt(1)); + expect(result.sender).toBe("GABC1234"); + expect(result.beneficiary).toBe("GDEF5678"); + expect(result.refundAddress).toBe("GHIJ9012"); + expect(result.asset).toBe("CASSET0001"); + expect(result.amount).toBe(BigInt(5_000_000_000)); + expect(result.safetyDeposit).toBe(BigInt(10_000_000)); + expect(result.hashlock).toBe("0x" + "a".repeat(64)); + expect(result.timelock).toBe(BigInt(1_800_000_000)); + expect(result.status).toBe("Funded"); + expect(result.preimage).toBe(""); + expect(result.createdAt).toBe(BigInt(1_700_000_000)); + expect(result.finalisedAt).toBe(BigInt(0)); + }); + + it("normalises numeric id/amount fields supplied as plain numbers", () => { + const result = parseSorobanOrder( + fundedRaw({ id: 7, amount: 1_000_000, safety_deposit: 500 }) + ) as SorobanOrderData; + + expect(result.id).toBe(BigInt(7)); + expect(result.amount).toBe(BigInt(1_000_000)); + expect(result.safetyDeposit).toBe(BigInt(500)); + }); + + // ------------------------------------------------------------------ + // Claimed order + // ------------------------------------------------------------------ + + it("parses a claimed order with a non-empty preimage", () => { + const result = parseSorobanOrder( + fundedRaw({ + status: "Claimed", + preimage: PREIMAGE_BYTES, + finalised_at: BigInt(1_700_001_000), + }) + ) as SorobanOrderData; + + expect(result.status).toBe("Claimed"); + expect(result.preimage).toBe("0x" + "b".repeat(64)); + expect(result.finalisedAt).toBe(BigInt(1_700_001_000)); + }); + + // ------------------------------------------------------------------ + // Refunded order + // ------------------------------------------------------------------ + + it("parses a refunded order", () => { + const result = parseSorobanOrder( + fundedRaw({ + status: "Refunded", + finalised_at: BigInt(1_700_002_000), + }) + ) as SorobanOrderData; + + expect(result.status).toBe("Refunded"); + expect(result.preimage).toBe(""); + expect(result.finalisedAt).toBe(BigInt(1_700_002_000)); + }); + + // ------------------------------------------------------------------ + // Malformed payloads + // ------------------------------------------------------------------ + + it("throws on a non-object value (array)", () => { + expect(() => parseSorobanOrder([])).toThrow("expected an object"); + }); + + it("throws on a non-object value (string)", () => { + expect(() => parseSorobanOrder("not-an-order")).toThrow("expected an object"); + }); + + it("throws when a required field is missing (sender)", () => { + const raw = fundedRaw(); + delete raw["sender"]; + expect(() => parseSorobanOrder(raw)).toThrow('missing required field "sender"'); + }); + + it("throws when status is an unknown value", () => { + expect(() => + parseSorobanOrder(fundedRaw({ status: "Pending" })) + ).toThrow('unknown status value "Pending"'); + }); + + it("throws when amount is not a numeric type", () => { + expect(() => + parseSorobanOrder(fundedRaw({ amount: "not-a-number" })) + ).toThrow('field "amount" is not a numeric type'); + }); + + it("throws when sender is not a string", () => { + expect(() => + parseSorobanOrder(fundedRaw({ sender: 12345 })) + ).toThrow('field "sender" is not a string'); + }); + + it("accepts string hex preimage (some SDK versions surface it as string)", () => { + const result = parseSorobanOrder( + fundedRaw({ status: "Claimed", preimage: "0x" + "c".repeat(64) }) + ) as SorobanOrderData; + + expect(result.preimage).toBe("0x" + "c".repeat(64)); + }); + + it("accepts hashlock supplied as a plain hex string without 0x prefix", () => { + const result = parseSorobanOrder( + fundedRaw({ hashlock: "a".repeat(64) }) + ) as SorobanOrderData; + + expect(result.hashlock).toBe("0x" + "a".repeat(64)); + }); +}); From d7d0fc6a64383b4e79dc53d8312b23bb912e2522 Mon Sep 17 00:00:00 2001 From: nikkybel Date: Wed, 24 Jun 2026 13:30:55 +0100 Subject: [PATCH 2/3] feat(config): add centralized env validation and deployment safeguards --- coordinator/src/config.ts | 119 ++++++++++++++------- coordinator/test/config.test.ts | 81 ++++++++++++++ docs/DEPLOYMENT.md | 63 ++++++++++- env.example | 21 +++- frontend/src/config/env.ts | 72 +++++++++++++ frontend/src/test/env-validation.test.ts | 97 +++++++++++++++++ frontend/vite.config.ts | 50 ++++++++- pnpm-lock.yaml | 8 +- relayer/jest.config.cjs | 25 +++++ relayer/package.json | 2 +- relayer/src/env-validation.ts | 112 ++++++++++++++++++++ relayer/src/index.ts | 9 ++ relayer/test/env-validation.test.ts | 111 +++++++++++++++++++ resolver/package.json | 7 +- resolver/src/config.ts | 129 ++++++++++++++--------- resolver/test/config.test.ts | 84 +++++++++++++++ resolver/vitest.config.ts | 8 ++ 17 files changed, 902 insertions(+), 96 deletions(-) create mode 100644 coordinator/test/config.test.ts create mode 100644 frontend/src/config/env.ts create mode 100644 frontend/src/test/env-validation.test.ts create mode 100644 relayer/jest.config.cjs create mode 100644 relayer/src/env-validation.ts create mode 100644 relayer/test/env-validation.test.ts create mode 100644 resolver/test/config.test.ts create mode 100644 resolver/vitest.config.ts diff --git a/coordinator/src/config.ts b/coordinator/src/config.ts index 26d5cc1..5cf1c8c 100644 --- a/coordinator/src/config.ts +++ b/coordinator/src/config.ts @@ -8,37 +8,62 @@ dotenvConfig({ path: resolve(process.cwd(), ".env") }); const networkSchema = z.enum(["testnet", "mainnet"]); export type Network = z.infer; -const configSchema = z.object({ - network: networkSchema.default("testnet"), - port: z.coerce.number().int().positive().default(3001), - databaseUrl: z.string().default("file:./oversync.db"), - logLevel: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"), - corsOrigin: z.string().default("*"), - pollIntervalMs: z.coerce.number().int().positive().default(15_000), - ethereum: z.object({ - rpcUrl: z.string().url(), - chainId: z.number().int(), - htlcEscrow: z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/) - .optional() - .or(z.literal("")) - .transform((v) => (v ? (v as `0x${string}`) : null)), - resolverRegistry: z - .string() - .regex(/^0x[0-9a-fA-F]{40}$/) - .optional() - .or(z.literal("")) - .transform((v) => (v ? (v as `0x${string}`) : null)) - }), - soroban: z.object({ - rpcUrl: z.string().url(), - horizonUrl: z.string().url(), - networkPassphrase: z.string(), - htlcContract: z.string().optional().transform((v) => v ?? null), - resolverRegistry: z.string().optional().transform((v) => v ?? null) +/** Ethereum address: 0x + 40 hex chars */ +const addressSchema = z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/, "must be a 0x-prefixed 20-byte address"); + +/** + * Coordinator configuration schema. + * + * Validation rules: + * - All RPC / Horizon URLs must be valid HTTPS (or HTTP for local dev). + * - Testnet contract IDs are recommended but not hard-required so the + * coordinator can start in read-only mode before contracts are deployed. + * - Mainnet is explicitly gated: NETWORK_MODE=mainnet requires + * MAINNET_AUDIT_CONFIRMED=true to prevent accidental production deploys. + */ +const configSchema = z + .object({ + network: networkSchema.default("testnet"), + mainnetAuditConfirmed: z.boolean().default(false), + port: z.coerce.number().int().positive().default(3001), + databaseUrl: z.string().min(1).default("file:./oversync.db"), + logLevel: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"), + corsOrigin: z.string().default("*"), + pollIntervalMs: z.coerce.number().int().positive().default(15_000), + ethereum: z.object({ + rpcUrl: z.string().url("ETH_RPC_URL must be a valid URL"), + chainId: z.number().int(), + htlcEscrow: addressSchema + .optional() + .or(z.literal("")) + .transform((v) => (v ? (v as `0x${string}`) : null)), + resolverRegistry: addressSchema + .optional() + .or(z.literal("")) + .transform((v) => (v ? (v as `0x${string}`) : null)), + }), + soroban: z.object({ + rpcUrl: z.string().url("SOROBAN_RPC_URL must be a valid URL"), + horizonUrl: z.string().url("STELLAR_HORIZON_URL must be a valid URL"), + networkPassphrase: z.string().min(1), + 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; @@ -48,6 +73,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", @@ -56,21 +82,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; } diff --git a/coordinator/test/config.test.ts b/coordinator/test/config.test.ts new file mode 100644 index 0000000..03cd551 --- /dev/null +++ b/coordinator/test/config.test.ts @@ -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(overrides: Record, fn: () => T): T { + const saved: Record = {}; + 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 = { + 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); + }); + }); +}); diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index b2386af..7552aee 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -153,10 +153,65 @@ pnpm exec hardhat verify --network sepolia < pnpm exec hardhat verify --network sepolia ``` -## 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 diff --git a/env.example b/env.example index 2d9a59d..2ed9f58 100644 --- a/env.example +++ b/env.example @@ -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. @@ -114,8 +129,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= diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts new file mode 100644 index 0000000..7680746 --- /dev/null +++ b/frontend/src/config/env.ts @@ -0,0 +1,72 @@ +/** + * 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. + */ + +type Env = Record; + +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; diff --git a/frontend/src/test/env-validation.test.ts b/frontend/src/test/env-validation.test.ts new file mode 100644 index 0000000..2d0d391 --- /dev/null +++ b/frontend/src/test/env-validation.test.ts @@ -0,0 +1,97 @@ +/** + * Tests for the Vite build-time env validation plugin logic. + * + * We extract the validation rules inline rather than importing vite.config.ts + * (which would require Vite to be fully initialised). This keeps the tests + * fast and dependency-free. + */ +import { describe, it, expect } from "vitest"; + +/** + * Mirrors the validation logic in vite.config.ts envValidationPlugin. + * Returns a list of error strings, or an empty array on success. + */ +function validateFrontendEnv(env: Record): string[] { + const errors: string[] = []; + + const apiBase = env["VITE_API_BASE_URL"]?.trim(); + if (!apiBase) { + errors.push("VITE_API_BASE_URL is required"); + } else if (!apiBase.startsWith("http://") && !apiBase.startsWith("https://")) { + errors.push(`VITE_API_BASE_URL must be a valid HTTP(S) URL (got "${apiBase}")`); + } + + const networkMode = (env["VITE_NETWORK"] ?? env["VITE_NETWORK_MODE"])?.trim(); + if (!networkMode) { + errors.push("VITE_NETWORK is required"); + } else if (networkMode !== "testnet" && networkMode !== "mainnet") { + errors.push(`VITE_NETWORK must be 'testnet' or 'mainnet' (got "${networkMode}")`); + } + + const mainnetEnabled = env["VITE_MAINNET_ENABLED"] === "true"; + const auditConfirmed = env["VITE_MAINNET_AUDIT_CONFIRMED"] === "true"; + if (mainnetEnabled && !auditConfirmed) { + errors.push( + "VITE_MAINNET_ENABLED=true requires VITE_MAINNET_AUDIT_CONFIRMED=true" + ); + } + + return errors; +} + +const VALID_BASE = { + VITE_API_BASE_URL: "http://localhost:3001", + VITE_NETWORK: "testnet", + VITE_MAINNET_ENABLED: "false", + VITE_MAINNET_AUDIT_CONFIRMED: "false", +}; + +describe("frontend build-time env validation", () => { + it("passes with a valid testnet config", () => { + expect(validateFrontendEnv(VALID_BASE)).toHaveLength(0); + }); + + it("fails when VITE_API_BASE_URL is missing", () => { + const errors = validateFrontendEnv({ ...VALID_BASE, VITE_API_BASE_URL: undefined }); + expect(errors.some((e) => e.includes("VITE_API_BASE_URL"))).toBe(true); + }); + + it("fails when VITE_API_BASE_URL is not a URL", () => { + const errors = validateFrontendEnv({ ...VALID_BASE, VITE_API_BASE_URL: "localhost:3001" }); + expect(errors.some((e) => e.includes("HTTP(S)"))).toBe(true); + }); + + it("fails when VITE_NETWORK is missing", () => { + const errors = validateFrontendEnv({ + ...VALID_BASE, + VITE_NETWORK: undefined, + VITE_NETWORK_MODE: undefined, + }); + expect(errors.some((e) => e.includes("VITE_NETWORK"))).toBe(true); + }); + + it("fails when VITE_NETWORK has an invalid value", () => { + const errors = validateFrontendEnv({ ...VALID_BASE, VITE_NETWORK: "localhost" }); + expect(errors.some((e) => e.includes("testnet"))).toBe(true); + }); + + it("fails when VITE_MAINNET_ENABLED=true without VITE_MAINNET_AUDIT_CONFIRMED=true", () => { + const errors = validateFrontendEnv({ + ...VALID_BASE, + VITE_NETWORK: "mainnet", + VITE_MAINNET_ENABLED: "true", + VITE_MAINNET_AUDIT_CONFIRMED: "false", + }); + expect(errors.some((e) => e.includes("VITE_MAINNET_AUDIT_CONFIRMED"))).toBe(true); + }); + + it("passes when both mainnet flags are true", () => { + const errors = validateFrontendEnv({ + ...VALID_BASE, + VITE_NETWORK: "mainnet", + VITE_MAINNET_ENABLED: "true", + VITE_MAINNET_AUDIT_CONFIRMED: "true", + }); + expect(errors).toHaveLength(0); + }); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 43bbcd9..4c40175 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,53 @@ -import { defineConfig, loadEnv } from 'vite' +import { defineConfig, loadEnv, type Plugin } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' +/** + * Build-time env validation plugin. + * Runs during `vite build` (and on dev-server start) so misconfigured + * testnet/mainnet deployments fail before any assets are compiled. + */ +function envValidationPlugin(env: Record): Plugin { + return { + name: 'oversync-env-validation', + // Only validate during actual builds, not during vitest / dev server. + apply: 'build', + buildStart() { + const errors: string[] = []; + + const apiBase = env['VITE_API_BASE_URL']?.trim(); + 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}")`); + } + + const networkMode = (env['VITE_NETWORK'] ?? env['VITE_NETWORK_MODE'])?.trim(); + 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}")`); + } + + const mainnetEnabled = env['VITE_MAINNET_ENABLED'] === 'true'; + const auditConfirmed = env['VITE_MAINNET_AUDIT_CONFIRMED'] === 'true'; + if (mainnetEnabled && !auditConfirmed) { + errors.push( + 'VITE_MAINNET_ENABLED=true requires VITE_MAINNET_AUDIT_CONFIRMED=true. ' + + 'Complete docs/DEPLOYMENT.md#mainnet-rollout-checklist first.' + ); + } + + if (errors.length > 0) { + throw new Error( + 'Frontend build aborted — fix these env vars:\n' + + errors.map((e) => ` - ${e}`).join('\n') + ); + } + }, + }; +} + // https://vitejs.dev/config/ export default defineConfig(({ mode }) => { // Load env file based on `mode` in the current working directory. @@ -10,7 +56,7 @@ export default defineConfig(({ mode }) => { const isProduction = mode === 'production'; return { - plugins: [react()], + plugins: [react(), envValidationPlugin(env)], resolve: { alias: { '@': path.resolve(__dirname, './src'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbbfe2e..33a62c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -345,6 +345,9 @@ importers: viem: specifier: ^2.21.0 version: 2.31.7(typescript@5.8.3)(zod@3.25.76) + zod: + specifier: ^3.23.8 + version: 3.25.76 devDependencies: '@types/node': specifier: ^20.12.0 @@ -355,6 +358,9 @@ importers: typescript: specifier: ^5.6.0 version: 5.8.3 + vitest: + specifier: ^2.1.0 + version: 2.1.9(@types/node@20.19.7) stellar: dependencies: @@ -3854,7 +3860,7 @@ packages: '@vitest/spy': 2.1.9 estree-walker: 3.0.3 magic-string: 0.30.17 - vite: 5.4.19(@types/node@22.19.19) + vite: 5.4.19(@types/node@20.19.7) dev: true /@vitest/pretty-format@2.1.9: diff --git a/relayer/jest.config.cjs b/relayer/jest.config.cjs new file mode 100644 index 0000000..b82e0bc --- /dev/null +++ b/relayer/jest.config.cjs @@ -0,0 +1,25 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + testMatch: ['**/test/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + module: 'ESNext', + moduleResolution: 'node', + types: ['jest', 'node'], + esModuleInterop: true, + strict: false, + }, + }, + ], + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + extensionsToTreatAsEsm: ['.ts'], +}; diff --git a/relayer/package.json b/relayer/package.json index 9f71510..cf9e8f5 100644 --- a/relayer/package.json +++ b/relayer/package.json @@ -8,7 +8,7 @@ "start": "node dist/index.js", "dev": "tsx watch src/index.ts", "build": "tsc", - "test": "jest", + "test": "jest --config jest.config.cjs", "lint": "eslint src --ext .ts", "clean": "rm -rf dist" }, diff --git a/relayer/src/env-validation.ts b/relayer/src/env-validation.ts new file mode 100644 index 0000000..c98332b --- /dev/null +++ b/relayer/src/env-validation.ts @@ -0,0 +1,112 @@ +/** + * Strict environment validation for the relayer service. + * + * Called once at startup — any missing or malformed variable causes a + * descriptive fatal error instead of silently using placeholder values + * that produce wrong behaviour at runtime. + */ + +function requireEnv(errors: string[], name: string, description: string): string { + const v = process.env[name]; + if (!v || v.trim() === "" || v.includes("YOUR_") || v.includes("SAMPLE")) { + errors.push(` ${name}: ${description}`); + return ""; + } + return v.trim(); +} + +function requireEthPrivateKey(errors: string[], name: string, description: string): string { + const v = requireEnv(errors, name, description); + if (v && !/^0x[0-9a-fA-F]{64}$/.test(v)) { + errors.push(` ${name}: must be a 0x-prefixed 32-byte private key`); + } + return v; +} + +function requirePositiveInt(errors: string[], name: string, description: string, defaultVal: number): number { + const raw = process.env[name]; + if (!raw) return defaultVal; + const n = Number(raw); + if (!Number.isInteger(n) || n <= 0) { + errors.push(` ${name}: must be a positive integer (got "${raw}")`); + return defaultVal; + } + return n; +} + +export interface ValidatedRelayerEnv { + networkMode: "testnet" | "mainnet"; + mainnetAuditConfirmed: boolean; + ethereumRpcUrl: string; + relayerPrivateKey: string; + stellarSecret: string; + stellarPublicKey: string; + rpcTimeoutMs: number; + port: number; +} + +/** + * Parse and validate relayer environment variables. + * Throws a single error listing every problem found. + */ +export function validateRelayerEnv(): ValidatedRelayerEnv { + const errors: string[] = []; + + const networkMode = (process.env.NETWORK_MODE ?? "testnet") as "testnet" | "mainnet"; + if (networkMode !== "testnet" && networkMode !== "mainnet") { + errors.push(` NETWORK_MODE: must be 'testnet' or 'mainnet' (got "${networkMode}")`); + } + + const mainnetAuditConfirmed = process.env.MAINNET_AUDIT_CONFIRMED === "true"; + if (networkMode === "mainnet" && !mainnetAuditConfirmed) { + errors.push( + " MAINNET_AUDIT_CONFIRMED: must be 'true' when NETWORK_MODE=mainnet. " + + "Read docs/DEPLOYMENT.md#mainnet-rollout-checklist before enabling." + ); + } + + // RPC URL — accept explicit var first, then fall back to Infura if key is set. + const explicitRpc = + networkMode === "mainnet" + ? process.env.MAINNET_RPC_URL + : process.env.SEPOLIA_RPC_URL ?? process.env.ETHEREUM_RPC_URL; + const infuraKey = process.env.INFURA_API_KEY; + const ethereumRpcUrl = + explicitRpc?.trim() || + (infuraKey + ? networkMode === "mainnet" + ? `https://mainnet.infura.io/v3/${infuraKey}` + : `https://sepolia.infura.io/v3/${infuraKey}` + : ""); + + if (!ethereumRpcUrl) { + errors.push( + " SEPOLIA_RPC_URL / MAINNET_RPC_URL / INFURA_API_KEY: at least one Ethereum RPC source is required" + ); + } else if (!ethereumRpcUrl.startsWith("http://") && !ethereumRpcUrl.startsWith("https://")) { + errors.push(` Ethereum RPC URL: must be a valid HTTP(S) URL (got "${ethereumRpcUrl}")`); + } + + const relayerPrivateKey = requireEthPrivateKey(errors, "RELAYER_PRIVATE_KEY", "required to sign ETH release transactions"); + const stellarSecret = requireEnv(errors, "RELAYER_STELLAR_SECRET", "required to sign Stellar payment transactions"); + const stellarPublicKey = requireEnv(errors, "RELAYER_STELLAR_PUBLIC", "required to monitor incoming Stellar payments"); + const rpcTimeoutMs = requirePositiveInt(errors, "RELAYER_RPC_TIMEOUT_MS", "must be a positive integer (milliseconds)", 30_000); + const port = requirePositiveInt(errors, "RELAYER_PORT", "must be a positive integer", 3001); + + if (errors.length > 0) { + throw new Error( + `Relayer configuration invalid — fix these env vars before starting:\n${errors.join("\n")}` + ); + } + + return { + networkMode, + mainnetAuditConfirmed, + ethereumRpcUrl, + relayerPrivateKey, + stellarSecret, + stellarPublicKey, + rpcTimeoutMs, + port, + }; +} diff --git a/relayer/src/index.ts b/relayer/src/index.ts index e5668f5..7f3422d 100644 --- a/relayer/src/index.ts +++ b/relayer/src/index.ts @@ -31,6 +31,15 @@ import { resolveEthereumRpcUrl } from './ethereum-rpc-url.js'; // Load environment variables from root directory config({ path: resolve(process.cwd(), '../.env') }); +// ── Strict env validation — fail fast before any service code runs ── +import { validateRelayerEnv } from './env-validation.js'; +try { + validateRelayerEnv(); +} catch (err) { + console.error((err as Error).message); + process.exit(1); +} + // ✅ NETWORK-AWARE Dynamic Safety Deposit Helper Function function calculateDynamicSafetyDeposit(amountInWei: string | bigint, networkMode?: string): bigint { const ETH_USD_PRICE = 3500; // $3500 per ETH diff --git a/relayer/test/env-validation.test.ts b/relayer/test/env-validation.test.ts new file mode 100644 index 0000000..b14a427 --- /dev/null +++ b/relayer/test/env-validation.test.ts @@ -0,0 +1,111 @@ +/** + * Smoke tests for relayer env validation. + * Uses Jest (already in devDependencies). + */ +import { validateRelayerEnv } from "../src/env-validation.js"; + +function withEnv(overrides: Record, fn: () => T): T { + const saved: Record = {}; + 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; + } + } +} + +const VALID_PRIVATE_KEY = "0x" + "b".repeat(64); +const VALID_STELLAR_SECRET = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3"; +const VALID_STELLAR_PUBLIC = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const BASE: Record = { + NETWORK_MODE: "testnet", + MAINNET_AUDIT_CONFIRMED: "false", + SEPOLIA_RPC_URL: "https://sepolia.infura.io/v3/testkey", + MAINNET_RPC_URL: undefined, + INFURA_API_KEY: undefined, + RELAYER_PRIVATE_KEY: VALID_PRIVATE_KEY, + RELAYER_STELLAR_SECRET: VALID_STELLAR_SECRET, + RELAYER_STELLAR_PUBLIC: VALID_STELLAR_PUBLIC, +}; + +describe("validateRelayerEnv()", () => { + it("succeeds with a minimal valid testnet environment", () => { + withEnv(BASE, () => { + expect(() => validateRelayerEnv()).not.toThrow(); + const env = validateRelayerEnv(); + expect(env.networkMode).toBe("testnet"); + }); + }); + + it("throws when RELAYER_PRIVATE_KEY is missing", () => { + withEnv({ ...BASE, RELAYER_PRIVATE_KEY: undefined }, () => { + expect(() => validateRelayerEnv()).toThrow(/RELAYER_PRIVATE_KEY/); + }); + }); + + it("throws when RELAYER_PRIVATE_KEY has wrong format", () => { + withEnv({ ...BASE, RELAYER_PRIVATE_KEY: "0xshort" }, () => { + expect(() => validateRelayerEnv()).toThrow(/RELAYER_PRIVATE_KEY/); + }); + }); + + it("throws when RELAYER_STELLAR_SECRET is missing", () => { + withEnv({ ...BASE, RELAYER_STELLAR_SECRET: undefined }, () => { + expect(() => validateRelayerEnv()).toThrow(/RELAYER_STELLAR_SECRET/); + }); + }); + + it("throws when no Ethereum RPC source is available", () => { + withEnv( + { + ...BASE, + SEPOLIA_RPC_URL: undefined, + MAINNET_RPC_URL: undefined, + INFURA_API_KEY: undefined, + }, + () => { + expect(() => validateRelayerEnv()).toThrow(/RPC/); + } + ); + }); + + it("throws when NETWORK_MODE=mainnet without MAINNET_AUDIT_CONFIRMED=true", () => { + withEnv( + { + ...BASE, + NETWORK_MODE: "mainnet", + MAINNET_AUDIT_CONFIRMED: "false", + MAINNET_RPC_URL: "https://mainnet.infura.io/v3/testkey", + SEPOLIA_RPC_URL: undefined, + }, + () => { + expect(() => validateRelayerEnv()).toThrow(/MAINNET_AUDIT_CONFIRMED/); + } + ); + }); + + it("accepts 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 env = validateRelayerEnv(); + expect(env.networkMode).toBe("mainnet"); + expect(env.mainnetAuditConfirmed).toBe(true); + } + ); + }); +}); diff --git a/resolver/package.json b/resolver/package.json index 7235f81..8ca4a3a 100644 --- a/resolver/package.json +++ b/resolver/package.json @@ -12,6 +12,7 @@ "build": "tsc", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", + "test": "vitest run", "lint": "eslint src --ext .ts", "clean": "rm -rf dist" }, @@ -20,11 +21,13 @@ "commander": "^12.1.0", "dotenv": "^16.4.5", "pino": "^9.5.0", - "viem": "^2.21.0" + "viem": "^2.21.0", + "zod": "^3.23.8" }, "devDependencies": { "@types/node": "^20.12.0", "tsx": "^4.19.0", - "typescript": "^5.6.0" + "typescript": "^5.6.0", + "vitest": "^2.1.0" } } diff --git a/resolver/src/config.ts b/resolver/src/config.ts index cc7cc17..ca96906 100644 --- a/resolver/src/config.ts +++ b/resolver/src/config.ts @@ -1,77 +1,103 @@ import { config as dotenvConfig } from "dotenv"; import { resolve } from "node:path"; +import { z } from "zod"; +import { resolveEthereumRpcUrl } from "./ethereum-rpc-url.js"; dotenvConfig({ path: resolve(process.cwd(), ".env") }); export type Network = "testnet" | "mainnet"; -export interface EthereumConfig { - rpcUrl: string; - chainId: number; - htlcEscrow: `0x${string}` | null; - resolverRegistry: `0x${string}` | null; - resolverPrivateKey: `0x${string}` | null; -} +/** 0x-prefixed 20-byte Ethereum address */ +const addressSchema = z + .string() + .regex(/^0x[0-9a-fA-F]{40}$/, "must be a 0x-prefixed 20-byte address") + .transform((v) => v as `0x${string}`); -export interface SorobanConfig { - rpcUrl: string; - networkPassphrase: string; - horizonUrl: string; - htlc: string | null; - resolverRegistry: string | null; - resolverSecret: string | null; -} +/** 0x-prefixed 32-byte Ethereum private key */ +const privateKeySchema = z + .string() + .regex(/^0x[0-9a-fA-F]{64}$/, "must be a 0x-prefixed 32-byte private key") + .transform((v) => v as `0x${string}`); -export interface ResolverConfig { - network: Network; - pollIntervalMs: number; - coordinatorUrl: string; - logLevel: "trace" | "debug" | "info" | "warn" | "error"; - ethereum: EthereumConfig; - soroban: SorobanConfig; -} +const resolverConfigSchema = z + .object({ + network: z.enum(["testnet", "mainnet"]).default("testnet"), + mainnetAuditConfirmed: z.boolean().default(false), + pollIntervalMs: z.coerce.number().int().positive().default(15_000), + coordinatorUrl: z.string().url("COORDINATOR_URL must be a valid URL").default("http://localhost:3001"), + logLevel: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"), + ethereum: z.object({ + rpcUrl: z.string().url("Ethereum RPC URL must be a valid URL"), + chainId: z.number().int(), + htlcEscrow: addressSchema.nullable().default(null), + resolverRegistry: addressSchema.nullable().default(null), + resolverPrivateKey: privateKeySchema.nullable().default(null), + }), + soroban: z.object({ + rpcUrl: z.string().url("SOROBAN_RPC_URL must be a valid URL"), + horizonUrl: z.string().url("STELLAR_HORIZON_URL must be a valid URL"), + networkPassphrase: z.string().min(1), + htlc: z.string().nullable().default(null), + resolverRegistry: z.string().nullable().default(null), + resolverSecret: z.string().nullable().default(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.", + }); + } + // Running mode requires a private key. + if (!cfg.ethereum.resolverPrivateKey) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ethereum", "resolverPrivateKey"], + message: + "RESOLVER_ETH_PRIVATE_KEY is required. " + + "The resolver must be able to sign claim/refund transactions.", + }); + } + }); -import { resolveEthereumRpcUrl } from "./ethereum-rpc-url.js"; +export type ResolverConfig = z.infer; +// Re-export sub-shapes for callers that type-annotate them. +export type EthereumConfig = ResolverConfig["ethereum"]; +export type SorobanConfig = ResolverConfig["soroban"]; -function requireEnv(name: string): string { - const v = process.env[name]; - if (!v) { - throw new Error(`Missing required env var: ${name}`); - } - return v; -} - -function optionalAddress(name: string): `0x${string}` | null { +function optionalAddress(name: string): string | null { const v = process.env[name]; if (!v) return null; if (!/^0x[0-9a-fA-F]{40}$/.test(v)) { throw new Error(`${name} is not a 0x-prefixed 20-byte address`); } - return v as `0x${string}`; + return v; } export function loadConfig(): ResolverConfig { const network = (process.env.NETWORK_MODE ?? "testnet") as Network; - if (network !== "testnet" && network !== "mainnet") { - throw new Error(`NETWORK_MODE must be 'testnet' or 'mainnet', got: ${network}`); - } - const isMainnet = network === "mainnet"; - return { + const raw = { network, - pollIntervalMs: Number(process.env.RESOLVER_POLL_INTERVAL_MS ?? 15_000), - coordinatorUrl: process.env.COORDINATOR_URL ?? "http://localhost:3001", - logLevel: (process.env.LOG_LEVEL as ResolverConfig["logLevel"]) ?? "info", + mainnetAuditConfirmed: process.env.MAINNET_AUDIT_CONFIRMED === "true", + pollIntervalMs: process.env.RESOLVER_POLL_INTERVAL_MS, + coordinatorUrl: process.env.COORDINATOR_URL, + logLevel: process.env.LOG_LEVEL, ethereum: { rpcUrl: resolveEthereumRpcUrl(isMainnet ? "mainnet" : "testnet"), chainId: isMainnet ? 1 : 11_155_111, - htlcEscrow: optionalAddress(isMainnet ? "ETH_HTLC_ESCROW_MAINNET" : "ETH_HTLC_ESCROW_TESTNET"), + htlcEscrow: + optionalAddress(isMainnet ? "ETH_HTLC_ESCROW_MAINNET" : "ETH_HTLC_ESCROW_TESTNET"), resolverRegistry: optionalAddress( isMainnet ? "ETH_RESOLVER_REGISTRY_MAINNET" : "ETH_RESOLVER_REGISTRY_TESTNET" ), - resolverPrivateKey: - (process.env.RESOLVER_ETH_PRIVATE_KEY as `0x${string}` | undefined) ?? null + resolverPrivateKey: process.env.RESOLVER_ETH_PRIVATE_KEY ?? null, }, soroban: { rpcUrl: @@ -88,7 +114,16 @@ export function loadConfig(): ResolverConfig { process.env[ isMainnet ? "SOROBAN_RESOLVER_REGISTRY_MAINNET" : "SOROBAN_RESOLVER_REGISTRY_TESTNET" ] ?? null, - resolverSecret: process.env.RESOLVER_STELLAR_SECRET ?? null - } + resolverSecret: process.env.RESOLVER_STELLAR_SECRET ?? null, + }, }; + + const result = resolverConfigSchema.safeParse(raw); + if (!result.success) { + const messages = result.error.issues + .map((i) => ` [${i.path.join(".")}] ${i.message}`) + .join("\n"); + throw new Error(`Resolver configuration invalid:\n${messages}`); + } + return result.data; } diff --git a/resolver/test/config.test.ts b/resolver/test/config.test.ts new file mode 100644 index 0000000..2b0801f --- /dev/null +++ b/resolver/test/config.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { loadConfig } from "../src/config.js"; + +function withEnv(overrides: Record, fn: () => T): T { + const saved: Record = {}; + 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; + } + } +} + +const VALID_KEY = "0x" + "a".repeat(64); + +const BASE: Record = { + NETWORK_MODE: "testnet", + MAINNET_AUDIT_CONFIRMED: "false", + RESOLVER_ETH_PRIVATE_KEY: VALID_KEY, + 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("resolver loadConfig() — env validation", () => { + it("parses a valid testnet config without throwing", () => { + withEnv(BASE, () => { + const cfg = loadConfig(); + expect(cfg.network).toBe("testnet"); + expect(cfg.ethereum.resolverPrivateKey).toBe(VALID_KEY); + }); + }); + + it("throws when RESOLVER_ETH_PRIVATE_KEY is missing", () => { + withEnv({ ...BASE, RESOLVER_ETH_PRIVATE_KEY: undefined }, () => { + expect(() => loadConfig()).toThrow(/RESOLVER_ETH_PRIVATE_KEY/); + }); + }); + + it("throws when RESOLVER_ETH_PRIVATE_KEY has wrong format", () => { + withEnv({ ...BASE, RESOLVER_ETH_PRIVATE_KEY: "not-a-key" }, () => { + expect(() => loadConfig()).toThrow(/configuration invalid/i); + }); + }); + + it("throws when NETWORK_MODE=mainnet without MAINNET_AUDIT_CONFIRMED=true", () => { + 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 mainnet when MAINNET_AUDIT_CONFIRMED=true and key is provided", () => { + 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"); + } + ); + }); +}); diff --git a/resolver/vitest.config.ts b/resolver/vitest.config.ts new file mode 100644 index 0000000..ed8bf77 --- /dev/null +++ b/resolver/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["test/**/*.test.ts"], + }, +}); From 6c00c6770d817e80d7f9f73bd457dda07b02b91a Mon Sep 17 00:00:00 2001 From: nikkybel Date: Fri, 26 Jun 2026 11:44:41 +0100 Subject: [PATCH 3/3] chore(frontend): remove unused Env type alias from env.ts --- frontend/src/config/env.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/config/env.ts b/frontend/src/config/env.ts index 7680746..04d9676 100644 --- a/frontend/src/config/env.ts +++ b/frontend/src/config/env.ts @@ -11,8 +11,6 @@ * to prevent accidental production deployments. */ -type Env = Record; - function e(key: string): string | undefined { return (import.meta as any).env?.[key]?.trim() || undefined; }