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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ RUN mkdir -p /var/lib/refract/oracle-evidence && chown node:node /var/lib/refrac
USER node
EXPOSE 4001

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:4001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:4001/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

# Exec form so node is PID 1 and receives SIGTERM directly. Give it more time
# than SHUTDOWN_GRACE_PERIOD_MS before SIGKILL (docker-compose sets
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ services:
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4001/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4001/health/ready').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 3s
timeout: 10s
retries: 3
start_period: 10s

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "refract-protocol-backend",
"version": "0.1.0",
"description": "Refract Protocol backend \u2014 oracle monitoring, claim processing, premium quoting",
"description": "Refract Protocol backend — oracle monitoring, claim processing, premium quoting",
"main": "dist/main.js",
"private": true,
"workspaces": [
Expand Down Expand Up @@ -127,7 +127,7 @@
]
},
"transformIgnorePatterns": [
"^(?!.*/node_modules/(@noble|uint8array-extras)/).*/node_modules/"
"/node_modules/(?!(?:@stellar/stellar-sdk|@noble/.*|@scure/.*|uint8array-extras|hexoid|bignumber.js|@stellar|@noble|@scure|stellar-base|crc|tweetnacl|base32.js|@adraffy|@noble/curves))"
],
"collectCoverageFrom": [
"**/*.(t|j)s"
Expand Down
5 changes: 5 additions & 0 deletions src/auth/public.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { SetMetadata } from "@nestjs/common";

export const IS_PUBLIC_KEY = "isPublic";
export const Public = (): MethodDecorator & ClassDecorator =>
SetMetadata(IS_PUBLIC_KEY, true);
77 changes: 59 additions & 18 deletions src/cache/cache.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@ import { ConfigService } from "@nestjs/config";
import Redis from "ioredis";
import { AppConfig } from "../config/configuration";

/**
* CacheService — thin, typed wrapper around ioredis.
*
* Design goals
* ------------
* - Single Redis connection per process, shared across all consumers.
* - Fail-open: every public method swallows Redis errors and falls back
* to the non-cached path so a Redis outage never brings down the API.
* - `wrap<T>()` is the primary interface: "return cached value or run
* the loader, cache the result, return it."
*
* TTL conventions (seconds)
* -------------------------
* ORACLE_TTL 55 s — just under the 60 s scheduler interval so the
* HTTP endpoint always returns data that is at most one
* cycle stale without issuing a redundant upstream call.
*/
export const ORACLE_TTL = 55;

@Injectable()
Expand All @@ -11,41 +28,54 @@ export class CacheService implements OnModuleInit, OnModuleDestroy, OnApplicatio
private readonly client: Redis;
private closed = false;

constructor(configService: ConfigService<AppConfig, true>) {
const configured = configService.get("redis", { infer: true });
constructor(private readonly configService: ConfigService<AppConfig, true>) {
const configured = this.configService.get("redis", { infer: true });
const url = process.env.REDIS_URL ?? configured.url;
this.client = new Redis(url, { lazyConnect: false });
this.client.on("error", (err) => this.logger.error(`Redis client error: ${err.message}`));
this.client = new Redis(url, {
// Don't retry indefinitely — give up after 3 reconnection attempts
// so a missing Redis doesn't stall startup.
maxRetriesPerRequest: 1,
enableReadyCheck: false,
lazyConnect: false,
});

this.client.on("connect", () => this.logger.log("Redis connected"));
this.client.on("error", (err: Error) =>
this.logger.warn(`Redis error (cache degraded): ${err.message}`)
);
}

onModuleInit(): void {}

/** Returns the parsed JSON value for `key`, or `null` on miss or error. */
async get<T>(key: string): Promise<T | null> {
try {
const value = await this.client.get(key);
return value === null ? null : (JSON.parse(value) as T);
} catch (error) {
this.logger.warn(`Redis cache read failed for ${key}: ${this.errorMessage(error)}`);
const raw = await this.client.get(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch (err) {
this.logger.warn(`cache.get("${key}") failed: ${this.errorMessage(err)}`);
return null;
}
}

async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
/** Serialises `value` to JSON and stores it under `key` for `ttlSeconds`. */
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
try {
const serialized = JSON.stringify(value);
if (ttlSeconds) await this.client.set(key, serialized, "EX", ttlSeconds);
else await this.client.set(key, serialized);
} catch (error) {
this.logger.warn(`Redis cache write failed for ${key}: ${this.errorMessage(error)}`);
} catch (err) {
this.logger.warn(`cache.set("${key}") failed: ${this.errorMessage(err)}`);
}
}

/** Deletes one or more keys (best-effort). */
async del(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
try {
await this.client.del(...keys);
} catch (error) {
this.logger.warn(`Redis cache delete failed: ${this.errorMessage(error)}`);
} catch (err) {
this.logger.warn(`cache.del(${keys.join(",")}) failed: ${this.errorMessage(err)}`);
}
}

Expand All @@ -62,9 +92,25 @@ export class CacheService implements OnModuleInit, OnModuleDestroy, OnApplicatio
return this.client.status === "ready";
}

/**
* Cache-aside helper.
*
* Returns the cached value for `key` when present. Otherwise calls
* `loader`, stores the result for `ttlSeconds`, and returns it.
* If either the cache read or write throws, the loader result is
* returned without caching (fail-open).
*
* @example
* const readings = await this.cacheService.wrap(
* "oracle:checkAll",
* ORACLE_TTL,
* () => this.runAllChecks()
* );
*/
async wrap<T>(key: string, ttlSeconds: number, loader: () => Promise<T>): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) return cached;

const fresh = await loader();
await this.set(key, fresh, ttlSeconds);
return fresh;
Expand All @@ -74,11 +120,6 @@ export class CacheService implements OnModuleInit, OnModuleDestroy, OnApplicatio
await this.close();
}

