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
39 changes: 39 additions & 0 deletions apps/oracle/fallback-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
21 changes: 17 additions & 4 deletions apps/oracle/fallback-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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;
}

Expand All @@ -95,8 +105,11 @@ export class FallbackAdapter implements ProviderAdapter {
* Each provider is retried per retryConfig before advancing.
*/
async resolve(request: ResolutionRequest): Promise<ProviderResult> {
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) {
Expand Down
29 changes: 29 additions & 0 deletions apps/oracle/oracle-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
45 changes: 45 additions & 0 deletions apps/oracle/oracle-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
Expand All @@ -46,6 +54,13 @@ const VALID_LOG_LEVELS: ReadonlySet<string> = 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<string, string | undefined>;

Expand Down Expand Up @@ -79,16 +94,46 @@ 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,
logLevel,
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,
Expand Down
81 changes: 81 additions & 0 deletions apps/oracle/oracle-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
68 changes: 68 additions & 0 deletions apps/oracle/oracle-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}

/**
Expand Down Expand Up @@ -132,6 +156,7 @@ export class OracleService {
primaryTimeoutMs: DEFAULT_TIMEOUT_MS,
fallbackTimeoutMs: DEFAULT_TIMEOUT_MS,
retryConfig: { maxRetries: 0 },
minConfidenceThreshold: 0.75,
...config,
};
if (isProduction) {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading