From fe26696e792233e697505c75c3dfaee88f16e3d9 Mon Sep 17 00:00:00 2001 From: Mac-5 Date: Sun, 30 Aug 2026 23:18:57 +0100 Subject: [PATCH 1/4] fix(oracle): fail closed on low-confidence resolutions before enqueue Partial-success results with confidence below a configurable threshold (ORACLE_MIN_CONFIDENCE_THRESHOLD, default 0.75) were previously enqueued for on-chain submission regardless of signal strength. OracleService now rejects low-confidence primary/fallback results the same way it already rejects total provider outages: it refuses to enqueue, logs an oracle.low_confidence_fail_closed event, and increments oracleFailClosedTotal. Adds unit tests and env-validation docs. --- apps/oracle/oracle-config.test.ts | 29 +++++++++++ apps/oracle/oracle-config.ts | 45 +++++++++++++++++ apps/oracle/oracle-service.test.ts | 81 ++++++++++++++++++++++++++++++ apps/oracle/oracle-service.ts | 68 +++++++++++++++++++++++++ docs/env-validation.md | 24 +++++++++ 5 files changed, 247 insertions(+) diff --git a/apps/oracle/oracle-config.test.ts b/apps/oracle/oracle-config.test.ts index d0b7e9ed..35b2331f 100644 --- a/apps/oracle/oracle-config.test.ts +++ b/apps/oracle/oracle-config.test.ts @@ -37,4 +37,33 @@ describe("oracle-config", () => { it("throws on invalid log level", () => { expect(() => loadOracleConfig({ ORACLE_LOG_LEVEL: "invalid" })).toThrow(); }); + + describe("minConfidenceThreshold (#991)", () => { + it("defaults to 0.75 when unset", () => { + const config = loadOracleConfig({}); + expect(config.minConfidenceThreshold).toBe(0.75); + }); + + it("reads a valid value from env", () => { + const config = loadOracleConfig({ + ORACLE_MIN_CONFIDENCE_THRESHOLD: "0.9", + }); + expect(config.minConfidenceThreshold).toBe(0.9); + }); + + it("throws when out of the [0,1] range", () => { + expect(() => + loadOracleConfig({ ORACLE_MIN_CONFIDENCE_THRESHOLD: "1.5" }) + ).toThrow(); + expect(() => + loadOracleConfig({ ORACLE_MIN_CONFIDENCE_THRESHOLD: "-0.1" }) + ).toThrow(); + }); + + it("throws on a non-numeric value", () => { + expect(() => + loadOracleConfig({ ORACLE_MIN_CONFIDENCE_THRESHOLD: "not-a-number" }) + ).toThrow(); + }); + }); }); diff --git a/apps/oracle/oracle-config.ts b/apps/oracle/oracle-config.ts index 1691eb57..06c336a2 100644 --- a/apps/oracle/oracle-config.ts +++ b/apps/oracle/oracle-config.ts @@ -35,6 +35,14 @@ export interface OracleConfig { primaryTimeoutMs: number; /** Timeout for the fallback oracle provider, in milliseconds. */ fallbackTimeoutMs: number; + /** + * Minimum acceptable confidence score (0-1, inclusive) for a resolution + * to be enqueued for on-chain submission. Results below this threshold + * are treated as a fail-closed condition: they are never enqueued, and + * in production they raise the same `oracleFailClosedTotal` metric used + * for total provider outages. + */ + minConfidenceThreshold: number; } const VALID_LOG_LEVELS: ReadonlySet = new Set([ @@ -46,6 +54,13 @@ const VALID_LOG_LEVELS: ReadonlySet = new Set([ const DEFAULT_CHALLENGE_WINDOW_SECONDS = 86_400; const DEFAULT_LOG_LEVEL: LogLevel = "info"; +/** + * Default minimum confidence threshold. Chosen to be strict enough that a + * partial-success, low-confidence resolution never reaches the submission + * queue silently — operators must explicitly lower this via + * `ORACLE_MIN_CONFIDENCE_THRESHOLD` if they want to accept weaker signals. + */ +const DEFAULT_MIN_CONFIDENCE_THRESHOLD = 0.75; type Env = Record; @@ -79,6 +94,12 @@ export function loadOracleConfig(env: Env = process.env): OracleConfig { DEFAULT_TIMEOUT_MS ); + const minConfidenceThreshold = parseOptionalUnitInterval( + env["ORACLE_MIN_CONFIDENCE_THRESHOLD"], + "ORACLE_MIN_CONFIDENCE_THRESHOLD", + DEFAULT_MIN_CONFIDENCE_THRESHOLD + ); + return { pollIntervalMs, challengeWindowSeconds, @@ -86,9 +107,33 @@ export function loadOracleConfig(env: Env = process.env): OracleConfig { secretKey: env["ORACLE_SECRET_KEY"] ?? undefined, primaryTimeoutMs, fallbackTimeoutMs, + minConfidenceThreshold, }; } +/** + * Parse an optional environment variable that must fall within [0, 1]. + * Used for confidence-threshold style settings. + */ +function parseOptionalUnitInterval( + raw: string | undefined, + name: string, + defaultValue: number +): number { + if (raw === undefined || raw === "") { + return defaultValue; + } + + const value = Number(raw); + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new Error( + `${name} must be a number between 0 and 1, got: ${JSON.stringify(raw)}` + ); + } + + return value; +} + function parseOptionalPositiveInt( raw: string | undefined, name: string, diff --git a/apps/oracle/oracle-service.test.ts b/apps/oracle/oracle-service.test.ts index 3030df88..b38086a9 100644 --- a/apps/oracle/oracle-service.test.ts +++ b/apps/oracle/oracle-service.test.ts @@ -548,4 +548,85 @@ describe("OracleService", () => { // No error, enqueue was skipped gracefully }); }); + + describe("confidence gate (#991)", () => { + function lowConfidenceAdapter(source: string): ProviderAdapter { + return { + getSource: () => source, + healthCheck: vi.fn().mockResolvedValue(true), + resolve: vi.fn().mockResolvedValue({ + outcome: true, + confidence: 0.2, + source, + timestamp: new Date().toISOString(), + } as ProviderResult), + }; + } + + it("refuses to enqueue a low-confidence primary result", async () => { + const enqueueCallback = vi.fn().mockResolvedValue(undefined); + const service = new OracleService({ + primaryAdapter: lowConfidenceAdapter("primary"), + fallbackAdapter, + enqueueCallback, + minConfidenceThreshold: 0.75, + }); + + await expect( + service.resolve({ + marketId: "market-low-confidence", + oracleAddress: + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }) + ).rejects.toThrow(/confidence/i); + + expect(enqueueCallback).not.toHaveBeenCalled(); + }); + + it("increments oracleFailClosedTotal when refusing a low-confidence result", async () => { + const before = (await oracleFailClosedTotal.get()).values.reduce( + (sum, v) => sum + v.value, + 0 + ); + + const service = new OracleService({ + primaryAdapter: lowConfidenceAdapter("primary"), + fallbackAdapter, + minConfidenceThreshold: 0.75, + }); + + await expect( + service.resolve({ + marketId: "market-low-confidence-2", + oracleAddress: + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }) + ).rejects.toThrow(); + + const after = (await oracleFailClosedTotal.get()).values.reduce( + (sum, v) => sum + v.value, + 0 + ); + expect(after).toBeGreaterThan(before); + }); + + it("enqueues when confidence meets the threshold", async () => { + const enqueueCallback = vi.fn().mockResolvedValue(undefined); + const service = new OracleService({ + primaryAdapter, + fallbackAdapter, + enqueueCallback, + minConfidenceThreshold: 0.5, + }); + + const result = await service.resolve({ + marketId: "market-ok-confidence", + oracleAddress: + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }); + + expect(result.confidence).toBeGreaterThanOrEqual(0.5); + expect(enqueueCallback).toHaveBeenCalled(); + }); + }); }); diff --git a/apps/oracle/oracle-service.ts b/apps/oracle/oracle-service.ts index 5e859eb4..0b69e0d6 100644 --- a/apps/oracle/oracle-service.ts +++ b/apps/oracle/oracle-service.ts @@ -48,6 +48,30 @@ export interface OracleServiceConfig { submissionQueue?: SubmissionQueue; /** Optional enqueue callback (alternative to submissionQueue) */ enqueueCallback?: EnqueueCallback; + /** + * Minimum acceptable confidence (0-1) for a result to be enqueued. + * Results below this threshold are rejected and fail-closed rather than + * being silently enqueued with weak signal. Defaults to 0.75. + */ + minConfidenceThreshold?: number; +} + +/** + * Error thrown when a resolution succeeds but its confidence score is below + * the configured `minConfidenceThreshold`. This is a fail-closed condition: + * the result is never enqueued for on-chain submission. + */ +export class LowConfidenceResultError extends Error { + constructor( + public readonly marketId: string, + public readonly confidence: number, + public readonly threshold: number + ) { + super( + `Resolution for market ${marketId} has confidence ${confidence} below required threshold ${threshold}` + ); + this.name = "LowConfidenceResultError"; + } } /** @@ -132,6 +156,7 @@ export class OracleService { primaryTimeoutMs: DEFAULT_TIMEOUT_MS, fallbackTimeoutMs: DEFAULT_TIMEOUT_MS, retryConfig: { maxRetries: 0 }, + minConfidenceThreshold: 0.75, ...config, }; if (isProduction) { @@ -185,6 +210,8 @@ export class OracleService { source: result.source, }); + this.assertConfidence(request, result); + // Enqueue for on-chain submission if configured await this.enqueueResult(request, result); @@ -246,6 +273,8 @@ export class OracleService { source: result.source, }); + this.assertConfidence(request, result); + // Enqueue for on-chain submission if configured await this.enqueueResult(request, result); @@ -327,6 +356,45 @@ export class OracleService { return this.fallbackAdapter; } + /** + * Fail closed when a resolution's confidence falls below the configured + * `minConfidenceThreshold`. Partial-success, low-confidence results must + * never reach the submission queue — silently enqueuing a weak signal is + * how bad resolutions end up on-chain. + * + * @throws {LowConfidenceResultError} If confidence is below threshold. + */ + private assertConfidence( + request: ResolutionRequest, + result: ProviderResult + ): void { + const threshold = this.config.minConfidenceThreshold ?? 0.75; + + if (result.confidence >= threshold) { + return; + } + + this.metrics.totalOutageCount++; + oracleFailClosedTotal.inc(); + this.logger.error( + "Refusing to enqueue low-confidence resolution — failing closed", + { + event: "oracle.low_confidence_fail_closed", + marketId: request.marketId, + requestId: request.marketId, + confidence: result.confidence, + threshold, + source: result.source, + } + ); + + throw new LowConfidenceResultError( + request.marketId, + result.confidence, + threshold + ); + } + /** * Enqueue a resolved result for on-chain submission. * Skips if no queue or callback is configured. diff --git a/docs/env-validation.md b/docs/env-validation.md index 6e90f236..2650473a 100644 --- a/docs/env-validation.md +++ b/docs/env-validation.md @@ -213,6 +213,30 @@ PORT must be a positive integer, got: "abc" PORT must be <= 65535, got: "99999" ``` +### Unit-interval variables + +Must be a finite number between `0` and `1` (inclusive). + +| Variable | Min | Max | Default | +| ------------------------------------ | --- | --- | ------- | +| `ORACLE_MIN_CONFIDENCE_THRESHOLD` | 0 | 1 | `0.75` | + +`ORACLE_MIN_CONFIDENCE_THRESHOLD` gates whether a resolved market result is +enqueued for on-chain submission (#991). A resolution — from either the +primary or fallback provider — whose `confidence` score falls below this +threshold is treated as a fail-closed condition: it is never enqueued, an +`oracle.low_confidence_fail_closed` log event is emitted, and the +`oracleFailClosedTotal` metric is incremented, mirroring the existing +`ALL_PROVIDERS_FAILED` outage path. Operators must raise this value +explicitly to accept weaker signals; the default (`0.75`) errs toward +dropping ambiguous resolutions rather than submitting them on-chain. + +**Error example:** + +``` +ORACLE_MIN_CONFIDENCE_THRESHOLD must be a number between 0 and 1, got: "1.5" +``` + ### Boolean variables Accepted values are the literal strings `true` or `false`; any other value From 02fc9e89cb8096f968b56deede8d7997c0f3d12e Mon Sep 17 00:00:00 2001 From: Mac-5 Date: Sun, 30 Aug 2026 23:21:21 +0100 Subject: [PATCH 2/4] fix(oracle): fail fast on out-of-range timeouts in production timeout-utils previously clamped out-of-range timeouts to MIN/MAX with only a console.warn, letting production silently run with a different effective timeout than configured. validateTimeout() now throws in NODE_ENV=production instead of clamping (dev/test keep the clamp). FallbackAdapter now defaults to a named FALLBACK_PROVIDER_TIMEOUT_POLICY_MS constant matching docs/architecture.md instead of the generic default, and validates both its configured and per-request timeouts through the same fail-fast path. Adds tests and a fix writeup under docs/fixes/. --- apps/oracle/fallback-adapter.test.ts | 39 +++++++++++++++++ apps/oracle/fallback-adapter.ts | 21 ++++++++-- apps/oracle/timeout-utils.test.ts | 42 ++++++++++++++++++- apps/oracle/timeout-utils.ts | 34 ++++++++++++++- docs/architecture.md | 2 + docs/fixes/992-timeout-policy-fail-fast.md | 49 ++++++++++++++++++++++ 6 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 docs/fixes/992-timeout-policy-fail-fast.md diff --git a/apps/oracle/fallback-adapter.test.ts b/apps/oracle/fallback-adapter.test.ts index 024bfc24..2bffc554 100644 --- a/apps/oracle/fallback-adapter.test.ts +++ b/apps/oracle/fallback-adapter.test.ts @@ -258,3 +258,42 @@ describe("FallbackAdapter", () => { }); }); }); + +describe("FallbackAdapter timeout policy (#992)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("defaults to the documented fallback timeout policy", () => { + const adapter = makeAdapter(); + expect(adapter.getSource()).toBe("fallback"); + }); + + it("fails fast on construction when timeoutMs is out of range in production", () => { + vi.stubEnv("NODE_ENV", "production"); + expect(() => makeAdapter({ timeoutMs: 999_999 })).toThrow( + /refusing to silently clamp/i + ); + }); + + it("clamps an out-of-range timeoutMs outside production instead of throwing", () => { + vi.stubEnv("NODE_ENV", "development"); + expect(() => makeAdapter({ timeoutMs: 999_999 })).not.toThrow(); + }); + + it("fails fast on a per-request timeout override that is out of range in production", async () => { + vi.stubEnv("NODE_ENV", "production"); + const fetchFn = vi + .fn() + .mockResolvedValue(okResponse({ outcome: true, confidence: 0.9 })); + const adapter = makeAdapter({ fetchFn }); + + await expect( + adapter.resolve({ + marketId: "market-1", + oracleAddress: "GORACLE", + timeoutMs: 500, + }) + ).rejects.toThrow(/refusing to silently clamp/i); + }); +}); diff --git a/apps/oracle/fallback-adapter.ts b/apps/oracle/fallback-adapter.ts index a63d646b..af78d46b 100644 --- a/apps/oracle/fallback-adapter.ts +++ b/apps/oracle/fallback-adapter.ts @@ -14,7 +14,11 @@ import type { ProviderResult, ResolutionRequest, } from "./provider-adapter.js"; -import { withTimeout, DEFAULT_TIMEOUT_MS } from "./timeout-utils.js"; +import { + withTimeout, + validateTimeout, + FALLBACK_PROVIDER_TIMEOUT_POLICY_MS, +} from "./timeout-utils.js"; import { withRetry, type RetryConfig } from "./retry-utils.js"; /** @@ -86,7 +90,13 @@ export class FallbackAdapter implements ProviderAdapter { if (!config.providers || config.providers.length === 0) { throw new Error("FallbackAdapter requires at least one provider"); } - this.config = { timeoutMs: DEFAULT_TIMEOUT_MS, ...config }; + this.config = { + timeoutMs: FALLBACK_PROVIDER_TIMEOUT_POLICY_MS, + ...config, + }; + // Fail fast (in production) rather than silently running with a + // timeout that doesn't match the documented fallback policy. + this.config.timeoutMs = validateTimeout(this.config.timeoutMs); this.fetchFn = config.fetchFn ?? fetch; } @@ -95,8 +105,11 @@ export class FallbackAdapter implements ProviderAdapter { * Each provider is retried per retryConfig before advancing. */ async resolve(request: ResolutionRequest): Promise { - const timeoutMs = - request.timeoutMs ?? this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const timeoutMs = validateTimeout( + request.timeoutMs ?? + this.config.timeoutMs ?? + FALLBACK_PROVIDER_TIMEOUT_POLICY_MS + ); const errors: Error[] = []; for (const provider of this.config.providers) { diff --git a/apps/oracle/timeout-utils.test.ts b/apps/oracle/timeout-utils.test.ts index c55e0912..09ca12d5 100644 --- a/apps/oracle/timeout-utils.test.ts +++ b/apps/oracle/timeout-utils.test.ts @@ -4,7 +4,7 @@ * Covers timeout validation, signal creation, and withTimeout behavior. */ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { validateTimeout, createTimeoutSignal, @@ -12,6 +12,8 @@ import { DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, + PRIMARY_PROVIDER_TIMEOUT_POLICY_MS, + FALLBACK_PROVIDER_TIMEOUT_POLICY_MS, formatDuration, } from "./timeout-utils.js"; @@ -151,3 +153,41 @@ describe("formatDuration", () => { expect(formatDuration(2000)).toBe("2.00s"); }); }); + +describe("documented timeout policy (#992)", () => { + it("exposes named policy constants matching docs/architecture.md", () => { + expect(PRIMARY_PROVIDER_TIMEOUT_POLICY_MS).toBe(30_000); + expect(FALLBACK_PROVIDER_TIMEOUT_POLICY_MS).toBe(30_000); + }); +}); + +describe("validateTimeout production fail-fast (#992)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("clamps out-of-range values outside production", () => { + vi.stubEnv("NODE_ENV", "development"); + expect(validateTimeout(MIN_TIMEOUT_MS - 1)).toBe(MIN_TIMEOUT_MS); + expect(validateTimeout(MAX_TIMEOUT_MS + 1)).toBe(MAX_TIMEOUT_MS); + }); + + it("throws instead of silently clamping below the minimum in production", () => { + vi.stubEnv("NODE_ENV", "production"); + expect(() => validateTimeout(MIN_TIMEOUT_MS - 1)).toThrow( + /refusing to silently clamp/i + ); + }); + + it("throws instead of silently clamping above the maximum in production", () => { + vi.stubEnv("NODE_ENV", "production"); + expect(() => validateTimeout(MAX_TIMEOUT_MS + 1)).toThrow( + /refusing to silently clamp/i + ); + }); + + it("still accepts an in-range value in production", () => { + vi.stubEnv("NODE_ENV", "production"); + expect(validateTimeout(DEFAULT_TIMEOUT_MS)).toBe(DEFAULT_TIMEOUT_MS); + }); +}); diff --git a/apps/oracle/timeout-utils.ts b/apps/oracle/timeout-utils.ts index 6b4bc299..295ebd84 100644 --- a/apps/oracle/timeout-utils.ts +++ b/apps/oracle/timeout-utils.ts @@ -22,6 +22,16 @@ export const MIN_TIMEOUT_MS = 1_000; */ export const MAX_TIMEOUT_MS = 300_000; +/** + * Documented per-role timeout policy (docs/architecture.md, "Oracle failover + * policy"). These are the values operators are told to expect; adapters + * should reference these named constants rather than the generic + * `DEFAULT_TIMEOUT_MS` so a future policy change can't silently diverge + * between what's documented and what a given adapter actually enforces. + */ +export const PRIMARY_PROVIDER_TIMEOUT_POLICY_MS = 30_000; +export const FALLBACK_PROVIDER_TIMEOUT_POLICY_MS = 30_000; + /** * Timeout configuration options. */ @@ -57,15 +67,32 @@ export class TimeoutValidationError extends Error { /** * Validate that a timeout value is within acceptable bounds. * + * In `NODE_ENV=production`, an out-of-range timeout is a configuration bug + * and fails fast (throws) rather than being silently clamped to a different + * value than what was configured — a silently-clamped timeout is exactly + * the kind of divergence-from-policy that let production run with different + * effective timeouts than docs/architecture.md described. Outside + * production, the value is clamped with a warning so local/dev stubs keep + * working without ceremony. + * * @param timeoutMs - Timeout value to validate - * @returns The validated timeout value (clamped to bounds) + * @returns The validated timeout value (clamped to bounds outside production) + * @throws {TimeoutValidationError} If the value is invalid, or out of bounds + * while running in production. */ export function validateTimeout(timeoutMs: unknown): number { + const isProduction = process.env.NODE_ENV === "production"; + if (typeof timeoutMs !== "number" || isNaN(timeoutMs as number)) { throw new TimeoutValidationError(`Invalid timeout value: ${timeoutMs}`); } if (timeoutMs < MIN_TIMEOUT_MS) { + if (isProduction) { + throw new TimeoutValidationError( + `Timeout ${timeoutMs}ms is below the minimum of ${MIN_TIMEOUT_MS}ms — refusing to silently clamp in production` + ); + } console.warn("Timeout is below minimum, clamping", { providedTimeoutMs: timeoutMs, minTimeoutMs: MIN_TIMEOUT_MS, @@ -74,6 +101,11 @@ export function validateTimeout(timeoutMs: unknown): number { } if (timeoutMs > MAX_TIMEOUT_MS) { + if (isProduction) { + throw new TimeoutValidationError( + `Timeout ${timeoutMs}ms exceeds the maximum of ${MAX_TIMEOUT_MS}ms — refusing to silently clamp in production` + ); + } console.warn("Timeout exceeds maximum, clamping", { providedTimeoutMs: timeoutMs, maxTimeoutMs: MAX_TIMEOUT_MS, diff --git a/docs/architecture.md b/docs/architecture.md index b8fc222f..c31977c8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -100,6 +100,8 @@ store (a DB `UNIQUE` constraint on the event ID). See - In production (`NODE_ENV=production`), fallback is disabled and any primary failure fails closed immediately. No secondary off-chain result, stale value, or default value is accepted. - If the fallback also fails or times out, the oracle fails closed: no resolution is generated, and `vatix_oracle_fail_closed_total` is incremented. No trades are settled and no on-chain submission occurs until a provider becomes available again. - Non-transient errors (4xx, malformed responses) from the primary provider skip fallback and fail fast. +- Resolutions must also meet `ORACLE_MIN_CONFIDENCE_THRESHOLD` (default `0.75`) to be enqueued for on-chain submission; a low-confidence primary or fallback result fails closed the same way a total outage does (#991). +- Timeout values are validated against `apps/oracle/timeout-utils.ts` (`MIN_TIMEOUT_MS`/`MAX_TIMEOUT_MS`, 1s–5min). In production, an out-of-range timeout throws at construction/request time instead of being silently clamped, so a misconfigured deployment never runs with a different effective timeout than what's documented here (#992). Outside production, out-of-range values are clamped with a warning. `FallbackAdapter` defaults to the documented `FALLBACK_PROVIDER_TIMEOUT_POLICY_MS` (30s) rather than the generic timeout default, keeping the fallback chain's timeout traceable to this policy. - See `apps/oracle/oracle-service.ts` for implementation details and `apps/oracle/oracle-config.ts` for configuration. ### Market lifecycle diff --git a/docs/fixes/992-timeout-policy-fail-fast.md b/docs/fixes/992-timeout-policy-fail-fast.md new file mode 100644 index 00000000..97292812 --- /dev/null +++ b/docs/fixes/992-timeout-policy-fail-fast.md @@ -0,0 +1,49 @@ +# Fix: Timeout utils shared with fallback adapter (#992) + +## Problem + +`apps/oracle/timeout-utils.ts` silently **clamped** out-of-range timeout +values (below `MIN_TIMEOUT_MS` / above `MAX_TIMEOUT_MS`) to the nearest +bound and only logged a `console.warn`. `apps/oracle/fallback-adapter.ts` +defaulted to the generic `DEFAULT_TIMEOUT_MS` export rather than a value +tied to the documented fallback timeout policy in `docs/architecture.md`. +In production, a misconfigured timeout (e.g. an env var typo, or a value +in milliseconds where seconds were expected) would silently run with a +different effective timeout than what operators believe is configured — +exactly the kind of silent divergence that can drop or mis-time trade +resolutions on Stellar. + +## Fix + +- `validateTimeout()` now fails fast (`TimeoutValidationError`) on an + out-of-range value when `NODE_ENV=production`, instead of clamping. + Outside production it keeps the previous clamp-with-warning behavior so + local dev/test stubs keep working without extra ceremony. +- Added named policy constants `PRIMARY_PROVIDER_TIMEOUT_POLICY_MS` and + `FALLBACK_PROVIDER_TIMEOUT_POLICY_MS` (both 30s, matching + `docs/architecture.md`) so adapters reference the documented policy + directly rather than a generic default that could drift from it. +- `FallbackAdapter` now defaults to `FALLBACK_PROVIDER_TIMEOUT_POLICY_MS`, + and validates both its constructor-provided `timeoutMs` and any + per-request `timeoutMs` override through `validateTimeout()` — so a bad + fallback timeout throws immediately in production rather than being + silently coerced. + +## Tests + +- `apps/oracle/timeout-utils.test.ts`: production fail-fast on + below-minimum and above-maximum values; clamping still works outside + production; policy constants match documented values. +- `apps/oracle/fallback-adapter.test.ts`: constructor and per-request + timeout overrides fail fast in production; clamp instead of throw + outside production. + +## Docs + +`docs/architecture.md`'s "Oracle failover policy" section now documents +the fail-fast-in-production timeout behavior and the confidence-threshold +gate from #991. + +## Out of scope + +No change to retry/backoff logic or the shape of `ProviderResult`. From c062c4af1d58df8172512825c81e1e495ea0bf1c Mon Sep 17 00:00:00 2001 From: Mac-5 Date: Sun, 30 Aug 2026 23:22:59 +0100 Subject: [PATCH 3/4] fix(oracle): reject legacy passphrase-less signatures in production Adds explicit signature envelope versioning to SignedResolutionReport (version 2 = domain+network separated per #978, version 1/undefined = legacy domain-only). verifyResolutionReport now throws LegacySignatureRejectedError for legacy signatures when NODE_ENV=production instead of silently verifying them with the weaker (passphrase-less) canonical form, closing the cross-network replay gap. Outside production, legacy reports still verify (with a warning) to support a migration window. signResolutionReport always stamps new reports with version 2. Adds tests and docs/signature-helper.md updates. --- apps/oracle/signature-helper.test.ts | 82 +++++++++++++++++++++++++++- apps/oracle/signature-helper.ts | 81 ++++++++++++++++++++++++++- docs/signature-helper.md | 26 +++++++++ 3 files changed, 186 insertions(+), 3 deletions(-) diff --git a/apps/oracle/signature-helper.test.ts b/apps/oracle/signature-helper.test.ts index 3564be1b..80f73fd8 100644 --- a/apps/oracle/signature-helper.test.ts +++ b/apps/oracle/signature-helper.test.ts @@ -1,10 +1,38 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, afterEach } from "vitest"; import { Keypair } from "@stellar/stellar-sdk"; import { signResolutionReport, verifyResolutionReport, + LegacySignatureRejectedError, + CURRENT_SIGNATURE_VERSION, } from "./signature-helper.js"; -import type { ResolutionPayload } from "./signature-helper.js"; +import type { + ResolutionPayload, + SignedResolutionReport, +} from "./signature-helper.js"; + +/** Builds a pre-#978 legacy report: domain-separated only, no network passphrase. */ +function signLegacyReport( + payload: ResolutionPayload, + keypair: Keypair +): SignedResolutionReport { + const legacyMessage = Buffer.from( + JSON.stringify({ + domain: "vatix.oracle-resolution.v1", + payload: { + marketId: payload.marketId, + outcome: payload.outcome, + timestamp: payload.timestamp, + }, + }), + "utf8" + ); + return { + payload, + signature: keypair.sign(legacyMessage).toString("base64"), + publicKey: keypair.publicKey(), + }; +} const testKeypair = Keypair.random(); const SECRET = testKeypair.secret(); @@ -139,3 +167,53 @@ describe("verifyResolutionReport", () => { expect(verifyResolutionReport(tampered)).toBe(false); }); }); + +describe("signature envelope versioning (#993)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("stamps newly signed reports with the current version", () => { + const report = signResolutionReport(basePayload, SECRET); + expect(report.version).toBe(CURRENT_SIGNATURE_VERSION); + expect(CURRENT_SIGNATURE_VERSION).toBe(2); + }); + + it("rejects a legacy (passphrase-less) signature in production", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv( + "SOROBAN_NETWORK_PASSPHRASE", + "Public Global Stellar Network ; September 2015" + ); + const legacy = signLegacyReport(basePayload, testKeypair); + + expect(() => verifyResolutionReport(legacy)).toThrow( + LegacySignatureRejectedError + ); + }); + + it("still verifies a legacy signature outside production", () => { + vi.stubEnv("NODE_ENV", "development"); + const legacy = signLegacyReport(basePayload, testKeypair); + + expect(verifyResolutionReport(legacy)).toBe(true); + }); + + it("rejects a current (v2) signature verified as if it were legacy-tampered to v1", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv( + "SOROBAN_NETWORK_PASSPHRASE", + "Public Global Stellar Network ; September 2015" + ); + const report = signResolutionReport( + basePayload, + SECRET, + "Public Global Stellar Network ; September 2015" + ); + const downgraded: SignedResolutionReport = { ...report, version: 1 }; + + expect(() => verifyResolutionReport(downgraded)).toThrow( + LegacySignatureRejectedError + ); + }); +}); diff --git a/apps/oracle/signature-helper.ts b/apps/oracle/signature-helper.ts index 21675817..96e0223f 100644 --- a/apps/oracle/signature-helper.ts +++ b/apps/oracle/signature-helper.ts @@ -36,6 +36,49 @@ export interface SignedResolutionReport { signature: string; /** Stellar-format public key of the signing keypair */ publicKey: string; + /** + * Signature envelope version. `2` (current) binds the signature to both + * the domain tag and the Stellar network passphrase (#978). `1` (legacy) + * bound only the domain tag, which allowed a testnet signature to be + * replayed as a valid mainnet signature and vice versa. Reports omitting + * this field are treated as version `1` for backward compatibility with + * signatures produced before #978. + */ + version?: 1 | 2; +} + +/** Current signature envelope version. Always used for newly signed reports. */ +export const CURRENT_SIGNATURE_VERSION = 2 as const; + +/** + * Error thrown when a legacy (pre-#978), passphrase-less signature is + * encountered in production. These signatures are vulnerable to + * cross-network replay and must never be accepted as valid in production. + */ +export class LegacySignatureRejectedError extends Error { + constructor(marketId: string) { + super( + `Legacy v1 signature (no network passphrase binding) rejected for market ${marketId} — cross-network replay risk. Re-sign with the current (v2) envelope.` + ); + this.name = "LegacySignatureRejectedError"; + } +} + +/** + * Reproduces the pre-#978 canonical string: domain-separated but **not** + * network-separated. Exists only so legacy reports can be recognized and, + * outside production, verified during a migration window. Never used for + * new signatures. + */ +function legacyCanonicalise(payload: ResolutionPayload): string { + return JSON.stringify({ + domain: SIGNING_DOMAINS.ORACLE_RESOLUTION, + payload: { + marketId: payload.marketId, + outcome: payload.outcome, + timestamp: payload.timestamp, + }, + }); } /** @@ -85,21 +128,57 @@ export function signResolutionReport( const message = Buffer.from(canonicalise(payload, networkPassphrase), "utf8"); const signature = keypair.sign(message).toString("base64"); - return { payload, signature, publicKey: keypair.publicKey() }; + return { + payload, + signature, + publicKey: keypair.publicKey(), + version: CURRENT_SIGNATURE_VERSION, + }; } /** * Verify a signed resolution report. * + * Legacy (`version: 1` or missing `version`) reports are signatures that + * predate #978's network-passphrase binding and are vulnerable to + * cross-network replay (a testnet signature also verifies on mainnet). In + * `NODE_ENV=production` these are rejected outright — `verifyResolutionReport` + * throws `LegacySignatureRejectedError` rather than silently falling back to + * the weaker legacy check. Outside production, legacy reports are still + * verified (using the pre-#978 canonical form) so a migration window can + * validate old signatures, but a warning is logged every time. + * * @param report - The signed report to check * @param networkPassphrase - Passphrase the signature must be bound to. * Defaults to `resolveSigningNetworkPassphrase()`. * @returns `true` when the signature is valid and the payload is unmodified + * @throws {LegacySignatureRejectedError} If `report` is a legacy (v1) + * signature and `NODE_ENV=production`. */ export function verifyResolutionReport( report: SignedResolutionReport, networkPassphrase: string = resolveSigningNetworkPassphrase() ): boolean { + const isLegacy = report.version === undefined || report.version === 1; + + if (isLegacy) { + if (process.env.NODE_ENV === "production") { + throw new LegacySignatureRejectedError(report.payload.marketId); + } + console.warn( + "Verifying legacy v1 oracle signature (no network passphrase binding) — cross-network replay risk", + { marketId: report.payload.marketId, event: "oracle.legacy_signature_verified" } + ); + try { + const message = Buffer.from(legacyCanonicalise(report.payload), "utf8"); + const signatureBuffer = Buffer.from(report.signature, "base64"); + const keypair = Keypair.fromPublicKey(report.publicKey); + return keypair.verify(message, signatureBuffer); + } catch { + return false; + } + } + try { const message = Buffer.from( canonicalise(report.payload, networkPassphrase), diff --git a/docs/signature-helper.md b/docs/signature-helper.md index 24597fef..fe8c309d 100644 --- a/docs/signature-helper.md +++ b/docs/signature-helper.md @@ -107,3 +107,29 @@ import { verifyResolutionReport } from "../apps/oracle/signature-helper"; const isValid = verifyResolutionReport(signedReport); ``` + +## Signature Envelope Versioning (#993) + +`SignedResolutionReport` carries a `version` field: + +- **`2`** (current, `CURRENT_SIGNATURE_VERSION`): the envelope described + above — domain **and** network-passphrase separated. `signResolutionReport` + always stamps new reports with `version: 2`. +- **`1`** (legacy) or a missing `version`: a pre-#978 signature, computed + over `{"domain": ..., "payload": ...}` **without** the network passphrase. + These signatures verify identically on any Stellar network, which means a + testnet resolution report can be replayed as a valid mainnet one (or vice + versa) — a cross-network replay attack. + +`verifyResolutionReport` treats `version: 1` / missing `version` as legacy: + +- In `NODE_ENV=production`, it **throws `LegacySignatureRejectedError`** + rather than silently falling back to the weaker legacy check. Production + must never accept a passphrase-less signature. +- Outside production, legacy reports are still verified (using the pre-#978 + canonical form) to support a migration window, and each verification logs + an `oracle.legacy_signature_verified` warning. + +Any oracle key rotation or resigning workflow should re-sign outstanding +legacy reports with `signResolutionReport` (which always emits `version: 2`) +before production cutover. From 44a16abec8d8e7a91709a4be4f243d7b39360ce3 Mon Sep 17 00:00:00 2001 From: Mac-5 Date: Sun, 30 Aug 2026 23:24:37 +0100 Subject: [PATCH 4/4] fix(price-fetcher): add primary/fallback source attribution PriceFetcher.fetchPrice() previously returned a bare number with no way to tell whether it came from a primary or fallback provider, making forensic investigation of a bad price impossible. It now returns a PriceFetchResult { price, source: "primary" | "fallback", sourceMetadata: { provider, requestId }, fetchedAt }, intended to be persisted onto OracleReport.source. Adds a real primary/fallback provider chain (PriceProviderConfig), per-fetch correlation ids in all log lines, and fail-closed behavior: no primaryProvider is required in NODE_ENV=production (constructor throws instead of using the local stub), and AllPriceProvidersFailedError is thrown instead of ever returning a stale/default price. Adds tests and docs/price-fetcher.md updates. --- apps/oracle/price-fetcher.test.ts | 111 +++++++++++++++++- apps/oracle/price-fetcher.ts | 180 +++++++++++++++++++++++++++--- docs/price-fetcher.md | 37 ++++++ 3 files changed, 310 insertions(+), 18 deletions(-) diff --git a/apps/oracle/price-fetcher.test.ts b/apps/oracle/price-fetcher.test.ts index a44eadf4..8339f08c 100644 --- a/apps/oracle/price-fetcher.test.ts +++ b/apps/oracle/price-fetcher.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from "vitest"; -import { PriceFetcher, PriceFetcherValidationError } from "./price-fetcher.js"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + PriceFetcher, + PriceFetcherValidationError, + AllPriceProvidersFailedError, +} from "./price-fetcher.js"; describe("PriceFetcher", () => { const mockLogger = { @@ -39,4 +43,107 @@ describe("PriceFetcher", () => { () => new PriceFetcher(mockLogger, { assetId: "BTC", timeoutMs: 1000 }) ).not.toThrow(); }); + + describe("source attribution (#994)", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("attributes a successful fetch to the primary provider", async () => { + const fetcher = new PriceFetcher(mockLogger, { + assetId: "BTC", + timeoutMs: 1000, + primaryProvider: { name: "coingecko", fetchFn: async () => 42_000 }, + }); + + const result = await fetcher.fetchPrice(); + + expect(result.price).toBe(42_000); + expect(result.source).toBe("primary"); + expect(result.sourceMetadata.provider).toBe("coingecko"); + expect(result.sourceMetadata.requestId).toBeTruthy(); + expect(result.fetchedAt).toBeTruthy(); + }); + + it("attributes a fetch to the fallback provider when primary fails", async () => { + const fetcher = new PriceFetcher(mockLogger, { + assetId: "BTC", + timeoutMs: 1000, + primaryProvider: { + name: "coingecko", + fetchFn: async () => { + throw new Error("primary down"); + }, + }, + fallbackProvider: { name: "pyth", fetchFn: async () => 41_500 }, + }); + + const result = await fetcher.fetchPrice(); + + expect(result.price).toBe(41_500); + expect(result.source).toBe("fallback"); + expect(result.sourceMetadata.provider).toBe("pyth"); + }); + + it("fails closed (throws) when every configured provider fails", async () => { + const fetcher = new PriceFetcher(mockLogger, { + assetId: "BTC", + timeoutMs: 1000, + primaryProvider: { + name: "coingecko", + fetchFn: async () => { + throw new Error("primary down"); + }, + }, + fallbackProvider: { + name: "pyth", + fetchFn: async () => { + throw new Error("fallback down"); + }, + }, + }); + + await expect(fetcher.fetchPrice()).rejects.toBeInstanceOf( + AllPriceProvidersFailedError + ); + }); + + it("fails closed when primary fails and no fallback is configured", async () => { + const fetcher = new PriceFetcher(mockLogger, { + assetId: "BTC", + timeoutMs: 1000, + primaryProvider: { + name: "coingecko", + fetchFn: async () => { + throw new Error("primary down"); + }, + }, + }); + + await expect(fetcher.fetchPrice()).rejects.toBeInstanceOf( + AllPriceProvidersFailedError + ); + }); + + it("requires an explicit primaryProvider in production instead of using the local stub", () => { + vi.stubEnv("NODE_ENV", "production"); + + expect( + () => new PriceFetcher(mockLogger, { assetId: "BTC", timeoutMs: 1000 }) + ).toThrow(PriceFetcherValidationError); + }); + + it("works with an explicit primaryProvider in production", async () => { + vi.stubEnv("NODE_ENV", "production"); + + const fetcher = new PriceFetcher(mockLogger, { + assetId: "BTC", + timeoutMs: 1000, + primaryProvider: { name: "coingecko", fetchFn: async () => 42_000 }, + }); + + const result = await fetcher.fetchPrice(); + expect(result.source).toBe("primary"); + }); + }); }); diff --git a/apps/oracle/price-fetcher.ts b/apps/oracle/price-fetcher.ts index bb394e52..fd5bc3b3 100644 --- a/apps/oracle/price-fetcher.ts +++ b/apps/oracle/price-fetcher.ts @@ -1,8 +1,48 @@ +import { randomUUID } from "node:crypto"; import type { ILogger } from "../../packages/shared/src/logger.js"; +/** + * Which provider actually produced a price. Persisted onto + * `OracleReport.source` (via callers) so forensics can distinguish a + * primary-provider price from a fallback-provider price after the fact + * (#994) — without this, an operator investigating a bad resolution cannot + * tell whether the price came from the trusted primary feed or a + * lower-confidence fallback. + */ +export type PriceSource = "primary" | "fallback"; + +/** + * A single upstream price provider: a name for attribution/logging and the + * function that fetches the price. + */ +export interface PriceProviderConfig { + /** Attribution label, e.g. "coingecko", "pyth". Never a secret. */ + name: string; + fetchFn: () => Promise; +} + export interface PriceFetcherConfig { assetId: string; timeoutMs: number; + /** Primary price provider. Defaults to a local stub outside production. */ + primaryProvider?: PriceProviderConfig; + /** Fallback price provider, used only if the primary fails. */ + fallbackProvider?: PriceProviderConfig; +} + +/** + * Result of a price fetch, always carrying source attribution. + */ +export interface PriceFetchResult { + price: number; + /** Which provider tier produced this price: "primary" or "fallback". */ + source: PriceSource; + /** Attribution metadata — provider name and correlation id for forensics. */ + sourceMetadata: { + provider: string; + requestId: string; + }; + fetchedAt: string; } export class PriceFetcherValidationError extends Error { @@ -13,7 +53,27 @@ export class PriceFetcherValidationError extends Error { } } +/** + * Thrown when both the primary and fallback price providers fail. Fails + * closed: no stale/default price is ever returned in place of a real one. + */ +export class AllPriceProvidersFailedError extends Error { + constructor(assetId: string, requestId: string, cause?: unknown) { + super( + `All price providers failed for asset ${assetId} (requestId=${requestId}): ${ + cause instanceof Error ? cause.message : String(cause) + }` + ); + this.name = "AllPriceProvidersFailedError"; + } +} + +const DEFAULT_STUB_PRICE = 100.5; + export class PriceFetcher { + private readonly primaryProvider: PriceProviderConfig; + private readonly fallbackProvider?: PriceProviderConfig; + constructor( private readonly logger: ILogger, private readonly config: PriceFetcherConfig @@ -32,33 +92,121 @@ export class PriceFetcher { "Invalid timeoutMs: must be a positive number" ); } + + const isProduction = process.env.NODE_ENV === "production"; + + if (config.primaryProvider) { + this.primaryProvider = config.primaryProvider; + } else if (isProduction) { + // Never silently stub a real price feed in production — fail fast at + // construction time instead of returning a fake price later. + throw new PriceFetcherValidationError( + "primaryProvider is required in NODE_ENV=production — no local stub is used" + ); + } else { + this.primaryProvider = { + name: "local-stub-primary", + fetchFn: async () => DEFAULT_STUB_PRICE, + }; + } + + this.fallbackProvider = config.fallbackProvider; } - async fetchPrice(): Promise { + /** + * Fetch the current price for the configured asset, with explicit source + * attribution. Tries the primary provider first; on failure, falls back + * to the fallback provider if one is configured. If every configured + * provider fails, throws `AllPriceProvidersFailedError` — no default or + * stale price is ever silently returned. + */ + async fetchPrice(): Promise { + const requestId = randomUUID(); + this.logger.info("Initiating price fetch", { assetId: this.config.assetId, timeoutMs: this.config.timeoutMs, - timestamp: new Date().toISOString(), + requestId, }); try { - // Mock price fetch implementation - const price = 100.5; - - this.logger.info("Price fetch successful", { + const price = await this.primaryProvider.fetchFn(); + return this.buildResult(price, "primary", this.primaryProvider.name, requestId); + } catch (primaryError) { + this.logger.warn("Primary price provider failed", { assetId: this.config.assetId, - price, - timestamp: new Date().toISOString(), + requestId, + provider: this.primaryProvider.name, + error: + primaryError instanceof Error + ? primaryError.message + : String(primaryError), }); - return price; - } catch (error) { - this.logger.error("Price fetch failed", { - assetId: this.config.assetId, - error: error instanceof Error ? error.message : String(error), - timestamp: new Date().toISOString(), - }); - throw error; + if (!this.fallbackProvider) { + this.logger.error("Price fetch failed — no fallback configured", { + assetId: this.config.assetId, + requestId, + }); + throw new AllPriceProvidersFailedError( + this.config.assetId, + requestId, + primaryError + ); + } + + try { + const price = await this.fallbackProvider.fetchFn(); + this.logger.warn("Price resolved via fallback provider", { + assetId: this.config.assetId, + requestId, + provider: this.fallbackProvider.name, + }); + return this.buildResult( + price, + "fallback", + this.fallbackProvider.name, + requestId + ); + } catch (fallbackError) { + this.logger.error("All price providers failed", { + assetId: this.config.assetId, + requestId, + error: + fallbackError instanceof Error + ? fallbackError.message + : String(fallbackError), + }); + throw new AllPriceProvidersFailedError( + this.config.assetId, + requestId, + fallbackError + ); + } } } + + private buildResult( + price: number, + source: PriceSource, + provider: string, + requestId: string + ): PriceFetchResult { + const result: PriceFetchResult = { + price, + source, + sourceMetadata: { provider, requestId }, + fetchedAt: new Date().toISOString(), + }; + + this.logger.info("Price fetch successful", { + assetId: this.config.assetId, + requestId, + source, + provider, + price, + }); + + return result; + } } diff --git a/docs/price-fetcher.md b/docs/price-fetcher.md index 8039bd10..9c74f34a 100644 --- a/docs/price-fetcher.md +++ b/docs/price-fetcher.md @@ -14,3 +14,40 @@ The component securely and reliably requests live price feeds from registered ex ## Integration The price fetcher results are enqueued into the **Submission Queue** to be later signed and dispatched on-chain. + +## Source Attribution (#994) + +`fetchPrice()` returns a `PriceFetchResult`, not a bare number: + +```typescript +interface PriceFetchResult { + price: number; + source: "primary" | "fallback"; + sourceMetadata: { provider: string; requestId: string }; + fetchedAt: string; +} +``` + +- `source` records which provider **tier** produced the price (`primary` or + `fallback`) so it can be persisted onto `OracleReport.source` and used in + forensics — without this, an operator investigating a bad resolution + could not tell whether the underlying price came from the trusted + primary feed or a lower-confidence fallback. +- `sourceMetadata.provider` is the specific provider name (e.g. + `coingecko`, `pyth`) configured via `PriceProviderConfig.name`. +- `sourceMetadata.requestId` is a per-fetch correlation id (UUID) included + in every log line for that fetch, so a single price fetch can be traced + end-to-end across primary/fallback attempts. + +## Production / Development Split + +`PriceFetcher` requires an explicit `primaryProvider` in +`NODE_ENV=production` — the constructor throws +`PriceFetcherValidationError` immediately if one isn't supplied, rather +than silently using the built-in local stub price. Outside production, a +local stub provider is used automatically when `primaryProvider` is +omitted, so local dev and tests keep working without extra configuration. + +If every configured provider (primary, then fallback) fails, `fetchPrice()` +throws `AllPriceProvidersFailedError` — no default or stale price is ever +returned in its place.