/**
* Gracefully close the Redis connection pool during application shutdown.
* Called by Nest when `app.enableShutdownHooks()` is active and the process
* receives SIGTERM/SIGINT, ensuring in-flight commands drain before exit.
*/
async onApplicationShutdown(signal?: string): Promise<void> {
this.logger.log(
`Closing Redis connection pool${signal ? ` (signal: ${signal})` : ''}...`,
Expand Down
84 changes: 61 additions & 23 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export interface AppConfig {
defiLlamaBaseUrl: string;
defiLlamaProtocolSlug: string;
httpTimeoutMs: number;
feedLatencyBudgetsMs?: Record<string, number>;
};
/** Soroban inclusion-fee estimation strategy. */
fees: {
Expand All @@ -111,6 +112,7 @@ export interface AppConfig {
pollIntervalMs: number;
lagAlertLedgers: number;
pageLimit: number;
contractId: string;
};
/** Ops-facing auth (bearer token + read-only community keys). */
ops: {
Expand All @@ -134,6 +136,7 @@ export interface AppConfig {
default: { ttl: number; limit: number };
catalog: { ttl: number; limit: number };
};
oracleTrustMode: "strict" | "quorum" | "development";
/** Pending off-chain policies that never get submitted expire after this many ms. */
policyPendingTtlMs: number;
}
Expand Down Expand Up @@ -348,21 +351,17 @@ export default (): AppConfig => {
redis: { url: env.REDIS_URL ?? "redis://localhost:6379" },
stellar: {
network,
sorobanRpcUrl: env.SOROBAN_RPC_URL ?? selected.sorobanRpcUrl,
sorobanRpcUrls: (env.SOROBAN_RPC_URLS ?? selected.sorobanRpcUrl)
sorobanRpcUrl: env.SOROBAN_RPC_URL || selectedNetwork.sorobanRpcUrl,
sorobanRpcUrls: (env.SOROBAN_RPC_URLS ?? env.SOROBAN_RPC_URL ?? selectedNetwork.sorobanRpcUrl)
.split(",")
.map((url) => url.trim())
.filter(Boolean),
eventStartLedger: numberFromEnv("EVENT_START_LEDGER", 1),
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 ?? "",
horizonUrl: env.STELLAR_HORIZON_URL || selectedNetwork.horizonUrl,
networkPassphrase: env.STELLAR_NETWORK_PASSPHRASE || selectedNetwork.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",
Expand Down Expand Up @@ -397,20 +396,20 @@ export default (): AppConfig => {
minIntervalMs: env.ORACLE_PUBLISH_MIN_INTERVAL_MS ?? 0,
},
oracles: {
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",
coingeckoBaseUrl: env.COINGECKO_BASE_URL || "https://api.coingecko.com/api/v3",
horizonUrl: env.STELLAR_HORIZON_URL || selectedNetwork.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: env.ORACLE_HTTP_TIMEOUT_MS ?? 5000,
defiLlamaProtocolSlug: env.DEFILLAMA_PROTOCOL_SLUG || "aave",
httpTimeoutMs: numberFromEnv("ORACLE_HTTP_TIMEOUT_MS", 5_000),
feedLatencyBudgetsMs: parseFeedBudgets(env.ORACLE_FEED_LATENCY_BUDGETS_MS),
},
fees: {
statsTtlMs: numberFromEnv("FEE_STATS_TTL_MS", 15_000),
ceilingStroops: String(numberFromEnv("FEE_CEILING_STROOPS", 10_000_000)),
ceilingStroops: env.FEE_CEILING_STROOPS || String(numberFromEnv("FEE_CEILING_STROOPS", 10_000_000)),
profiles: {
moderate: {
percentile: numberFromEnv("FEE_MODERATE_PERCENTILE", 90),
Expand All @@ -427,13 +426,11 @@ export default (): AppConfig => {
pollIntervalMs: numberFromEnv("EVENT_INDEXER_POLL_INTERVAL_MS", 5_000),
lagAlertLedgers: numberFromEnv("EVENT_INDEXER_LAG_ALERT_LEDGERS", 100),
pageLimit: numberFromEnv("EVENT_INDEXER_PAGE_LIMIT", 100),
contractId: env.REFRACT_POOL_CONTRACT_ID || "",
},
ops: {
apiToken: env.OPS_API_TOKEN ?? "",
communityApiKeys: (env.COMMUNITY_API_KEYS ?? "")
.split(",")
.map((k) => k.trim())
.filter(Boolean),
apiToken: env.OPS_API_TOKEN || "",
communityApiKeys: parseCsv(env.COMMUNITY_API_KEYS),
},
adminApiKey: env.ADMIN_API_KEY ?? "",
communityApiKeys: [],
Expand Down Expand Up @@ -474,6 +471,47 @@ export default (): AppConfig => {
limit: parseInt(process.env.THROTTLE_CATALOG_LIMIT || "300", 10),
},
},
oracleTrustMode: parseTrustMode(env.ORACLE_TRUST_MODE),
policyPendingTtlMs: numberFromEnv("POLICY_PENDING_TTL_MS", 3_600_000),
};
};

export function isSandboxMode(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = (env.SANDBOX_MODE ?? "").trim().toLowerCase();
return raw === "true" || raw === "1" || raw === "yes" || raw === "on";
}

export function assertSandboxSafety(env: NodeJS.ProcessEnv = process.env): void {
if (isSandboxMode(env) && (env.NODE_ENV ?? "").trim().toLowerCase() === "production") {
throw new Error("SANDBOX_MODE is not allowed when NODE_ENV=production");
}
}

function parseCsv(value?: string): string[] {
return (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
}

function parseFeedBudgets(value?: string): Record<string, number> | undefined {
if (!value) return undefined;
let result: unknown;
try {
result = JSON.parse(value);
} catch {
throw new Error("ORACLE_FEED_LATENCY_BUDGETS_MS must be valid JSON");
}
if (!result || typeof result !== "object" || Array.isArray(result)) {
throw new Error("ORACLE_FEED_LATENCY_BUDGETS_MS must be a JSON object");
}
for (const [feed, budget] of Object.entries(result)) {
if (!Number.isInteger(budget) || Number(budget) <= 0) {
throw new Error(`Latency budget for ${feed} must be a positive integer`);
}
}
return result as Record<string, number>;
}

function parseTrustMode(value?: string): AppConfig["oracleTrustMode"] {
if (value === undefined || value === "") return "strict";
if (value === "strict" || value === "quorum" || value === "development") return value;
throw new Error("ORACLE_TRUST_MODE must be strict, quorum, or development");
}
Loading
Loading