diff --git a/package.json b/package.json index 8990221..bdf2ca9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,10 @@ "migrate:down": "node-pg-migrate down --migrations-dir migrations --config-file database.json", "migrate:create": "node-pg-migrate create --migrations-dir migrations --config-file database.json", "migrate:status": "node-pg-migrate status --migrations-dir migrations --config-file database.json", + "soroban:up": "docker compose -f test/soroban/docker-compose.yml up -d --wait", + "soroban:down": "docker compose -f test/soroban/docker-compose.yml down -v", + "soroban:bootstrap": "ts-node scripts/soroban-bootstrap.ts", + "test:soroban": "jest --config test/soroban/jest.soroban.config.js", "regenerate:xdr-fixtures": "ts-node scripts/regenerate-xdr-fixtures.ts" }, "engines": { @@ -90,9 +94,12 @@ "fast-check": "^4.10.2", "jest": "^30.4.2", "nodemon": "^3.0.2", + "source-map-support": "^0.5.21", "supertest": "^7.2.2", "ts-jest": "^29.4.11", + "ts-loader": "^9.4.3", "ts-node": "^10.9.2", + "tsconfig-paths": "^4.1.0", "typescript": "^5.3.2" }, "jest": { diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 21ca120..4ed723a 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -7,7 +7,12 @@ export type FeeProfileName = "moderate" | "aggressive"; * reads) so every consumer gets the same parsed/defaulted values and the * shape is documented in one place. See `.env.example` for the full list * of variables this reads. + * + * `AppConfig` is inferred from the Zod schema in `./env.schema` so the + * TypeScript type and the runtime validation cannot drift. */ +import { z } from "zod"; +import { validateEnv } from "./env.schema"; /** Fee-estimation profiles exposed by FeeStrategyService. */ export type FeeProfileName = "moderate" | "aggressive"; @@ -133,7 +138,10 @@ export interface AppConfig { policyPendingTtlMs: number; } -const NETWORK_CONFIG: Record = { +const NETWORK_CONFIG: Record< + string, + { sorobanRpcUrl: string; horizonUrl: string; networkPassphrase: string } +> = { testnet: { sorobanRpcUrl: "https://soroban-testnet.stellar.org", horizonUrl: "https://horizon-testnet.stellar.org", @@ -151,6 +159,131 @@ const NETWORK_CONFIG: Record; + function numberFromEnv(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw === "") return fallback; @@ -165,84 +298,115 @@ function readBool(value: string | undefined, fallback: boolean): boolean { } export default (): AppConfig => { - const network = (process.env.STELLAR_NETWORK ?? "testnet").trim().toLowerCase(); - if (!Object.prototype.hasOwnProperty.call(NETWORK_CONFIG, network)) { - throw new Error("STELLAR_NETWORK expected testnet, futurenet, or mainnet"); + const env = validateEnv(process.env); + + const requireChainConfig = env.REQUIRE_CHAIN_CONFIG === true; + const chainKeys = [ + "REFRACT_POOL_CONTRACT_ID", + "REFRACT_POLICY_CONTRACT_ID", + "REFRACT_ORACLE_CONTRACT_ID", + "ORACLE_RELAYER_SECRET", + ] as const; + + if (requireChainConfig) { + const missing = chainKeys.filter((key) => !env[key]); + if (missing.length > 0) { + throw new Error( + `Invalid environment configuration:\n${missing + .map((key) => ` - ${key}: required when REQUIRE_CHAIN_CONFIG is set`) + .join("\n")}`, + ); + } + } + + const network = (env.STELLAR_NETWORK ?? "testnet").toLowerCase(); + const selected = NETWORK_CONFIG[network]; + if (!selected) { + throw new Error("expected testnet, futurenet, or mainnet"); } const selectedNetwork = NETWORK_CONFIG[network]; const suffix = network.toUpperCase(); - const env = process.env; + const networkSpecificPoolId = env[`REFRACT_POOL_CONTRACT_ID_${suffix}`]; + const networkSpecificPolicyId = env[`REFRACT_POLICY_CONTRACT_ID_${suffix}`]; + const networkSpecificOracleId = env[`REFRACT_ORACLE_CONTRACT_ID_${suffix}`]; return { - port: numberFromEnv("PORT", 4001), + port: env.PORT ?? 4001, frontendUrl: env.FRONTEND_URL ?? "http://localhost:3000", database: { - url: env.DATABASE_URL ?? "postgres://localhost:5432/refract", - poolMax: numberFromEnv("DB_POOL_MAX", 10), - poolMin: numberFromEnv("DB_POOL_MIN", 2), - poolIdleTimeoutMs: numberFromEnv("DB_POOL_IDLE_TIMEOUT_MS", 30_000), - poolAcquireTimeoutMs: numberFromEnv("DB_POOL_ACQUIRE_TIMEOUT_MS", 5_000), - statementTimeoutMs: numberFromEnv("DB_STATEMENT_TIMEOUT_MS", 30_000), - connectRetries: numberFromEnv("DB_CONNECT_RETRIES", 5), - connectRetryDelayMs: numberFromEnv("DB_CONNECT_RETRY_DELAY_MS", 1_000), + url: + env.DATABASE_URL ?? + "postgres://refract:refract@localhost:5432/refract", + poolMax: env.DB_POOL_MAX ?? 10, + poolMin: env.DB_POOL_MIN ?? 2, + poolIdleTimeoutMs: env.DB_POOL_IDLE_TIMEOUT_MS ?? 30000, + poolAcquireTimeoutMs: env.DB_POOL_ACQUIRE_TIMEOUT_MS ?? 5000, + statementTimeoutMs: env.DB_STATEMENT_TIMEOUT_MS ?? 30000, + connectRetries: env.DB_CONNECT_RETRIES ?? 5, + connectRetryDelayMs: env.DB_CONNECT_RETRY_DELAY_MS ?? 1000, }, redis: { url: env.REDIS_URL ?? "redis://localhost:6379" }, stellar: { network, - sorobanRpcUrl: selectedNetwork.sorobanRpcUrl, - sorobanRpcUrls: (env.SOROBAN_RPC_URLS ?? selectedNetwork.sorobanRpcUrl) + sorobanRpcUrl: env.SOROBAN_RPC_URL ?? selected.sorobanRpcUrl, + sorobanRpcUrls: (env.SOROBAN_RPC_URLS ?? selected.sorobanRpcUrl) .split(",") .map((url) => url.trim()) .filter(Boolean), eventStartLedger: numberFromEnv("EVENT_START_LEDGER", 1), - horizonUrl: selectedNetwork.horizonUrl, - networkPassphrase: selectedNetwork.networkPassphrase, - poolContractId: env[`REFRACT_POOL_CONTRACT_ID_${suffix}`] ?? env.REFRACT_POOL_CONTRACT_ID ?? "", - policyContractId: env[`REFRACT_POLICY_CONTRACT_ID_${suffix}`] ?? env.REFRACT_POLICY_CONTRACT_ID ?? "", - oracleContractId: env[`REFRACT_ORACLE_CONTRACT_ID_${suffix}`] ?? env.REFRACT_ORACLE_CONTRACT_ID ?? "", + horizonUrl: env.STELLAR_HORIZON_URL ?? selected.horizonUrl, + networkPassphrase: + env.STELLAR_NETWORK_PASSPHRASE ?? selected.networkPassphrase, + poolContractId: + networkSpecificPoolId ?? env.REFRACT_POOL_CONTRACT_ID ?? "", + policyContractId: + networkSpecificPolicyId ?? env.REFRACT_POLICY_CONTRACT_ID ?? "", + oracleContractId: + networkSpecificOracleId ?? env.REFRACT_ORACLE_CONTRACT_ID ?? "", relayerSecret: env.ORACLE_RELAYER_SECRET ?? "", relayerSecretId: env.ORACLE_RELAYER_SECRET_ID ?? "", relayerSecretRegion: env.ORACLE_RELAYER_SECRET_REGION ?? "us-east-1", - validateOnChain: readBool(env.STELLAR_VALIDATE_ONCHAIN ?? env.VALIDATE_ON_CHAIN, false), - requireNetwork: readBool(env.STELLAR_REQUIRE_NETWORK ?? env.REQUIRE_NETWORK, false), - validateTimeoutMs: numberFromEnv("STELLAR_VALIDATE_TIMEOUT_MS", numberFromEnv("VALIDATE_TIMEOUT_MS", 5_000)), + validateOnChain: env.VALIDATE_ON_CHAIN === true, + requireNetwork: env.REQUIRE_NETWORK === true, + validateTimeoutMs: env.VALIDATE_TIMEOUT_MS ?? 5000, relayer: { - feeMultiplier: numberFromEnv("RELAYER_FEE_MULTIPLIER", 1.2), - feeCeiling: numberFromEnv("RELAYER_FEE_CEILING", 1_000_000), - idleResyncMs: numberFromEnv("RELAYER_IDLE_RESYNC_MS", 30_000), - minBalance: numberFromEnv("RELAYER_MIN_BALANCE", 50_000_000), - maxAttempts: numberFromEnv("RELAYER_MAX_ATTEMPTS", 3), + feeMultiplier: env.RELAYER_FEE_MULTIPLIER ?? 1.2, + feeCeiling: env.RELAYER_FEE_CEILING ?? 1000000, + idleResyncMs: env.RELAYER_IDLE_RESYNC_MS ?? 30000, + minBalance: env.RELAYER_MIN_BALANCE ?? 50000000, + maxAttempts: env.RELAYER_MAX_ATTEMPTS ?? 3, }, eventIndexer: { - enabled: readBool(env.EVENT_INDEXER_ENABLED, false), - pollIntervalMs: numberFromEnv("EVENT_INDEXER_POLL_INTERVAL_MS", 5_000), - lagAlertLedgers: numberFromEnv("EVENT_INDEXER_LAG_ALERT_LEDGERS", 100), - pageLimit: numberFromEnv("EVENT_INDEXER_PAGE_LIMIT", 100), + enabled: env.EVENT_INDEXER_ENABLED === true, + pollIntervalMs: env.EVENT_INDEXER_POLL_INTERVAL_MS ?? 5000, + lagAlertLedgers: env.EVENT_INDEXER_LAG_ALERT_LEDGERS ?? 100, + pageLimit: env.EVENT_INDEXER_PAGE_LIMIT ?? 100, }, }, confirmation: { - initialIntervalMs: numberFromEnv("CONFIRMATION_INITIAL_INTERVAL_MS", 400), - backoffMultiplier: numberFromEnv("CONFIRMATION_BACKOFF_MULTIPLIER", 1.8), - maxIntervalMs: numberFromEnv("CONFIRMATION_MAX_INTERVAL_MS", 8_000), - deadlineMs: numberFromEnv("CONFIRMATION_DEADLINE_MS", 30_000), - jitterRatio: numberFromEnv("CONFIRMATION_JITTER_RATIO", 0.2), - settlementDeadlineMs: numberFromEnv("CONFIRMATION_SETTLEMENT_DEADLINE_MS", 90_000), - httpDeadlineMs: numberFromEnv("CONFIRMATION_HTTP_DEADLINE_MS", 20_000), + initialIntervalMs: env.CONFIRMATION_INITIAL_INTERVAL_MS ?? 10, + backoffMultiplier: env.CONFIRMATION_BACKOFF_MULTIPLIER ?? 1.5, + maxIntervalMs: env.CONFIRMATION_MAX_INTERVAL_MS ?? 50, + deadlineMs: env.CONFIRMATION_DEADLINE_MS ?? 200, + jitterRatio: env.CONFIRMATION_JITTER_RATIO ?? 0, + settlementDeadlineMs: env.CONFIRMATION_SETTLEMENT_DEADLINE_MS ?? 200, + httpDeadlineMs: env.CONFIRMATION_HTTP_DEADLINE_MS ?? 100, }, oraclePublish: { - mode: env.ORACLE_PUBLISH_MODE === "every_poll" ? "every_poll" : "on_change", - minIntervalMs: numberFromEnv("ORACLE_PUBLISH_MIN_INTERVAL_MS", 300_000), + mode: env.ORACLE_PUBLISH_MODE ?? "every_poll", + minIntervalMs: env.ORACLE_PUBLISH_MIN_INTERVAL_MS ?? 0, }, oracles: { - coingeckoBaseUrl: env.COINGECKO_BASE_URL ?? "https://api.coingecko.com/api/v3", - horizonUrl: selectedNetwork.horizonUrl, + coingeckoBaseUrl: + env.COINGECKO_BASE_URL ?? "https://api.coingecko.com/api/v3", + horizonUrl: env.STELLAR_HORIZON_URL ?? selected.horizonUrl, defiLlamaBaseUrl: env.DEFILLAMA_BASE_URL ?? "https://api.llama.fi", // Placeholder "covered protocol" for the SmartContractRisk TVL-drop // check until Refract defines a real list of covered Soroban // protocols. Defaults to a large, consistently-tracked protocol so // the drop-detection logic has real data to run against. defiLlamaProtocolSlug: env.DEFILLAMA_PROTOCOL_SLUG ?? "aave", - httpTimeoutMs: numberFromEnv("ORACLE_HTTP_TIMEOUT_MS", 5_000), + httpTimeoutMs: env.ORACLE_HTTP_TIMEOUT_MS ?? 5000, }, fees: { statsTtlMs: numberFromEnv("FEE_STATS_TTL_MS", 15_000), @@ -274,10 +438,41 @@ export default (): AppConfig => { adminApiKey: env.ADMIN_API_KEY ?? "", communityApiKeys: [], throttle: { - upstream: { ttl: 60_000, limit: 30 }, - chain: { ttl: 60_000, limit: 10 }, - default: { ttl: 60_000, limit: 100 }, - catalog: { ttl: 60_000, limit: 200 }, + tiers: { + strict: { + limit: env.THROTTLE_STRICT_LIMIT ?? 10, + ttl: env.THROTTLE_STRICT_TTL_MS ?? 60_000, + }, + chainWrite: { + limit: env.THROTTLE_CHAIN_WRITE_LIMIT ?? 5, + ttl: env.THROTTLE_CHAIN_WRITE_TTL_MS ?? 60_000, + }, + moderate: { + limit: env.THROTTLE_MODERATE_LIMIT ?? 60, + ttl: env.THROTTLE_MODERATE_TTL_MS ?? 60_000, + }, + generous: { + limit: env.THROTTLE_GENEROUS_LIMIT ?? 120, + ttl: env.THROTTLE_GENEROUS_TTL_MS ?? 60_000, + }, + }, + trustProxy: env.TRUST_PROXY === true, + upstream: { + ttl: parseInt(process.env.THROTTLE_UPSTREAM_TTL_MS || "60000", 10), + limit: parseInt(process.env.THROTTLE_UPSTREAM_LIMIT || "60", 10), + }, + chain: { + ttl: parseInt(process.env.THROTTLE_CHAIN_TTL_MS || "60000", 10), + limit: parseInt(process.env.THROTTLE_CHAIN_LIMIT || "20", 10), + }, + default: { + ttl: parseInt(process.env.THROTTLE_DEFAULT_TTL_MS || "60000", 10), + limit: parseInt(process.env.THROTTLE_DEFAULT_LIMIT || "600", 10), + }, + catalog: { + ttl: parseInt(process.env.THROTTLE_CATALOG_TTL_MS || "60000", 10), + limit: parseInt(process.env.THROTTLE_CATALOG_LIMIT || "300", 10), + }, }, policyPendingTtlMs: numberFromEnv("POLICY_PENDING_TTL_MS", 3_600_000), }; diff --git a/src/oracle/oracle.module.ts b/src/oracle/oracle.module.ts index 2a1497d..9fe13a0 100644 --- a/src/oracle/oracle.module.ts +++ b/src/oracle/oracle.module.ts @@ -1,5 +1,6 @@ import { Module, Provider } from "@nestjs/common"; import { ConfigModule, ConfigService } from "@nestjs/config"; + import { DbModule } from "../db/db.module"; import { StellarModule } from "../stellar/stellar.module"; import { OracleController } from "./oracle.controller"; @@ -96,6 +97,9 @@ const attestationVerifierProvider: Provider = { // Core oracle services OracleService, + OracleGateway, + OracleScheduler, + OraclePublisherService, OracleMonitorService, FreshnessEnforcementService, OracleEventRepository, @@ -107,6 +111,8 @@ const attestationVerifierProvider: Provider = { ], exports: [ OracleService, + OracleGateway, + OraclePublisherService, OracleMonitorService, FreshnessEnforcementService, ClockService,