Skip to content
Merged
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
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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": {
Expand Down
293 changes: 244 additions & 49 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -133,7 +138,10 @@ export interface AppConfig {
policyPendingTtlMs: number;
}

const NETWORK_CONFIG: Record<string, { sorobanRpcUrl: string; horizonUrl: string; networkPassphrase: string }> = {
const NETWORK_CONFIG: Record<
string,
{ sorobanRpcUrl: string; horizonUrl: string; networkPassphrase: string }
> = {
testnet: {
sorobanRpcUrl: "https://soroban-testnet.stellar.org",
horizonUrl: "https://horizon-testnet.stellar.org",
Expand All @@ -151,6 +159,131 @@ const NETWORK_CONFIG: Record<string, { sorobanRpcUrl: string; horizonUrl: string
},
};

const appConfigSchema = z.object({
port: z.number().int().min(1).max(65535),
frontendUrl: z.string().url(),
database: z.object({
url: z.string().url(),
poolMax: z.number().int(),
poolMin: z.number().int(),
poolIdleTimeoutMs: z.number().int(),
poolAcquireTimeoutMs: z.number().int(),
statementTimeoutMs: z.number().int(),
connectRetries: z.number().int(),
connectRetryDelayMs: z.number().int(),
}),
redis: z.object({ url: z.string().url() }),
stellar: z.object({
network: z.string(),
sorobanRpcUrl: z.string().url(),
sorobanRpcUrls: z.array(z.string().url()),
eventStartLedger: z.number().int(),
horizonUrl: z.string().url(),
networkPassphrase: z.string(),
poolContractId: z.string(),
policyContractId: z.string(),
oracleContractId: z.string(),
relayerSecret: z.string(),
relayerSecretId: z.string(),
relayerSecretRegion: z.string(),
validateOnChain: z.boolean(),
requireNetwork: z.boolean(),
validateTimeoutMs: z.number().int(),
relayer: z.object({
feeMultiplier: z.number(),
feeCeiling: z.number().int(),
idleResyncMs: z.number().int(),
minBalance: z.number().int(),
maxAttempts: z.number().int(),
}),
eventIndexer: z.object({
enabled: z.boolean(),
pollIntervalMs: z.number().int(),
lagAlertLedgers: z.number().int(),
pageLimit: z.number().int(),
}),
}),
oracles: z.object({
coingeckoBaseUrl: z.string().url(),
horizonUrl: z.string().url(),
defiLlamaBaseUrl: z.string().url(),
defiLlamaProtocolSlug: z.string(),
httpTimeoutMs: z.number().int().min(100).max(120_000),
}),
confirmation: z.object({
initialIntervalMs: z.number().int(),
backoffMultiplier: z.number(),
maxIntervalMs: z.number().int(),
deadlineMs: z.number().int(),
jitterRatio: z.number(),
settlementDeadlineMs: z.number().int(),
httpDeadlineMs: z.number().int(),
}),
oraclePublish: z.object({
mode: z.enum(["every_poll", "on_change"]),
minIntervalMs: z.number().int(),
}),
fees: z.object({
ceilingStroops: z.string(),
statsTtlMs: z.number().int(),
profiles: z.record(
z.enum(["moderate", "aggressive"]),
z.object({ percentile: z.number().int(), multiplier: z.number() }),
),
}),
eventIndexer: z.object({
enabled: z.boolean(),
pollIntervalMs: z.number().int(),
lagAlertLedgers: z.number().int(),
pageLimit: z.number().int(),
}),
ops: z.object({
apiToken: z.string(),
communityApiKeys: z.array(z.string()),
}),
adminApiKey: z.string(),
communityApiKeys: z.array(
z.object({
key: z.string(),
scope: z.literal("community"),
contact: z.string(),
rateLimitPerMinute: z.number().int(),
createdAt: z.string(),
}),
),
throttle: z.object({
/**
* Named throttling tiers, each expressed as `{ limit, ttl }` where
* `limit` is the max number of requests allowed per `ttl` window
* (milliseconds). Consumed by `ThrottlerModule.forRootAsync` and
* referenced by name from `@Throttle({ ... })` route overrides.
*/
tiers: z.object({
/** Upstream-proxying reads (e.g. GET /oracle/status). */
strict: z.object({ limit: z.number().int(), ttl: z.number().int() }),
/** Chain-writing posts (e.g. /tx/submit, /policies/buy). */
chainWrite: z.object({ limit: z.number().int(), ttl: z.number().int() }),
/** Ordinary database-backed reads. */
moderate: z.object({ limit: z.number().int(), ttl: z.number().int() }),
/** Static catalog routes (e.g. /quotes/coverage-types). */
generous: z.object({ limit: z.number().int(), ttl: z.number().int() }),
}),
/**
* Whether to trust `X-Forwarded-For` from a fronting proxy. Must be
* set deliberately (never implicitly) so IP-based limiting does not
* collapse every caller onto the load balancer's address.
*/
trustProxy: z.boolean(),
upstream: z.object({ ttl: z.number().int(), limit: z.number().int() }),
chain: z.object({ ttl: z.number().int(), limit: z.number().int() }),
default: z.object({ ttl: z.number().int(), limit: z.number().int() }),
catalog: z.object({ ttl: z.number().int(), limit: z.number().int() }),
}),
policyPendingTtlMs: z.number().int(),
});

export type AppConfig = z.infer<typeof appConfigSchema>;

function numberFromEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw === "") return fallback;
Expand All @@ -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),
Expand Down Expand Up @@ -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),
};
Expand Down
Loading
Loading