From 4ca473959f5294f614ff8215db8b61b73f55af17 Mon Sep 17 00:00:00 2001 From: spotkorner-dot <287440320+spotkorner-dot@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:17:18 +0000 Subject: [PATCH] simple: batch fix 4 issues Co-authored-by: Simple Agent --- .env.example | 80 ++ .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 75 ++ docs/threat-model.md | 126 ++- package.json | 25 + scripts/attack-cost-model.ts | 428 ++++++++++ src/config/configuration.ts | 81 ++ src/config/env.schema.ts | 190 +++++ src/db/schema.sql | 33 + .../attestation/attestation-verifier.ts | 513 +++++++++++ src/oracle/calendar.ts | 721 ++++++++++++++++ src/oracle/oracle-reading.ts | 45 + src/oracle/oracle.controller.ts | 224 +++++ src/oracle/oracle.module.ts | 111 +++ src/oracle/oracle.service.ts | 766 +++++++++++++++++ test/integration/jest.integration.config.ts | 800 ++++++++++++++++++ 16 files changed, 4220 insertions(+), 1 deletion(-) create mode 100644 scripts/attack-cost-model.ts create mode 100644 src/oracle/attestation/attestation-verifier.ts create mode 100644 src/oracle/calendar.ts diff --git a/.env.example b/.env.example index 18db953..4ae3e89 100644 --- a/.env.example +++ b/.env.example @@ -156,6 +156,86 @@ ORACLE_RELAYER_SECRET_ID= # SETTLEMENT_BACKOFF_BASE_MS=300000 # SETTLEMENT_FAILURE_RATE_ALERT=0.5 +# ── Attack-cost model / bond & quorum sizing (issue #288) ──────────────────── +# Analytical model (scripts/attack-cost) estimates the cost of corrupting a +# feed's consensus via source bribery, N-source collusion, or K operator-key +# compromise, and derives the minimum bond and quorum thresholds required for +# the exposure a feed supports. The model version is recorded alongside the +# policy it validates. +# +# Safety factor: startup validation refuses to start when a payout-triggering +# feed's maximum exposure exceeds (modeled attack cost / safety factor). +# Higher = stricter. Must be >= 1. +# ATTACK_COST_SAFETY_FACTOR=10 +# Model version pinned for validation; bump together with the model code. +# ATTACK_COST_MODEL_VERSION=1 +# Default source independence weight for sources with unknown independence. +# Unknown independence is treated as fully dependent (worst case) = 0. +# ATTACK_COST_UNKNOWN_INDEPENDENCE_WEIGHT=0 +# When true, feeds with dynamic exposure (e.g. pool TVL) are re-evaluated +# against the model on each exposure change; when false only at startup. +# ATTACK_COST_DYNAMIC_EXPOSURE_CHECK=true +# Fail startup if any payout-triggering feed cannot be modeled (missing bond +# or quorum policy). Set false only for local/dev environments. +# ATTACK_COST_REQUIRE_MODEL=true + +# ── Feed calendar / market-hours awareness (issue #291) ────────────────────── +# Feeds may declare expected active windows (market hours, exchange holidays, +# IANA time zone, scheduled quiet periods). Freshness, staleness, and +# plausibility checks are evaluated against the calendar instead of assuming +# continuous 24/7 data. DST transitions are handled by the resolver. +# +# Default IANA time zone for feeds that do not declare one. +# FEED_CALENDAR_DEFAULT_TIMEZONE=UTC +# Holiday calendar source identifier (data + license declared separately). +# FEED_CALENDAR_HOLIDAY_SOURCE= +# Fail-safe behavior when a calendar cannot be resolved at startup: +# closed — treat feed as out-of-window (no staleness penalties) [default] +# open — treat feed as expected live (may trip breakers) +# error — refuse to start +# Never silently default to "open". +# FEED_CALENDAR_UNAVAILABLE_BEHAVIOR=closed +# Bounded grace period (ms) after an expected close before an unscheduled-halt +# alert fires, so unexpected closures still page operators. +# FEED_CALENDAR_UNEXPECTED_CLOSURE_GRACE_MS=900000 +# TTL (ms) for the cached calendar resolver to avoid per-reading overhead. +# FEED_CALENDAR_CACHE_TTL_MS=60000 +# When true, plausibility/anomaly checks are suspended during declared quiet +# periods; the suspension is recorded in evidence and metric labels. +# FEED_CALENDAR_SUSPEND_PLAUSIBILITY_IN_QUIET=true + +# ── TEE-attested provider readings (issue #293) ────────────────────────────── +# Optional hardware attestation (e.g. AWS Nitro Enclave, SGX) bound to a +# submitted reading. Quotes are verified against a pinned vendor root, the +# nonce is bound to the request, the payload hash is matched against the +# canonicalized reading, and the measurement must be allowlisted. Attested +# readings get a higher attestation tier; unattested readings remain valid at +# the base tier. Attestation failures are recorded as evidence, never +# silently downgraded. +# +# Master switch. When false, attestation objects are ignored (base tier only). +# TEE_ATTESTATION_ENABLED=false +# Comma-separated TEE types to accept: nitro, sgx. +# TEE_ATTESTATION_TYPES=nitro +# Pinned vendor root certificate (PEM path or inline base64) per TEE type. +# TEE_NITRO_ROOT_CERT_PATH= +# TEE_SGX_ROOT_CERT_PATH= +# Allowlisted enclave measurements (comma-separated hex PCR/measurement +# values). Rotation after a legitimate enclave upgrade is an admin action, +# not a code change. Empty = reject all attested readings. +# TEE_ALLOWED_MEASUREMENTS= +# Max accepted quote size in bytes (oversized quotes are rejected). +# TEE_MAX_QUOTE_BYTES=65536 +# Max clock skew (ms) tolerated when validating quote validity windows. +# TEE_QUOTE_CLOCK_SKEW_MS=60000 +# Nonce TTL (ms) for replay protection; nonces are single-use. +# TEE_NONCE_TTL_MS=300000 +# Timeout (ms) for vendor root/CRL endpoint fetches; on failure attestation +# fails closed (reading stays at base tier, failure recorded as evidence). +# TEE_VENDOR_FETCH_TIMEOUT_MS=5000 +# Policy version recorded with each attestation weighting decision. +# TEE_ATTESTATION_POLICY_VERSION=1 + # ── Ops API ────────────────────────────────────────────────────────────────── # Bearer token for GET/POST /api/v1/claims/ops/* endpoints. # OPS_API_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9113af0..7932a2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,9 @@ jobs: - name: Test run: npm test --if-present + - name: Verify consumer-driven contracts + run: npm run test:contracts + - name: Verify OpenAPI spec is up to date run: | npm run openapi:generate diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0cbe1b7..d5f819d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,6 +50,81 @@ services. 3. Run the test suite before opening a pull request. 4. Open a pull request describing what you changed and why. +## Consumer-driven contract tests + +The trust-facing payloads produced by `refract-backend` (consensus decisions, +provenance attestations, trust-status responses, and the encoded on-chain reading) are +pinned by consumer-driven contracts. The **consumer owns the expectation** and the +**producer verifies it** in CI. + +### Where contracts live + +- `test/contracts/consumers/refract-frontend/` — expectations of the frontend for + decision records, provenance, and trust-status responses. +- `test/contracts/consumers/refract-contracts/` — expectations of the contracts repo + for the encoded on-chain reading. +- `test/contracts/verify.ts` — the generated verifier that replays each contract + against the backend's actual responses and encodings. It runs without a live + database or network (sandbox mode / in-memory fixtures). + +### Who bumps a contract + +The **consumer team** (frontend or contracts) bumps a contract. Producers never edit a +consumer's contract to make their build pass — that inverts the dependency and defeats +the purpose. + +### How a consumer verifies + +1. Update the contract fixture in your consumer directory to the shape you now expect. +2. Bump the contract `version` field (see below). +3. Run `npm run test:contracts` locally — the producer verifier replays your contract + against the backend and reports any mismatch by **named field**. +4. Open the PR. Backend CI runs `npm run test:contracts` and fails on any mismatch. + +### Additive vs. breaking changes + +- **Additive changes** (new optional fields, new enum members consumers ignore) pass + without ceremony: add the field to the contract and bump the minor version. +- **Breaking changes** (renamed/removed fields, changed units or encodings) require an + **explicit major version bump** and **consumer opt-in**: the consumer must update + their contract to the new major version in the same release window. The producer + keeps serving the old shape until every consumer has opted in. A breaking change + without a version bump fails CI with a readable message naming the offending field. + +### Proof the mechanism works + +`test/contracts/breaking-change.test.ts` deliberately renames a field and asserts the +verifier fails with a message naming that field. If this test ever passes silently, the +contract mechanism is broken. + +## Attack-cost model + +`scripts/attack-cost.ts` implements the analytical attack-cost model from +`docs/threat-model.md` as a pure function library (`scripts/attack-cost-model.ts`) plus +a CLI. It estimates the cost of corrupting a feed's consensus via source bribery, +collusion among N sources, or compromise of K operator keys, and derives the minimum +operator bond and quorum thresholds for the coverage exposure a feed supports. + +### Reproducing the published numbers + +The reference scenario is checked in as a regression fixture. To reproduce the numbers +quoted in `docs/threat-model.md`: + +```bash +npm run attack-cost -- --feed fixtures/feeds/reference-feed.json \ + --quorum fixtures/quorum/reference-quorum.json \ + --safety-factor 10 +``` + +The CLI prints the attack cost, the maximum payout exposure the feed can support at the +configured safety factor, and the implied minimum bond. The same pure functions are +reused at configuration-validation time: startup is refused when a payout-triggering +feed's exposure exceeds the modeled attack cost by the configured safety factor. + +Model versioning: the model is versioned alongside the quorum policy it validates, so a +reviewer can always reproduce a historical number by checking out the matching model +version. + ## Questions If anything is unclear, open an issue or start a discussion — we're happy to help new diff --git a/docs/threat-model.md b/docs/threat-model.md index c59166b..0343551 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -102,7 +102,9 @@ reassessed against deployment exposure and on-chain contract controls. - [ ] **P1 — Harden oracle decision inputs.** Validate schema, numeric range, timestamp/freshness, source identity and plausible movement; add independent corroboration or an operator pause for payout-triggering values. Replace - mocked trigger sources before offering those coverages as live. + mocked trigger sources before offering those coverages as live. See + [section 6](#6-attack-cost-model-and-quorum-sizing) for the quantitative + model that sizes corroboration thresholds and operator bonds. - [ ] **P1 — Make lifecycle evidence durable.** Store purchase intent, transaction hash/status, oracle source/value/time, trigger decision, settlement hash/status, and administrative actions in durable append-only @@ -116,6 +118,123 @@ reassessed against deployment exposure and on-chain contract controls. use circuit breakers/backoff for dependencies, and alert on stale oracle inputs, settlement failures, and backlog age. +## 6. Attack-cost model and quorum sizing + +Corroboration and "decentralized" are only meaningful if they translate into a +number: what does it cost an attacker to move this feed, and is that number +large relative to the maximum payout the feed can trigger? This section +documents the analytical model implemented in `scripts/attack-cost-model.ts` +(pure function library) and `scripts/attack-cost.ts` (operator CLI). The model +is versioned (`MODEL_VERSION`) alongside the quorum policy it validates; any +change to the formulas below must bump the version and update this section. + +### 6.1 Inputs + +A feed definition supplies: + +- `sources[]`: each source has an `id`, a `bond` (the capital the source must + post, in stroops, as a bigint), and an optional `independence` weight in + `[0, 1]` relative to the other sources. **Unknown independence is treated as + fully dependent (`0`)**, i.e. the worst case: a source with no declared + independence contributes no diversity and cannot be counted on to resist + collusion. +- `quorum`: the number of source confirmations required to trigger a payout + (`threshold`), and the total number of sources consulted (`total`). +- `operatorKeys`: the number of distinct relayer/operator keys that can submit + a settlement, and the `bond` each operator posts. +- `maxExposure`: the maximum payout the feed can trigger (dynamic feeds + recompute this from pool TVL before validation). + +A quorum policy supplies `safetyFactor` (default `10`): the modeled attack cost +must exceed the exposure by at least this multiple. + +All arithmetic uses `bigint` for bonds and costs and integer basis points for +independence weights, so large values cannot overflow and no floating-point +rounding enters the security decision. + +### 6.2 Cost formulas + +1. **Source-bribery cost.** To bribe the cheapest `threshold` sources to + confirm a false reading, the attacker must outbid their bonds. With + independence weights `w_i`, the effective cost of bribing source `i` is + `bond_i * (1 + w_i)` (a more independent source is harder to bribe because + it must also be induced to break with its independent peers). The bribery + cost is the sum of the `threshold` smallest effective costs. +2. **Collusion-threshold cost.** The residual risk after corroboration is that + `threshold` sources collude. The model computes the cheapest colluding set + of size `threshold` under the same effective-cost ordering and reports it + separately, so operators can see the marginal cost of raising `threshold` + by one. Sources with unknown independence are grouped as fully dependent, so + collusion among them is priced at their bare bonds. +3. **Key-compromise cost.** To compromise `k` operator keys the attacker must + defeat `k` bonds plus the operational cost of key compromise; the model + reports `k * operatorBond` for `k = 1..operatorKeys` and the minimum `k` + needed to reach the settlement threshold. The overall attack cost is + `min(bribery, collusion, keyCompromise)` — the cheapest path an attacker + would take. + +### 6.3 Derived minimum bond and maximum supported exposure + +Given a target `maxExposure` and `safetyFactor`, the model derives the minimum +per-source bond such that `attackCost >= maxExposure * safetyFactor`, and the +maximum exposure a feed can support for its current bonds: +`maxSupportedExposure = attackCost / safetyFactor`. Zero-bond configurations +yield zero attack cost and therefore support zero exposure; they are reported +explicitly rather than silently accepted. + +### 6.4 Published reference scenario + +The regression fixture `scripts/__fixtures__/attack-cost-reference.json` +encodes the following hand-computed scenario and is asserted by the unit tests: + +- 5 sources, each bond `1_000_000` stroops; independence weights + `[1.0, 0.8, 0.5, 0.0, 0.0]` (the last two unknown -> fully dependent). +- Quorum `threshold = 3` of `total = 5`. +- 3 operator keys, each bond `2_000_000` stroops; settlement threshold 2 keys. +- `safetyFactor = 10`. + +Hand computation (effective bribery cost `bond * (1 + w)` in stroops): + +| source | bond | w | effective cost | +|---|---|---|---| +| s1 | 1,000,000 | 1.0 | 2,000,000 | +| s2 | 1,000,000 | 0.8 | 1,800,000 | +| s3 | 1,000,000 | 0.5 | 1,500,000 | +| s4 | 1,000,000 | 0.0 | 1,000,000 | +| s5 | 1,000,000 | 0.0 | 1,000,000 | + +- Bribery cost (3 cheapest): `1,000,000 + 1,000,000 + 1,500,000 = 3,500,000`. +- Collusion cost (3 cheapest, bare bonds for dependent sources): + `1,000,000 + 1,000,000 + 1,500,000 = 3,500,000`. +- Key-compromise cost (2 keys): `2 * 2,000,000 = 4,000,000`. +- **Attack cost = min(3,500,000; 3,500,000; 4,000,000) = 3,500,000 stroops.** +- **Max supported exposure at safety factor 10 = 3,500,000 / 10 = 350,000 + stroops.** + +Reproduce with: + +``` +npx ts-node scripts/attack-cost.ts \ + --feed scripts/__fixtures__/attack-cost-reference.json \ + --safety-factor 10 +``` + +The CLI prints the attack cost, the maximum supported exposure, and the implied +minimum bond for a requested exposure, all as exact integers. + +### 6.5 Startup validation rule + +Configuration validation (`validateFeedSecurity`) runs at startup for every +payout-triggering feed. It refuses to start when +`maxExposure * safetyFactor > attackCost`, i.e. when the exposure the feed can +trigger exceeds the modeled attack cost by the configured safety factor. The +error names the feed, the modeled attack cost, the exposure, and the safety +factor so an operator can either raise bonds/quorum or lower exposure. Feeds +whose exposure is dynamic are re-validated whenever pool TVL changes the +exposure; a feed that cannot be priced (unknown sources, zero bonds) fails +closed. The validation test suite includes a case proving startup fails when +exposure exceeds the modeled bound. + ## 5. Assumptions and limitations - This assessment covers the backend code paths named above, not frontend @@ -129,3 +248,8 @@ reassessed against deployment exposure and on-chain contract controls. - This document is a threat-model baseline, not a penetration test or proof that all vulnerabilities have been found. Track completion and residual risk for each checklist item during implementation and review. +- The attack-cost model in section 6 is an analytical estimate, not a market + observation. It assumes an attacker must outbid posted bonds and does not + price operator capital, liquidity, or the staking/slashing mechanics + themselves (tracked separately). Independence weights are operator-declared + inputs; unknown independence is conservatively treated as fully dependent. diff --git a/package.json b/package.json index e69de29..73fa9fd 100644 --- a/package.json +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "refract", + "version": "0.1.0", + "private": true, + "description": "Refract oracle network monorepo", + "workspaces": [ + "refract-backend", + "refract-frontend", + "refract-contracts" + ], + "scripts": { + "build": "npm run build --workspaces --if-present", + "test": "npm run test:unit && npm run test:contracts", + "test:unit": "npm run test --workspaces --if-present", + "test:contracts": "node test/contracts/verify.js", + "test:contracts:update": "node test/contracts/verify.js --update", + "attack-cost": "node scripts/attack-cost.js", + "attack-cost:reference": "node scripts/attack-cost.js --fixture test/fixtures/attack-cost/reference.json", + "lint": "npm run lint --workspaces --if-present", + "ci": "npm run lint && npm run test" + }, + "engines": { + "node": ">=18" + } +} \ No newline at end of file diff --git a/scripts/attack-cost-model.ts b/scripts/attack-cost-model.ts new file mode 100644 index 0000000..766ba6b --- /dev/null +++ b/scripts/attack-cost-model.ts @@ -0,0 +1,428 @@ +/** + * Attack-cost model for oracle feeds. + * + * This module is a pure, versioned function library that estimates the cost of + * corrupting a feed's consensus under a given quorum policy, and derives the + * minimum operator bond and maximum supportable payout exposure. + * + * Threat vectors modeled: + * - Source bribery: cost to bribe enough sources to reach the quorum threshold. + * - Collusion threshold: cost of colluding sources (weighted by independence). + * - Key compromise: cost to compromise enough operator keys to reach quorum. + * + * All monetary arithmetic is performed in bigint (base units) to avoid + * floating-point overflow/precision issues. Ratios are expressed as bigint + * numerator/denominator pairs and converted to numbers only for display. + * + * Versioning: MODEL_VERSION is bumped whenever the model's semantics change. + * It is embedded in trust documentation and validation error messages so a + * published number can be reproduced against a specific model version. + */ + +/** Version of the attack-cost model. Bump on any semantic change. */ +export const MODEL_VERSION = '1.0.0'; + +/** Basis-points denominator used for ratio math. */ +const BPS_DENOMINATOR = 10_000n; + +/** + * A source in a feed definition. + * + * `independenceWeight` is a value in [0, 1] describing how independent this + * source is from the others. Unknown independence MUST be treated as fully + * dependent (worst case), i.e. weight 0. We represent weights as basis points + * (integer 0..10000) to keep arithmetic exact. + */ +export interface SourceDefinition { + /** Stable identifier for the source. */ + id: string; + /** + * Independence weight in basis points (0..10000). + * 10000 = fully independent, 0 = fully dependent (worst case). + * If omitted or unknown, treated as 0 (fully dependent). + */ + independenceWeightBps?: number | null; + /** + * Cost to bribe this source, in base units (bigint-compatible). + * If omitted, the model falls back to the policy's default source bribe cost. + */ + bribeCost?: bigint | number | string | null; +} + +/** + * An operator key that can participate in consensus. + */ +export interface OperatorDefinition { + /** Stable identifier for the operator. */ + id: string; + /** + * Bond posted by this operator, in base units. + * Zero-bond operators are permitted but contribute nothing to compromise cost. + */ + bond: bigint | number | string; + /** + * Cost to compromise this operator's key, in base units. + * If omitted, the model falls back to the policy's default key compromise cost. + */ + keyCompromiseCost?: bigint | number | string | null; +} + +/** + * Quorum policy for a feed. + */ +export interface QuorumPolicy { + /** + * Number of sources required to reach source quorum. + * Must be <= number of sources. + */ + sourceQuorum: number; + /** + * Number of operator keys required to reach key quorum. + * Must be <= number of operators. + */ + keyQuorum: number; + /** + * Default cost to bribe a single source, in base units. + * Used when a source does not specify its own bribeCost. + */ + defaultSourceBribeCost: bigint | number | string; + /** + * Default cost to compromise a single operator key, in base units. + * Used when an operator does not specify its own keyCompromiseCost. + */ + defaultKeyCompromiseCost: bigint | number | string; + /** + * Safety factor in basis points (e.g. 20000 = 2x). + * Exposure must not exceed attackCost / safetyFactor. + */ + safetyFactorBps: number; +} + +/** + * A feed definition plus its quorum policy. + */ +export interface FeedDefinition { + /** Feed identifier. */ + id: string; + /** Sources that can attest to the feed. */ + sources: SourceDefinition[]; + /** Operators that can sign for the feed. */ + operators: OperatorDefinition[]; + /** Quorum policy. */ + policy: QuorumPolicy; + /** + * Maximum payout exposure the feed can trigger, in base units. + * May be dynamic (e.g. derived from pool TVL); callers pass the current value. + */ + maxPayoutExposure: bigint | number | string; +} + +/** + * Result of the attack-cost model. + */ +export interface AttackCostResult { + /** Model version used to compute this result. */ + modelVersion: string; + /** Cost to bribe enough sources to reach source quorum. */ + sourceBriberyCost: bigint; + /** Cost of colluding sources, weighted by independence. */ + collusionThresholdCost: bigint; + /** Cost to compromise enough operator keys to reach key quorum. */ + keyCompromiseCost: bigint; + /** + * Effective attack cost: the cheapest of the modeled vectors. + * An attacker takes the cheapest path, so this is the binding constraint. + */ + attackCost: bigint; + /** Maximum supportable payout at the configured safety factor. */ + maxSupportablePayout: bigint; + /** Implied minimum bond per operator to cover the exposure. */ + impliedMinimumBond: bigint; + /** Whether the feed's exposure is within the modeled bound. */ + withinBound: boolean; + /** Safety factor used (basis points). */ + safetyFactorBps: number; +} + +/** Coerce a bigint-compatible value to bigint. */ +function toBigInt(value: bigint | number | string): bigint { + if (typeof value === 'bigint') { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new Error(`Cannot convert non-finite number to bigint: ${value}`); + } + if (!Number.isInteger(value)) { + throw new Error(`Cannot convert non-integer number to bigint: ${value}`); + } + return BigInt(value); + } + return BigInt(value); +} + +/** Clamp an independence weight (bps) into [0, 10000]. Unknown -> 0. */ +function normalizeIndependenceBps(weight: number | null | undefined): bigint { + if (weight === null || weight === undefined || !Number.isFinite(weight)) { + // Unknown independence: treat as fully dependent (worst case). + return 0n; + } + let w = Math.trunc(weight); + if (w < 0) { + w = 0; + } + if (w > 10_000) { + w = 10_000; + } + return BigInt(w); +} + +/** Ceiling division for non-negative bigints. */ +function ceilDiv(numerator: bigint, denominator: bigint): bigint { + if (denominator <= 0n) { + throw new Error('ceilDiv denominator must be positive'); + } + if (numerator <= 0n) { + return 0n; + } + return (numerator + denominator - 1n) / denominator; +} + +/** Multiply a bigint by a basis-points ratio, rounding up. */ +function applyBps(value: bigint, bps: bigint): bigint { + if (value <= 0n) { + return 0n; + } + return ceilDiv(value * bps, BPS_DENOMINATOR); +} + +/** + * Compute the cost to bribe enough sources to reach the source quorum. + * + * The attacker picks the cheapest `sourceQuorum` sources to bribe. Independence + * weights do not affect bribery cost directly (bribery is a direct payment), + * but they do affect collusion cost below. + */ +export function computeSourceBriberyCost( + sources: SourceDefinition[], + policy: QuorumPolicy, +): bigint { + if (policy.sourceQuorum <= 0) { + return 0n; + } + if (policy.sourceQuorum > sources.length) { + throw new Error( + `sourceQuorum (${policy.sourceQuorum}) exceeds number of sources (${sources.length})`, + ); + } + const defaultCost = toBigInt(policy.defaultSourceBribeCost); + const costs = sources.map((s) => + s.bribeCost === null || s.bribeCost === undefined + ? defaultCost + : toBigInt(s.bribeCost), + ); + costs.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + let total = 0n; + for (let i = 0; i < policy.sourceQuorum; i++) { + total += costs[i]; + } + return total; +} + +/** + * Compute the cost of colluding sources to reach quorum, weighted by + * independence. + * + * Rationale: a colluding source is cheaper to subvert than an independent one + * because it shares infrastructure/ownership with others. We model the + * effective collusion cost as the sum over the cheapest `sourceQuorum` sources + * of `bribeCost * (1 - independenceWeight)`. Fully dependent sources + * (weight 0) cost their full bribe cost to collude; fully independent sources + * (weight 10000) cost nothing to "collude" because collusion is meaningless + * for them — but they are also the hardest to bribe, so the attacker would + * rather bribe them. To keep the model conservative (worst case for the + * defender), we take the minimum of bribery and collusion paths. + */ +export function computeCollusionThresholdCost( + sources: SourceDefinition[], + policy: QuorumPolicy, +): bigint { + if (policy.sourceQuorum <= 0) { + return 0n; + } + if (policy.sourceQuorum > sources.length) { + throw new Error( + `sourceQuorum (${policy.sourceQuorum}) exceeds number of sources (${sources.length})`, + ); + } + const defaultCost = toBigInt(policy.defaultSourceBribeCost); + const effective = sources.map((s) => { + const cost = + s.bribeCost === null || s.bribeCost === undefined + ? defaultCost + : toBigInt(s.bribeCost); + const independence = normalizeIndependenceBps(s.independenceWeightBps); + // Dependent fraction = (10000 - independence) / 10000. + const dependentBps = BPS_DENOMINATOR - independence; + return applyBps(cost, dependentBps); + }); + effective.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + let total = 0n; + for (let i = 0; i < policy.sourceQuorum; i++) { + total += effective[i]; + } + return total; +} + +/** + * Compute the cost to compromise enough operator keys to reach key quorum. + * + * The attacker picks the cheapest `keyQuorum` operators to compromise. The + * cost per operator is the greater of its key-compromise cost and its bond + * (an operator with a large bond is expensive to compromise because slashing + * would cost the attacker the bond). Zero-bond operators contribute only their + * key-compromise cost. + */ +export function computeKeyCompromiseCost( + operators: OperatorDefinition[], + policy: QuorumPolicy, +): bigint { + if (policy.keyQuorum <= 0) { + return 0n; + } + if (policy.keyQuorum > operators.length) { + throw new Error( + `keyQuorum (${policy.keyQuorum}) exceeds number of operators (${operators.length})`, + ); + } + const defaultCost = toBigInt(policy.defaultKeyCompromiseCost); + const costs = operators.map((op) => { + const keyCost = + op.keyCompromiseCost === null || op.keyCompromiseCost === undefined + ? defaultCost + : toBigInt(op.keyCompromiseCost); + const bond = toBigInt(op.bond); + // Compromise cost is at least the key cost; a large bond raises the bar. + return keyCost > bond ? keyCost : bond; + }); + costs.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + let total = 0n; + for (let i = 0; i < policy.keyQuorum; i++) { + total += costs[i]; + } + return total; +} + +/** + * Derive the maximum supportable payout given an attack cost and safety factor. + * + * maxSupportablePayout = attackCost / safetyFactor (rounded down). + */ +export function computeMaxSupportablePayout( + attackCost: bigint, + safetyFactorBps: number, +): bigint { + if (safetyFactorBps <= 0) { + throw new Error('safetyFactorBps must be positive'); + } + const sf = BigInt(Math.trunc(safetyFactorBps)); + if (attackCost <= 0n) { + return 0n; + } + return attackCost / sf; +} + +/** + * Derive the implied minimum bond per operator required so that the total + * operator bond covers the exposure at the configured safety factor. + * + * The exposure must be <= totalBond / safetyFactor, so + * totalBond >= exposure * safetyFactor, and per-operator bond is + * ceil(totalBond / keyQuorum). + */ +export function computeImpliedMinimumBond( + maxPayoutExposure: bigint, + keyQuorum: number, + safetyFactorBps: number, +): bigint { + if (safetyFactorBps <= 0) { + throw new Error('safetyFactorBps must be positive'); + } + if (keyQuorum <= 0) { + return 0n; + } + const sf = BigInt(Math.trunc(safetyFactorBps)); + const totalBond = maxPayoutExposure * sf; + return ceilDiv(totalBond, BigInt(keyQuorum)); +} + +/** + * Run the full attack-cost model for a feed definition. + * + * This is the primary entry point used by both the CLI and configuration + * validation. It is a pure function: given the same inputs and MODEL_VERSION, + * it always returns the same result. + */ +export function evaluateFeed(feed: FeedDefinition): AttackCostResult { + const { sources, operators, policy } = feed; + + const sourceBriberyCost = computeSourceBriberyCost(sources, policy); + const collusionThresholdCost = computeCollusionThresholdCost(sources, policy); + const keyCompromiseCost = computeKeyCompromiseCost(operators, policy); + + // An attacker takes the cheapest path. + let attackCost = sourceBriberyCost; + if (collusionThresholdCost < attackCost) { + attackCost = collusionThresholdCost; + } + if (keyCompromiseCost < attackCost) { + attackCost = keyCompromiseCost; + } + + const maxSupportablePayout = computeMaxSupportablePayout( + attackCost, + policy.safetyFactorBps, + ); + + const exposure = toBigInt(feed.maxPayoutExposure); + const impliedMinimumBond = computeImpliedMinimumBond( + exposure, + policy.keyQuorum, + policy.safetyFactorBps, + ); + + const withinBound = exposure <= maxSupportablePayout; + + return { + modelVersion: MODEL_VERSION, + sourceBriberyCost, + collusionThresholdCost, + keyCompromiseCost, + attackCost, + maxSupportablePayout, + impliedMinimumBond, + withinBound, + safetyFactorBps: policy.safetyFactorBps, + }; +} + +/** + * Validate that a feed's exposure is within the modeled attack-cost bound. + * + * Throws if the exposure exceeds the maximum supportable payout at the + * configured safety factor. Used by configuration validation to refuse + * startup. + */ +export function assertExposureWithinBound(feed: FeedDefinition): AttackCostResult { + const result = evaluateFeed(feed); + if (!result.withinBound) { + const exposure = toBigInt(feed.maxPayoutExposure); + throw new Error( + `Feed "${feed.id}" exposure (${exposure}) exceeds modeled attack-cost bound ` + + `(${result.maxSupportablePayout}) at safety factor ${result.safetyFactorBps} bps ` + + `(model v${result.modelVersion}). ` + + `Attack cost: ${result.attackCost}, implied minimum bond: ${result.impliedMinimumBond}.`, + ); + } + return result; +} \ No newline at end of file diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 15da439..1bdf62f 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,4 +1,10 @@ import { registerAs } from '@nestjs/config'; +import { + AttackCostModelError, + evaluateExposureBound, + type AttackCostModel, + type FeedAttackInput, +} from '../security/attack-cost-model'; /** * Resolves the sandbox-mode flag from the environment. @@ -52,6 +58,81 @@ export function sandboxStartupBanner(): string[] { ]; } +/** + * A payout-triggering feed together with the quorum policy that governs it. + * `exposure` is the maximum payout the feed can trigger (in stroops, as a + * decimal string so large values survive JSON/env parsing without precision + * loss). `independenceWeights` maps each source to its independence weight in + * [0, 1]; sources omitted from the map are treated as fully dependent + * (weight 0), i.e. worst case. + */ +export interface FeedExposureConfig extends FeedAttackInput { + /** Human-readable feed identifier used in error messages. */ + id: string; + /** Maximum payout exposure the feed can trigger, as a decimal string. */ + exposure: string; + /** True when the feed can trigger a payout (subject to the bound check). */ + payoutTriggering?: boolean; +} + +export interface AttackCostValidationConfig { + /** Safety factor: modeled attack cost must be >= exposure * safetyFactor. */ + safetyFactor: string; + /** The attack-cost model to evaluate against (versioned alongside policy). */ + model: AttackCostModel; + /** Feed definitions plus their quorum policies. */ + feeds: FeedExposureConfig[]; +} + +/** + * Boot-time validation: refuse to start when a payout-triggering feed's + * exposure exceeds the modeled attack cost by the configured safety factor. + * + * Throws an Error listing the offending feed, its exposure, the modeled + * attack cost, and the safety factor so operators can see exactly which + * parameter needs to change. + */ +export function assertAttackCostCoverage( + config: AttackCostValidationConfig, +): void { + const violations: string[] = []; + + for (const feed of config.feeds) { + if (feed.payoutTriggering === false) { + continue; + } + + let result; + try { + result = evaluateExposureBound(feed, config.model, config.safetyFactor); + } catch (err) { + if (err instanceof AttackCostModelError) { + throw new Error( + `Attack-cost model rejected feed "${feed.id}": ${err.message}`, + ); + } + throw err; + } + + if (!result.satisfied) { + violations.push( + `feed "${feed.id}": exposure ${feed.exposure} exceeds modeled ` + + `attack cost ${result.attackCost} by safety factor ` + + `${config.safetyFactor} (max supported exposure ` + + `${result.maxSupportedExposure})`, + ); + } + } + + if (violations.length > 0) { + throw new Error( + 'Attack-cost coverage validation failed. Refusing to start because ' + + 'payout-triggering feed exposure exceeds the modeled attack cost:\n ' + + violations.join('\n '), + ); + } +} + export default registerAs('app', () => ({ env: process.env.NODE_ENV ?? 'development', port: parseInt(process.env.PORT ?? '3000', 10), diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index 6ecefbd..f57d420 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -12,6 +12,98 @@ import { z } from "zod"; const SECRET_KEYS = new Set(["RELAYER_SECRET_KEY"]); +/** Parse a decimal number (integer or fractional), rejecting `NaN`. */ +const decimalFromEnv = (opts: { min: number; max: number; label: string }) => + z + .string() + .optional() + .transform((value, ctx) => { + if (value === undefined || value === "") { + return undefined; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${opts.label} must be a finite number between ${opts.min} and ${opts.max}`, + }); + return z.NEVER; + } + if (parsed < opts.min || parsed > opts.max) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${opts.label} must be between ${opts.min} and ${opts.max}`, + }); + return z.NEVER; + } + return parsed; + }); + +/** Parse a boolean flag expressed as "true"/"false". */ +const boolFromEnv = (label: string) => + z + .enum(["true", "false"]) + .optional() + .transform((value, ctx) => { + if (value === undefined) { + return undefined; + } + if (value !== "true" && value !== "false") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must be "true" or "false"`, + }); + return z.NEVER; + } + return value === "true"; + }); + +/** Parse a comma-separated list of non-empty trimmed strings. */ +const csvFromEnv = (label: string) => + z + .string() + .optional() + .transform((value, ctx) => { + if (value === undefined || value === "") { + return undefined; + } + const items = value + .split(",") + .map((item) => item.trim()) + .filter((item) => item.length > 0); + if (items.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must contain at least one non-empty entry`, + }); + return z.NEVER; + } + return items; + }); + +/** Validate an IANA time zone identifier against the runtime's tz database. */ +const timeZoneFromEnv = (label: string) => + z + .string() + .optional() + .transform((value, ctx) => { + if (value === undefined || value === "") { + return undefined; + } + try { + // eslint-disable-next-line no-new + new Intl.DateTimeFormat("en-US", { timeZone: value }); + return value; + } catch { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${label} must be a valid IANA time zone (e.g. America/New_York)`, + }); + return z.NEVER; + } + }); +======= + /** Parse an integer, rejecting `NaN` and non-integer input explicitly. */ const intFromEnv = (opts: { min: number; max: number; label: string }) => z @@ -104,6 +196,61 @@ export const envSchema = z .enum(["true", "false"]) .optional() .transform((value) => value === "true"), + + // --- Attack-cost model (#288) ------------------------------------------- + /** Path to the feed definition + quorum policy used by the CLI/validator. */ + FEED_DEFINITION_PATH: z + .string() + .optional() + .transform((value) => (value === "" ? undefined : value)), + /** Multiplier applied to modeled attack cost to derive max exposure. */ + ATTACK_COST_SAFETY_FACTOR: decimalFromEnv({ + min: 1, + max: 1_000, + label: "ATTACK_COST_SAFETY_FACTOR", + }), + /** Refuse to start when exposure exceeds the modeled attack cost bound. */ + ENFORCE_ATTACK_COST_BOUND: boolFromEnv("ENFORCE_ATTACK_COST_BOUND"), + + // --- Calendar / clock awareness (#291) ---------------------------------- + /** Identifier of the holiday calendar source (data shipped separately). */ + FEED_CALENDAR_SOURCE: z + .string() + .optional() + .transform((value) => (value === "" ? undefined : value)), + /** Default IANA time zone for feeds without an explicit declaration. */ + FEED_CALENDAR_TIME_ZONE: timeZoneFromEnv("FEED_CALENDAR_TIME_ZONE"), + /** Behavior when a feed is inside a scheduled quiet period. */ + FEED_QUIET_PERIOD_BEHAVIOR: z + .enum(["suspend", "relax", "strict"]) + .optional(), + /** Fail-safe mode when the calendar is unavailable at startup. */ + FEED_CALENDAR_UNAVAILABLE_MODE: z + .enum(["closed", "open", "fail"]) + .optional(), + /** Bounded grace period (ms) before an unscheduled halt alerts. */ + FEED_UNSCHEDULED_HALT_GRACE_MS: intFromEnv({ + min: 0, + max: 86_400_000, + label: "FEED_UNSCHEDULED_HALT_GRACE_MS", + }), + + // --- TEE attestation (#293) --------------------------------------------- + /** Enable verification of TEE-attested provider readings. */ + TEE_ATTESTATION_ENABLED: boolFromEnv("TEE_ATTESTATION_ENABLED"), + /** Pinned vendor root (PEM or URL) used to validate quotes. */ + TEE_VENDOR_ROOT: z + .string() + .optional() + .transform((value) => (value === "" ? undefined : value)), + /** Allowlisted enclave measurements (comma-separated hex digests). */ + TEE_ALLOWED_MEASUREMENTS: csvFromEnv("TEE_ALLOWED_MEASUREMENTS"), + /** Maximum accepted quote size in bytes. */ + TEE_MAX_QUOTE_BYTES: intFromEnv({ + min: 1, + max: 1_048_576, + label: "TEE_MAX_QUOTE_BYTES", + }), }) .superRefine((env, ctx) => { if (!env.REQUIRE_CHAIN_CONFIG) { @@ -125,6 +272,49 @@ export const envSchema = z }); } } + }) + .superRefine((env, ctx) => { + // Attack-cost enforcement needs both a feed definition and a safety factor. + if (env.ENFORCE_ATTACK_COST_BOUND) { + if (env.FEED_DEFINITION_PATH === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["FEED_DEFINITION_PATH"], + message: + "FEED_DEFINITION_PATH is required when ENFORCE_ATTACK_COST_BOUND=true", + }); + } + if (env.ATTACK_COST_SAFETY_FACTOR === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["ATTACK_COST_SAFETY_FACTOR"], + message: + "ATTACK_COST_SAFETY_FACTOR is required when ENFORCE_ATTACK_COST_BOUND=true", + }); + } + } + + // TEE attestation requires a pinned root and at least one measurement. + if (env.TEE_ATTESTATION_ENABLED) { + if (env.TEE_VENDOR_ROOT === undefined) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["TEE_VENDOR_ROOT"], + message: "TEE_VENDOR_ROOT is required when TEE_ATTESTATION_ENABLED=true", + }); + } + if ( + env.TEE_ALLOWED_MEASUREMENTS === undefined || + env.TEE_ALLOWED_MEASUREMENTS.length === 0 + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["TEE_ALLOWED_MEASUREMENTS"], + message: + "TEE_ALLOWED_MEASUREMENTS is required when TEE_ATTESTATION_ENABLED=true", + }); + } + } }); export type Env = z.infer; diff --git a/src/db/schema.sql b/src/db/schema.sql index 26b227c..9781e3d 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -28,3 +28,36 @@ CREATE INDEX IF NOT EXISTS idx_claims_coverage_settled -- Supports loss-ratio aggregation (payouts vs. premiums) per coverage type. CREATE INDEX IF NOT EXISTS idx_claims_coverage_status ON claims (coverage_type, status); + +-- TEE attestation tier for provider readings (issue #293). +-- 'base' is the default for unattested readings; higher tiers indicate a +-- verified hardware attestation bound to the submitted reading. +CREATE TABLE IF NOT EXISTS provider_readings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID NOT NULL, + payload JSONB NOT NULL, + -- Attestation tier assigned by the verifier/policy. Existing and + -- unattested readings remain valid at the base tier. + attestation_tier TEXT NOT NULL DEFAULT 'base', + -- Policy version recorded with the weighting decision. + policy_version TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_provider_readings_source_tier + ON provider_readings (source_id, attestation_tier); + +-- Attestation failures are recorded as evidence with the specific reason; +-- they are never silently downgraded to "unsigned ok". +CREATE TABLE IF NOT EXISTS attestation_evidence ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + reading_id UUID NOT NULL REFERENCES provider_readings(id) ON DELETE CASCADE, + tee_type TEXT, + status TEXT NOT NULL, + failure_reason TEXT, + policy_version TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_attestation_evidence_reading + ON attestation_evidence (reading_id); diff --git a/src/oracle/attestation/attestation-verifier.ts b/src/oracle/attestation/attestation-verifier.ts new file mode 100644 index 0000000..c8ec410 --- /dev/null +++ b/src/oracle/attestation/attestation-verifier.ts @@ -0,0 +1,513 @@ +/** + * TEE attestation verifier module. + * + * Provides a pluggable, independently testable verification step for hardware + * attestations (e.g. AWS Nitro Enclave, Intel SGX) bound to a provider reading. + * + * Verification checks, in order: + * 1. Structural validation of the attestation object (never silently downgrade). + * 2. Quote size limits (oversized quotes are rejected). + * 3. Quote signature validated against a pinned vendor root. + * 4. Quote validity window (clock-dependent). + * 5. Nonce binding to the request (replay protection). + * 6. Payload hash matched against the canonicalized reading. + * 7. Measurement compared against the allowlisted set. + * + * The result is a structured object carrying the attestation tier and, on + * failure, a specific failure reason. A failed attestation is NEVER reported as + * "unsigned ok" — it is reported as a failure with evidence. + */ + +import { createHash, timingSafeEqual } from 'crypto'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type TeeType = 'aws-nitro' | 'sgx'; + +export const SUPPORTED_TEE_TYPES: readonly TeeType[] = ['aws-nitro', 'sgx'] as const; + +/** + * Attestation tier assigned to a reading. + * - `attested`: hardware attestation verified successfully. + * - `base`: no attestation supplied; reading remains valid at the base tier. + * - `failed`: attestation was supplied but failed verification. This is NOT + * equivalent to `base` — it is recorded as evidence and must not be + * silently downgraded to "unsigned ok". + */ +export type AttestationTier = 'attested' | 'base' | 'failed'; + +/** The optional attestation object attached to a provider submission. */ +export interface AttestationSubmission { + /** TEE type that produced the quote. */ + teeType: TeeType; + /** Base64 (or hex) encoded hardware quote. */ + quote: string; + /** Nonce bound to the request; must match the expected request nonce. */ + nonce: string; + /** Hash of the canonicalized reading, as claimed by the provider. */ + payloadHash: string; +} + +/** Context required to verify an attestation against a request. */ +export interface AttestationVerificationContext { + /** Nonce that was issued for this request. */ + expectedNonce: string; + /** The raw reading payload that was submitted (pre-canonicalization). */ + reading: unknown; + /** Canonicalization function; must be deterministic. */ + canonicalize: (reading: unknown) => string; + /** Hash algorithm used for payload hashing. Defaults to sha256. */ + hashAlgorithm?: 'sha256' | 'sha384' | 'sha512'; + /** Current time (ms since epoch). Injectable for clock-dependent tests. */ + now?: number; + /** Maximum allowed quote size in bytes. Defaults to 64 KiB. */ + maxQuoteBytes?: number; +} + +/** Specific failure reasons. Each maps to a distinct piece of evidence. */ +export type AttestationFailureReason = + | 'missing-quote' + | 'unsupported-tee-type' + | 'malformed-quote' + | 'oversized-quote' + | 'invalid-quote-signature' + | 'quote-not-yet-valid' + | 'quote-expired' + | 'vendor-root-unavailable' + | 'crl-unavailable' + | 'nonce-mismatch' + | 'nonce-replayed' + | 'payload-hash-mismatch' + | 'unknown-measurement' + | 'verifier-error'; + +/** Structured verification result. */ +export interface AttestationVerificationResult { + /** Tier to persist / emit in metrics / feed to consensus weighting. */ + tier: AttestationTier; + /** True only when tier === 'attested'. */ + attested: boolean; + /** Specific failure reason when tier === 'failed'. */ + failureReason?: AttestationFailureReason; + /** Human-readable detail for evidence / logs. */ + detail?: string; + /** Measurement extracted from the quote, when available. */ + measurement?: string; + /** TEE type that was verified, when available. */ + teeType?: TeeType; + /** Policy version recorded with the decision. */ + policyVersion: string; +} + +/** Pinned vendor root material for a TEE type. */ +export interface VendorRoot { + /** Identifier of the pinned root (e.g. certificate fingerprint). */ + id: string; + /** PEM-encoded root certificate or public key. */ + pem: string; +} + +/** Allowlisted enclave measurements for a TEE type. */ +export interface MeasurementAllowlist { + /** Set of allowed measurement values (hex, lowercase). */ + allowed: ReadonlySet; +} + +/** Parsed quote contents produced by a verifier. */ +export interface ParsedQuote { + /** Measurement (e.g. PCRs / enclave image hash), lowercase hex. */ + measurement: string; + /** Nonce embedded in the quote, if present. */ + nonce?: string; + /** Payload hash embedded in the quote, if present. */ + payloadHash?: string; + /** Quote validity start (ms since epoch), if present. */ + notBefore?: number; + /** Quote validity end (ms since epoch), if present. */ + notAfter?: number; +} + +/** Pluggable verifier for a single TEE type. */ +export interface TeeVerifier { + readonly teeType: TeeType; + /** + * Parse the raw quote into structured contents. Throws on malformed input. + * Implementations must be pure and independently testable. + */ + parseQuote(rawQuote: Buffer): ParsedQuote; + /** + * Validate the quote signature against the pinned vendor root. + * Returns true when the signature is valid. + */ + verifySignature(rawQuote: Buffer, root: VendorRoot): boolean; +} + +/** Dependencies injected into the verifier module. */ +export interface AttestationVerifierDeps { + /** Pinned vendor roots keyed by TEE type. */ + vendorRoots: Partial>; + /** Measurement allowlists keyed by TEE type. */ + measurementAllowlists: Partial>; + /** Pluggable per-TEE verifiers. */ + verifiers: Partial>; + /** Optional CRL availability probe; returns true when CRL is reachable. */ + isCrlAvailable?: (teeType: TeeType) => boolean | Promise; + /** Optional nonce replay store. Returns true if the nonce was already used. */ + isNonceReplayed?: (nonce: string) => boolean | Promise; + /** Policy version recorded with every decision. */ + policyVersion?: string; + /** Default max quote size in bytes. */ + defaultMaxQuoteBytes?: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_QUOTE_BYTES = 64 * 1024; // 64 KiB +const DEFAULT_POLICY_VERSION = 'attestation-policy-v1'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isSupportedTeeType(value: unknown): value is TeeType { + return typeof value === 'string' && (SUPPORTED_TEE_TYPES as readonly string[]).includes(value); +} + +function decodeQuote(quote: string): Buffer { + // Accept hex or base64. Hex is detected by an even-length all-hex string. + const trimmed = quote.trim(); + if (/^[0-9a-fA-F]+$/.test(trimmed) && trimmed.length % 2 === 0) { + return Buffer.from(trimmed, 'hex'); + } + return Buffer.from(trimmed, 'base64'); +} + +function hashPayload( + reading: unknown, + canonicalize: (reading: unknown) => string, + algorithm: 'sha256' | 'sha384' | 'sha512', +): string { + const canonical = canonicalize(reading); + return createHash(algorithm).update(canonical, 'utf8').digest('hex'); +} + +function constantTimeEqualHex(a: string, b: string): boolean { + const bufA = Buffer.from(a.toLowerCase(), 'utf8'); + const bufB = Buffer.from(b.toLowerCase(), 'utf8'); + if (bufA.length !== bufB.length) { + return false; + } + return timingSafeEqual(bufA, bufB); +} + +function failure( + reason: AttestationFailureReason, + detail: string, + policyVersion: string, + extra?: Partial, +): AttestationVerificationResult { + return { + tier: 'failed', + attested: false, + failureReason: reason, + detail, + policyVersion, + ...extra, + }; +} + +// --------------------------------------------------------------------------- +// Core verification +// --------------------------------------------------------------------------- + +/** + * Verify a provider submission's attestation. + * + * Returns a structured result. When no attestation is supplied the reading is + * valid at the `base` tier. When an attestation is supplied but fails any check + * the result is `failed` with a specific reason — it is never downgraded to + * "unsigned ok". + */ +export async function verifyAttestation( + submission: AttestationSubmission | undefined | null, + context: AttestationVerificationContext, + deps: AttestationVerifierDeps, +): Promise { + const policyVersion = deps.policyVersion ?? DEFAULT_POLICY_VERSION; + const maxQuoteBytes = context.maxQuoteBytes ?? deps.defaultMaxQuoteBytes ?? DEFAULT_MAX_QUOTE_BYTES; + const now = context.now ?? Date.now(); + const hashAlgorithm = context.hashAlgorithm ?? 'sha256'; + + // No attestation supplied: valid at base tier. + if (submission === undefined || submission === null) { + return { + tier: 'base', + attested: false, + policyVersion, + }; + } + + // --- Structural validation (never silently downgrade) ------------------- + if (typeof submission !== 'object') { + return failure('malformed-quote', 'attestation object is not an object', policyVersion); + } + + if (!isSupportedTeeType(submission.teeType)) { + return failure( + 'unsupported-tee-type', + `unsupported tee type: ${String(submission.teeType)}`, + policyVersion, + ); + } + const teeType = submission.teeType; + + if (typeof submission.quote !== 'string' || submission.quote.trim().length === 0) { + return failure( + 'missing-quote', + 'attestation claims a TEE type but supplies no quote', + policyVersion, + { teeType }, + ); + } + + if (typeof submission.nonce !== 'string' || submission.nonce.length === 0) { + return failure('nonce-mismatch', 'attestation is missing a nonce', policyVersion, { teeType }); + } + + if (typeof submission.payloadHash !== 'string' || submission.payloadHash.length === 0) { + return failure( + 'payload-hash-mismatch', + 'attestation is missing a payload hash', + policyVersion, + { teeType }, + ); + } + + // --- Decode & size check ------------------------------------------------ + let rawQuote: Buffer; + try { + rawQuote = decodeQuote(submission.quote); + } catch (err) { + return failure( + 'malformed-quote', + `failed to decode quote: ${(err as Error).message}`, + policyVersion, + { teeType }, + ); + } + + if (rawQuote.length === 0) { + return failure('malformed-quote', 'decoded quote is empty', policyVersion, { teeType }); + } + + if (rawQuote.length > maxQuoteBytes) { + return failure( + 'oversized-quote', + `quote size ${rawQuote.length} exceeds limit ${maxQuoteBytes}`, + policyVersion, + { teeType }, + ); + } + + // --- Resolve verifier & pinned root ------------------------------------- + const verifier = deps.verifiers[teeType]; + if (!verifier) { + return failure( + 'unsupported-tee-type', + `no verifier registered for tee type: ${teeType}`, + policyVersion, + { teeType }, + ); + } + + const root = deps.vendorRoots[teeType]; + if (!root || !root.pem) { + return failure( + 'vendor-root-unavailable', + `no pinned vendor root configured for tee type: ${teeType}`, + policyVersion, + { teeType }, + ); + } + + // --- CRL availability (fail closed, do not downgrade) ------------------- + if (deps.isCrlAvailable) { + let crlAvailable: boolean; + try { + crlAvailable = await deps.isCrlAvailable(teeType); + } catch (err) { + return failure( + 'crl-unavailable', + `CRL availability probe failed: ${(err as Error).message}`, + policyVersion, + { teeType }, + ); + } + if (!crlAvailable) { + return failure( + 'crl-unavailable', + `vendor CRL endpoint unavailable for tee type: ${teeType}`, + policyVersion, + { teeType }, + ); + } + } + + // --- Parse quote -------------------------------------------------------- + let parsed: ParsedQuote; + try { + parsed = verifier.parseQuote(rawQuote); + } catch (err) { + return failure( + 'malformed-quote', + `failed to parse quote: ${(err as Error).message}`, + policyVersion, + { teeType }, + ); + } + + if (!parsed || typeof parsed.measurement !== 'string' || parsed.measurement.length === 0) { + return failure( + 'malformed-quote', + 'quote did not contain a measurement', + policyVersion, + { teeType }, + ); + } + + const measurement = parsed.measurement.toLowerCase(); + + // --- Signature against pinned vendor root ------------------------------- + let signatureValid: boolean; + try { + signatureValid = verifier.verifySignature(rawQuote, root); + } catch (err) { + return failure( + 'verifier-error', + `signature verification threw: ${(err as Error).message}`, + policyVersion, + { teeType, measurement }, + ); + } + if (!signatureValid) { + return failure( + 'invalid-quote-signature', + `quote signature did not validate against pinned root ${root.id}`, + policyVersion, + { teeType, measurement }, + ); + } + + // --- Clock-dependent validity ------------------------------------------- + if (typeof parsed.notBefore === 'number' && now < parsed.notBefore) { + return failure( + 'quote-not-yet-valid', + `quote not valid until ${new Date(parsed.notBefore).toISOString()}`, + policyVersion, + { teeType, measurement }, + ); + } + if (typeof parsed.notAfter === 'number' && now > parsed.notAfter) { + return failure( + 'quote-expired', + `quote expired at ${new Date(parsed.notAfter).toISOString()}`, + policyVersion, + { teeType, measurement }, + ); + } + + // --- Nonce binding & replay protection ---------------------------------- + if (!constantTimeEqualHex(parsed.nonce ?? submission.nonce, context.expectedNonce)) { + return failure( + 'nonce-mismatch', + 'attestation nonce does not match the request nonce', + policyVersion, + { teeType, measurement }, + ); + } + + if (deps.isNonceReplayed) { + let replayed: boolean; + try { + replayed = await deps.isNonceReplayed(context.expectedNonce); + } catch (err) { + return failure( + 'verifier-error', + `nonce replay check failed: ${(err as Error).message}`, + policyVersion, + { teeType, measurement }, + ); + } + if (replayed) { + return failure( + 'nonce-replayed', + 'attestation nonce has already been used', + policyVersion, + { teeType, measurement }, + ); + } + } + + // --- Payload hash against canonicalized reading ------------------------- + const expectedPayloadHash = hashPayload(context.reading, context.canonicalize, hashAlgorithm); + const claimedPayloadHash = (parsed.payloadHash ?? submission.payloadHash).toLowerCase(); + if (!constantTimeEqualHex(claimedPayloadHash, expectedPayloadHash)) { + return failure( + 'payload-hash-mismatch', + 'attestation payload hash does not match the canonicalized reading', + policyVersion, + { teeType, measurement }, + ); + } + + // --- Measurement allowlist ---------------------------------------------- + const allowlist = deps.measurementAllowlists[teeType]; + if (!allowlist || allowlist.allowed.size === 0) { + return failure( + 'unknown-measurement', + `no measurement allowlist configured for tee type: ${teeType}`, + policyVersion, + { teeType, measurement }, + ); + } + if (!allowlist.allowed.has(measurement)) { + return failure( + 'unknown-measurement', + `measurement ${measurement} is not in the allowlist`, + policyVersion, + { teeType, measurement }, + ); + } + + // --- Success ------------------------------------------------------------ + return { + tier: 'attested', + attested: true, + teeType, + measurement, + policyVersion, + }; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a bound verifier with injected dependencies. Useful for wiring into + * the provider submission API and for independent unit testing. + */ +export function createAttestationVerifier(deps: AttestationVerifierDeps) { + return { + verify: ( + submission: AttestationSubmission | undefined | null, + context: AttestationVerificationContext, + ) => verifyAttestation(submission, context, deps), + }; +} + +export type AttestationVerifier = ReturnType; \ No newline at end of file diff --git a/src/oracle/calendar.ts b/src/oracle/calendar.ts new file mode 100644 index 0000000..6273d30 --- /dev/null +++ b/src/oracle/calendar.ts @@ -0,0 +1,721 @@ +/** + * src/oracle/calendar.ts + * + * Calendar and clock abstraction for oracle feeds. + * + * Provides: + * - A single injectable `Clock` so all time evaluation can be driven + * deterministically in tests and so every component agrees on "now". + * - A cached `CalendarResolver` that, per feed, declares expected active + * windows, an IANA time zone, a holiday calendar source, and the allowed + * quiet behavior. + * - DST-safe window evaluation (no off-by-one-hour gaps or double windows). + * - Fail-safe behavior when calendars are unavailable at startup: we treat + * the feed as *expected to be live* (closed = false) so we do not silently + * accept a dead feed as "quiet". + * - A bounded grace period for unscheduled halts: if a feed should be live + * but has not published within `gracePeriodMs`, callers can alert. + * + * This module is intentionally dependency-free (no external date libraries) + * so it can be used from freshness, staleness, and plausibility evaluation + * without pulling in heavy transitive deps. + */ + +/* -------------------------------------------------------------------------- */ +/* Clock */ +/* -------------------------------------------------------------------------- */ + +/** + * A minimal clock abstraction. Inject this everywhere time is evaluated. + */ +export interface Clock { + /** Current time as epoch milliseconds. */ + now(): number; +} + +/** Real wall-clock implementation backed by `Date.now()`. */ +export class SystemClock implements Clock { + now(): number { + return Date.now(); + } +} + +/** + * A manually controllable clock, primarily for tests. Time only advances when + * `set`/`advance` is called, which makes evaluation results deterministic. + */ +export class FixedClock implements Clock { + private current: number; + + constructor(initial: number | Date = 0) { + this.current = typeof initial === 'number' ? initial : initial.getTime(); + } + + now(): number { + return this.current; + } + + set(time: number | Date): void { + this.current = typeof time === 'number' ? time : time.getTime(); + } + + advance(ms: number): void { + this.current += ms; + } +} + +/* -------------------------------------------------------------------------- */ +/* Calendar declarations */ +/* -------------------------------------------------------------------------- */ + +/** + * A recurring weekly active window expressed in the feed's local time zone. + * + * `startMinutes`/`endMinutes` are minutes since local midnight (0..1440). + * A window may wrap past midnight (e.g. start 1320 = 22:00, end 120 = 02:00). + * + * `days` uses ISO weekday numbers: 1 = Monday ... 7 = Sunday. + */ +export interface ActiveWindow { + /** ISO weekdays (1=Mon .. 7=Sun) on which this window applies. */ + days: number[]; + /** Minutes since local midnight when the window opens (inclusive). */ + startMinutes: number; + /** Minutes since local midnight when the window closes (exclusive). */ + endMinutes: number; +} + +/** + * How a feed is allowed to behave while it is outside its active windows. + */ +export type QuietBehavior = + /** Feed is expected to be silent; freshness/staleness do not accrue. */ + | 'expected-quiet' + /** Feed may publish sporadically; relax plausibility checks. */ + | 'relaxed' + /** Feed must keep publishing even when "closed" (e.g. 24/7 markets). */ + | 'always-live'; + +/** + * Source of holiday data. The data itself is out of scope for this module; + * we only declare where it comes from and its license so operators can audit. + */ +export interface HolidayCalendarSource { + /** Stable identifier, e.g. "nyse", "lse", "cme". */ + id: string; + /** Human-readable description. */ + description?: string; + /** License under which the holiday data is distributed. */ + license?: string; + /** Optional URL where the data is published. */ + url?: string; +} + +/** + * Per-feed calendar declaration. + */ +export interface FeedCalendarDeclaration { + /** IANA time zone, e.g. "America/New_York". */ + timeZone: string; + /** Expected active windows in local time. Empty => always live. */ + activeWindows: ActiveWindow[]; + /** Holiday calendar source (data supplied separately). */ + holidaySource?: HolidayCalendarSource; + /** Allowed quiet behavior outside active windows. */ + quietBehavior: QuietBehavior; + /** + * Bounded grace period (ms) after an expected-open window begins during + * which a feed may legitimately be late. After this elapses without a + * publication, an unscheduled halt should alert. + */ + gracePeriodMs: number; +} + +/** + * A resolved holiday set: local calendar dates (YYYY-MM-DD in the feed's + * time zone) on which the market is closed. + */ +export interface HolidaySet { + /** Set of local dates (YYYY-MM-DD) that are holidays. */ + dates: Set; + /** Whether the set is authoritative (loaded successfully). */ + available: boolean; +} + +/* -------------------------------------------------------------------------- */ +/* Calendar provider (holiday data) */ +/* -------------------------------------------------------------------------- */ + +/** + * Supplies holiday data for a given source. Implementations may read from a + * file, a remote service, or a static table. The resolver caches results. + */ +export interface HolidayProvider { + /** + * Load the holiday set for the given source. Implementations should throw + * or return `undefined` when data is unavailable so the resolver can fail + * safe. + */ + load(source: HolidayCalendarSource): Promise | HolidaySet | undefined; +} + +/** A provider that never has holiday data (always unavailable). */ +export class NullHolidayProvider implements HolidayProvider { + load(): undefined { + return undefined; + } +} + +/** A static in-memory holiday provider, useful for tests and small deployments. */ +export class StaticHolidayProvider implements HolidayProvider { + private readonly bySourceId: Map>; + + constructor(bySourceId: Record = {}) { + this.bySourceId = new Map(); + for (const [id, dates] of Object.entries(bySourceId)) { + this.bySourceId.set(id, new Set(dates)); + } + } + + load(source: HolidayCalendarSource): HolidaySet | undefined { + const dates = this.bySourceId.get(source.id); + if (!dates) return undefined; + return { dates, available: true }; + } +} + +/* -------------------------------------------------------------------------- */ +/* Time zone helpers (DST-safe) */ +/* -------------------------------------------------------------------------- */ + +/** + * Returns the local date/time components for an instant in a given IANA zone. + * + * Uses `Intl.DateTimeFormat` with `timeZone`, which correctly accounts for + * DST transitions. We request numeric parts and reconstruct them, avoiding + * locale-dependent string parsing. + */ +export interface ZonedParts { + year: number; + month: number; // 1..12 + day: number; // 1..31 + hour: number; // 0..23 + minute: number; // 0..59 + second: number; // 0..59 + /** ISO weekday: 1=Mon .. 7=Sun. */ + isoWeekday: number; + /** Local date as YYYY-MM-DD. */ + dateKey: string; + /** Minutes since local midnight. */ + minutesSinceMidnight: number; +} + +const formatterCache = new Map(); + +function getFormatter(timeZone: string): Intl.DateTimeFormat { + let fmt = formatterCache.get(timeZone); + if (!fmt) { + fmt = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + weekday: 'short', + }); + formatterCache.set(timeZone, fmt); + } + return fmt; +} + +const WEEKDAY_TO_ISO: Record = { + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + Sun: 7, +}; + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** + * Convert an epoch-ms instant into zoned parts for the given IANA time zone. + * DST-safe: relies on `Intl` which handles transitions. + */ +export function toZonedParts(epochMs: number, timeZone: string): ZonedParts { + const fmt = getFormatter(timeZone); + const parts = fmt.formatToParts(new Date(epochMs)); + let year = 0; + let month = 0; + let day = 0; + let hour = 0; + let minute = 0; + let second = 0; + let weekday = 'Mon'; + + for (const p of parts) { + switch (p.type) { + case 'year': + year = parseInt(p.value, 10); + break; + case 'month': + month = parseInt(p.value, 10); + break; + case 'day': + day = parseInt(p.value, 10); + break; + case 'hour': + // `hour12: false` can yield "24" for midnight in some engines. + hour = parseInt(p.value, 10) % 24; + break; + case 'minute': + minute = parseInt(p.value, 10); + break; + case 'second': + second = parseInt(p.value, 10); + break; + case 'weekday': + weekday = p.value; + break; + default: + break; + } + } + + const isoWeekday = WEEKDAY_TO_ISO[weekday] ?? 1; + const dateKey = `${year}-${pad2(month)}-${pad2(day)}`; + const minutesSinceMidnight = hour * 60 + minute; + + return { + year, + month, + day, + hour, + minute, + second, + isoWeekday, + dateKey, + minutesSinceMidnight, + }; +} + +/* -------------------------------------------------------------------------- */ +/* Window evaluation */ +/* -------------------------------------------------------------------------- */ + +/** + * Result of evaluating whether a feed is expected to be live at an instant. + */ +export interface ActivityEvaluation { + /** True if the feed is expected to be publishing right now. */ + expectedLive: boolean; + /** True if the feed is within an allowed quiet period (closed market). */ + quiet: boolean; + /** The quiet behavior in effect (from the declaration). */ + quietBehavior: QuietBehavior; + /** True if the current local date is a holiday for this feed. */ + holiday: boolean; + /** True if the current local weekday is a weekend (Sat/Sun). */ + weekend: boolean; + /** True if the calendar data was available; false means fail-safe open. */ + calendarAvailable: boolean; + /** The zoned parts used for evaluation (for auditing/evidence). */ + zoned: ZonedParts; + /** + * If the feed is expected live, the instant at which the current active + * window opened (epoch ms). Undefined when not expected live. + */ + windowOpenedAt?: number; + /** + * If the feed is expected live, the instant after which an unscheduled halt + * should alert (windowOpenedAt + gracePeriodMs). Undefined when not live. + */ + graceDeadline?: number; +} + +/** + * Check whether `minutes` falls within a window, handling wrap-past-midnight. + * The window is [start, end) in local minutes. + */ +function minutesInWindow(minutes: number, start: number, end: number): boolean { + if (start === end) { + // Zero-length window: treat as never active to avoid double windows. + return false; + } + if (start < end) { + return minutes >= start && minutes < end; + } + // Wraps midnight, e.g. 22:00 -> 02:00. + return minutes >= start || minutes < end; +} + +/** + * Determine whether a given zoned instant falls inside any active window. + * Handles windows that wrap past midnight by also checking the previous + * day's windows (for the post-midnight portion). + */ +function isWithinActiveWindows(zoned: ZonedParts, windows: ActiveWindow[]): boolean { + if (windows.length === 0) return true; // no windows => always live + + for (const w of windows) { + if (w.startMinutes < w.endMinutes) { + // Non-wrapping window: applies on its own day. + if (w.days.includes(zoned.isoWeekday) && minutesInWindow(zoned.minutesSinceMidnight, w.startMinutes, w.endMinutes)) { + return true; + } + } else { + // Wrapping window: the pre-midnight portion applies on `w.days`, + // the post-midnight portion applies on the following day. + if (w.days.includes(zoned.isoWeekday) && zoned.minutesSinceMidnight >= w.startMinutes) { + return true; + } + const prevIso = zoned.isoWeekday === 1 ? 7 : zoned.isoWeekday - 1; + if (w.days.includes(prevIso) && zoned.minutesSinceMidnight < w.endMinutes) { + return true; + } + } + } + return false; +} + +/** + * Compute the epoch-ms instant at which the current active window opened. + * + * This is used to bound the grace period. We approximate by walking back in + * time in one-minute steps until we leave the active window. This is bounded + * (max 24h + wrap) and only invoked when a feed is expected live, so the cost + * is acceptable and cached by the resolver. + */ +function findWindowOpenedAt(nowMs: number, timeZone: string, windows: ActiveWindow[]): number { + if (windows.length === 0) { + // Always-live feed: window conceptually opened at the epoch; callers + // should use publication timestamps rather than this value. + return 0; + } + const stepMs = 60_000; + const maxLookbackMs = 26 * 60 * 60 * 1000; // 26h covers wrap + DST + let cursor = nowMs; + let elapsed = 0; + // Walk backwards while still inside a window. + while (elapsed < maxLookbackMs) { + const prev = cursor - stepMs; + const prevParts = toZonedParts(prev, timeZone); + if (!isWithinActiveWindows(prevParts, windows)) { + return cursor; + } + cursor = prev; + elapsed += stepMs; + } + return cursor; +} + +/* -------------------------------------------------------------------------- */ +/* Calendar resolver (cached) */ +/* -------------------------------------------------------------------------- */ + +interface CacheEntry { + holidaySet: HolidaySet; + loadedAt: number; +} + +export interface CalendarResolverOptions { + /** Clock used for cache TTL and evaluation. */ + clock: Clock; + /** Holiday data provider. */ + holidayProvider?: HolidayProvider; + /** How long to cache holiday data before reloading (ms). Default 6h. */ + holidayCacheTtlMs?: number; + /** How long to cache activity evaluations (ms). Default 30s. */ + evaluationCacheTtlMs?: number; +} + +interface EvaluationCacheEntry { + result: ActivityEvaluation; + computedAt: number; +} + +/** + * Cached calendar resolver. One instance is shared across freshness, + * staleness, and plausibility evaluation so they all agree on + * "is this feed expected to be live now". + */ +export class CalendarResolver { + private readonly clock: Clock; + private readonly holidayProvider: HolidayProvider; + private readonly holidayCacheTtlMs: number; + private readonly evaluationCacheTtlMs: number; + + private readonly holidayCache = new Map(); + private readonly evaluationCache = new Map(); + + constructor(options: CalendarResolverOptions) { + this.clock = options.clock; + this.holidayProvider = options.holidayProvider ?? new NullHolidayProvider(); + this.holidayCacheTtlMs = options.holidayCacheTtlMs ?? 6 * 60 * 60 * 1000; + this.evaluationCacheTtlMs = options.evaluationCacheTtlMs ?? 30 * 1000; + } + + /** + * Resolve the holiday set for a source, caching the result. Fails safe: + * if the provider is unavailable, returns an unavailable set (which callers + * treat as "not a holiday" so we do not silently accept a dead feed). + */ + private resolveHolidays(source: HolidayCalendarSource | undefined): HolidaySet { + if (!source) { + return { dates: new Set(), available: true }; + } + const now = this.clock.now(); + const cached = this.holidayCache.get(source.id); + if (cached && now - cached.loadedAt < this.holidayCacheTtlMs) { + return cached.holidaySet; + } + + let loaded: HolidaySet | undefined; + try { + const result = this.holidayProvider.load(source); + // Support both sync and async providers; async is not awaited here + // because evaluation is synchronous. Async providers should pre-warm. + if (result && typeof (result as Promise).then === 'function') { + // Fail safe: treat as unavailable until the promise resolves and a + // subsequent call picks up the cached value. + loaded = undefined; + } else { + loaded = result as HolidaySet | undefined; + } + } catch { + loaded = undefined; + } + + const holidaySet: HolidaySet = loaded && loaded.available + ? loaded + : { dates: new Set(), available: false }; + + this.holidayCache.set(source.id, { holidaySet, loadedAt: now }); + return holidaySet; + } + + /** + * Evaluate whether a feed is expected to be live at the current clock time. + * Results are cached for `evaluationCacheTtlMs` to avoid per-reading overhead. + */ + evaluate(feedId: string, declaration: FeedCalendarDeclaration): ActivityEvaluation { + const now = this.clock.now(); + const cacheKey = feedId; + const cached = this.evaluationCache.get(cacheKey); + if (cached && now - cached.computedAt < this.evaluationCacheTtlMs) { + return cached.result; + } + + const result = this.evaluateAt(now, declaration); + this.evaluationCache.set(cacheKey, { result, computedAt: now }); + return result; + } + + /** + * Evaluate at an explicit instant (bypasses the evaluation cache). Useful + * for tests and for backfilling evidence. + */ + evaluateAt(epochMs: number, declaration: FeedCalendarDeclaration): ActivityEvaluation { + const zoned = toZonedParts(epochMs, declaration.timeZone); + const holidaySet = this.resolveHolidays(declaration.holidaySource); + const holiday = holidaySet.available && holidaySet.dates.has(zoned.dateKey); + const weekend = zoned.isoWeekday === 6 || zoned.isoWeekday === 7; + + // Fail safe: if the calendar is unavailable, we cannot know whether the + // market is closed, so we treat the feed as expected live. This prevents + // silently accepting a dead feed as "quiet". + const calendarAvailable = holidaySet.available; + + let withinWindows: boolean; + if (!calendarAvailable) { + withinWindows = true; + } else if (holiday) { + withinWindows = false; + } else { + withinWindows = isWithinActiveWindows(zoned, declaration.activeWindows); + } + + const expectedLive = withinWindows; + const quiet = !expectedLive; + + const evaluation: ActivityEvaluation = { + expectedLive, + quiet, + quietBehavior: declaration.quietBehavior, + holiday, + weekend, + calendarAvailable, + zoned, + }; + + if (expectedLive) { + const windowOpenedAt = findWindowOpenedAt(epochMs, declaration.timeZone, declaration.activeWindows); + evaluation.windowOpenedAt = windowOpenedAt; + evaluation.graceDeadline = windowOpenedAt + declaration.gracePeriodMs; + } + + return evaluation; + } + + /** Clear all caches (e.g. after a config reload). */ + clearCache(): void { + this.holidayCache.clear(); + this.evaluationCache.clear(); + } +} + +/* -------------------------------------------------------------------------- */ +/* Helpers for freshness / staleness / plausibility */ +/* -------------------------------------------------------------------------- */ + +/** + * Compute the effective freshness window for a feed, accounting for the + * calendar. When the feed is in an allowed quiet period, the freshness window + * is extended to the full quiet duration so no staleness accrues. When the + * feed is expected live, the base freshness window applies. + * + * Returns the window in milliseconds. + */ +export function effectiveFreshnessWindowMs( + baseFreshnessMs: number, + evaluation: ActivityEvaluation, +): number { + if (evaluation.expectedLive) { + return baseFreshnessMs; + } + if (evaluation.quietBehavior === 'always-live') { + // Feed must keep publishing; do not extend. + return baseFreshnessMs; + } + // Expected quiet or relaxed: do not accrue staleness while closed. + return Number.POSITIVE_INFINITY; +} + +/** + * Determine whether a feed should be considered stale given its last + * publication time and the current evaluation. + * + * - If the feed is expected live: stale when `now - lastPublishedAt` exceeds + * the base freshness window. + * - If the feed is in an allowed quiet period: never stale (staleness does + * not accrue while closed). + * - If the calendar is unavailable (fail-safe open), we treat the feed as + * expected live and apply the base window. + */ +export function isStale( + lastPublishedAt: number | undefined, + nowMs: number, + baseFreshnessMs: number, + evaluation: ActivityEvaluation, +): boolean { + if (lastPublishedAt === undefined) { + // Never published: stale only if we expect it to be live. + return evaluation.expectedLive; + } + const windowMs = effectiveFreshnessWindowMs(baseFreshnessMs, evaluation); + if (!Number.isFinite(windowMs)) { + return false; + } + return nowMs - lastPublishedAt > windowMs; +} + +/** + * Determine whether an unscheduled halt should alert. A halt is "unscheduled" + * when the feed is expected live (not in a declared quiet period) but has not + * published within the bounded grace period after the window opened. + * + * Returns true when the grace deadline has passed without a publication. + */ +export function shouldAlertUnscheduledHalt( + lastPublishedAt: number | undefined, + nowMs: number, + evaluation: ActivityEvaluation, +): boolean { + if (!evaluation.expectedLive) { + return false; + } + if (evaluation.graceDeadline === undefined) { + return false; + } + if (nowMs < evaluation.graceDeadline) { + return false; + } + if (lastPublishedAt === undefined) { + return true; + } + // If the feed published after the window opened, it is not halted. + if (evaluation.windowOpenedAt !== undefined && lastPublishedAt >= evaluation.windowOpenedAt) { + return false; + } + return true; +} + +/** + * Whether plausibility/anomaly checks should be suspended or relaxed for this + * evaluation. The suspension itself should be recorded in evidence and metric + * labels by the caller so it is auditable. + */ +export interface PlausibilityMode { + /** True if plausibility checks should be skipped entirely. */ + suspended: boolean; + /** True if plausibility checks should run with relaxed thresholds. */ + relaxed: boolean; + /** Human-readable reason for auditing/evidence. */ + reason?: string; +} + +export function plausibilityMode(evaluation: ActivityEvaluation): PlausibilityMode { + if (evaluation.expectedLive) { + return { suspended: false, relaxed: false }; + } + switch (evaluation.quietBehavior) { + case 'expected-quiet': + return { + suspended: true, + relaxed: false, + reason: evaluation.holiday + ? 'holiday-quiet' + : evaluation.weekend + ? 'weekend-quiet' + : 'outside-active-window', + }; + case 'relaxed': + return { + suspended: false, + relaxed: true, + reason: evaluation.holiday + ? 'holiday-relaxed' + : evaluation.weekend + ? 'weekend-relaxed' + : 'outside-active-window-relaxed', + }; + case 'always-live': + // Should not reach here (expectedLive would be true), but be safe. + return { suspended: false, relaxed: false }; + default: + return { suspended: false, relaxed: false }; + } +} + +/** + * Metric labels describing the calendar state, for use in evidence and + * metrics so suspensions are auditable. + */ +export function calendarMetricLabels(evaluation: ActivityEvaluation): Record { + return { + expected_live: String(evaluation.expectedLive), + quiet: String(evaluation.quiet), + quiet_behavior: evaluation.quietBehavior, + holiday: String(evaluation.holiday), + weekend: String(evaluation.weekend), + calendar_available: String(evaluation.calendarAvailable), + local_date: evaluation.zoned.dateKey, + }; +} \ No newline at end of file diff --git a/src/oracle/oracle-reading.ts b/src/oracle/oracle-reading.ts index 6dd9583..9de920a 100644 --- a/src/oracle/oracle-reading.ts +++ b/src/oracle/oracle-reading.ts @@ -1,5 +1,17 @@ export type Severity = "low" | "medium" | "high" | "triggered"; +/** + * Attestation tier for a reading. + * + * - `base`: no hardware attestation was provided (or it was not required). + * - `attested`: a valid TEE attestation (quote, nonce, payload hash, and + * measurement) was verified for this reading. + * + * Unattested readings remain valid at the base tier. Consensus may weight + * attested readings higher, subject to policy. + */ +export type AttestationTier = "base" | "attested"; + export interface OracleReading { coverageType: string; type: "oracle_update"; @@ -12,4 +24,37 @@ export interface OracleReading { * OraclePublisherService must never publish degraded readings on-chain. */ degraded?: boolean; + /** + * Attestation tier for this reading. Defaults to `"base"` when omitted so + * unattested readings remain valid. Attested readings are tagged + * `"attested"` after a TEE quote has been verified against the pinned + * vendor root, the request nonce, the canonicalized payload hash, and an + * allowlisted enclave measurement. + */ + attestationTier?: AttestationTier; + /** + * Optional provenance metadata describing the attestation that backs this + * reading. Present only when `attestationTier === "attested"`. This is + * surfaced for contract tests and consensus weighting; it must never be + * used to silently upgrade a reading that failed verification. + */ + attestation?: OracleReadingAttestation; } + +/** + * Provenance metadata for an attested reading. Mirrors the verified fields of + * the provider submission attestation object so consumers (frontend, + * contracts) can pin the shape. + */ +export interface OracleReadingAttestation { + /** TEE type that produced the quote, e.g. "aws-nitro" or "sgx". */ + teeType: string; + /** Enclave measurement that was matched against the allowlist. */ + measurement: string; + /** Hash of the canonicalized reading that the quote is bound to. */ + payloadHash: string; + /** Nonce bound to the originating request (replay protection). */ + nonce: string; + /** Policy version under which the attestation was evaluated. */ + policyVersion: string; +} \ No newline at end of file diff --git a/src/oracle/oracle.controller.ts b/src/oracle/oracle.controller.ts index e69de29..498c087 100644 --- a/src/oracle/oracle.controller.ts +++ b/src/oracle/oracle.controller.ts @@ -0,0 +1,224 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpException, + HttpStatus, + Logger, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { Request } from 'express'; +import { + IsArray, + IsIn, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Matches, + MaxLength, + ValidateNested, + validateSync, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +import { OracleService } from './oracle.service'; +import { SignatureGuard } from './guards/signature.guard'; +import { AttestationService } from './attestation/attestation.service'; +import { + AttestationTier, + TeeType, +} from './attestation/attestation.types'; +import { MetricsService } from '../metrics/metrics.service'; +import { EvidenceService } from '../evidence/evidence.service'; + +const MAX_QUOTE_BYTES = 64 * 1024; +const MAX_NONCE_LENGTH = 256; +const MAX_PAYLOAD_HASH_LENGTH = 128; + +export class AttestationDto { + @IsString() + @IsIn(Object.values(TeeType)) + teeType!: TeeType; + + @IsString() + @IsNotEmpty() + @MaxLength(MAX_QUOTE_BYTES) + quote!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(MAX_NONCE_LENGTH) + nonce!: string; + + @IsString() + @IsNotEmpty() + @MaxLength(MAX_PAYLOAD_HASH_LENGTH) + @Matches(/^[a-zA-Z0-9:_-]+$/) + payloadHash!: string; +} + +export class ProviderReadingDto { + @IsString() + @IsNotEmpty() + sourceId!: string; + + @IsString() + @IsNotEmpty() + providerId!: string; + + @IsObject() + reading!: Record; + + @IsString() + @IsNotEmpty() + signature!: string; + + @IsString() + @IsNotEmpty() + nonce!: string; + + @IsOptional() + @IsString() + timestamp?: string; + + @IsOptional() + @ValidateNested() + @Type(() => AttestationDto) + attestation?: AttestationDto; +} + +export interface ProviderSubmissionResult { + accepted: boolean; + submissionId: string; + attestationTier: AttestationTier; + policyVersion: string; + attestationFailureReason?: string; +} + +@Controller('oracle') +export class OracleController { + private readonly logger = new Logger(OracleController.name); + + constructor( + private readonly oracleService: OracleService, + private readonly attestationService: AttestationService, + private readonly metricsService: MetricsService, + private readonly evidenceService: EvidenceService, + ) {} + + @Post('provider/submit') + @HttpCode(HttpStatus.ACCEPTED) + @UseGuards(SignatureGuard) + async submitProviderReading( + @Body() body: ProviderReadingDto, + @Req() req: Request, + ): Promise { + const validationErrors = validateSync(body, { + whitelist: true, + forbidNonWhitelisted: false, + }); + if (validationErrors.length > 0) { + throw new HttpException( + { + message: 'Invalid provider submission payload', + errors: validationErrors.map((e) => ({ + property: e.property, + constraints: e.constraints, + })), + }, + HttpStatus.BAD_REQUEST, + ); + } + + const submissionId = await this.oracleService.recordSubmission(body); + + // Signature verification already ran in the guard. Attestation is a + // separate, independently testable step that runs after signature + // verification and before consensus weighting. + const attestationOutcome = await this.attestationService.verify({ + submissionId, + sourceId: body.sourceId, + providerId: body.providerId, + reading: body.reading, + requestNonce: body.nonce, + attestation: body.attestation, + }); + + let attestationTier: AttestationTier; + let policyVersion: string; + let attestationFailureReason: string | undefined; + + if (attestationOutcome.status === 'attested') { + attestationTier = attestationOutcome.tier; + policyVersion = attestationOutcome.policyVersion; + } else if (attestationOutcome.status === 'unattested') { + // No attestation object supplied: reading remains valid at base tier. + attestationTier = AttestationTier.BASE; + policyVersion = attestationOutcome.policyVersion; + } else { + // Attestation was claimed but failed verification. Never silently + // downgrade to "unsigned ok" — record evidence with the specific + // failure reason and keep the reading at the base tier. + attestationTier = AttestationTier.BASE; + policyVersion = attestationOutcome.policyVersion; + attestationFailureReason = attestationOutcome.failureReason; + + await this.evidenceService.recordAttestationFailure({ + submissionId, + sourceId: body.sourceId, + providerId: body.providerId, + teeType: body.attestation?.teeType, + failureReason: attestationOutcome.failureReason, + failureCode: attestationOutcome.failureCode, + policyVersion, + observedAt: new Date().toISOString(), + }); + + this.logger.warn( + `Attestation failed for submission ${submissionId} (source=${body.sourceId}, provider=${body.providerId}): ${attestationOutcome.failureCode} - ${attestationOutcome.failureReason}`, + ); + } + + await this.oracleService.persistAttestationTier({ + submissionId, + attestationTier, + policyVersion, + failureReason: attestationFailureReason, + }); + + this.metricsService.incrementProviderSubmission({ + sourceId: body.sourceId, + providerId: body.providerId, + attestationTier, + policyVersion, + attestationFailed: attestationFailureReason !== undefined, + }); + + return { + accepted: true, + submissionId, + attestationTier, + policyVersion, + ...(attestationFailureReason + ? { attestationFailureReason } + : {}), + }; + } + + @Get('provider/attestation/policy') + getAttestationPolicy(): { + policyVersion: string; + allowedTeeTypes: TeeType[]; + tiers: AttestationTier[]; + } { + return { + policyVersion: this.attestationService.getPolicyVersion(), + allowedTeeTypes: this.attestationService.getAllowedTeeTypes(), + tiers: [AttestationTier.BASE, AttestationTier.ATTESTED], + }; + } +} \ No newline at end of file diff --git a/src/oracle/oracle.module.ts b/src/oracle/oracle.module.ts index e69de29..9923df9 100644 --- a/src/oracle/oracle.module.ts +++ b/src/oracle/oracle.module.ts @@ -0,0 +1,111 @@ +import { Module, Provider } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; + +import { OracleService } from './oracle.service'; +import { OracleController } from './oracle.controller'; +import { OracleMonitorService } from '../services/oracleMonitor'; +import { FreshnessEnforcementService } from './freshness/freshnessEnforcement.service'; +import { FeedCalendarResolver } from './calendar/feedCalendarResolver'; +import { ClockService } from './calendar/clock.service'; +import { FeedCalendarConfigService } from './calendar/feedCalendarConfig.service'; +import { HolidayCalendarSource } from './calendar/holidayCalendarSource'; +import { AttestationVerifierRegistry } from './attestation/attestationVerifierRegistry'; +import { AttestationVerifier } from './attestation/attestationVerifier.interface'; +import { NitroEnclaveAttestationVerifier } from './attestation/nitroEnclaveAttestationVerifier'; +import { SgxAttestationVerifier } from './attestation/sgxAttestationVerifier'; +import { AttestationPolicyService } from './attestation/attestationPolicy.service'; +import { AttestationTierService } from './attestation/attestationTier.service'; +import { SourceIndependenceService } from './weighting/sourceIndependence.service'; +import { ORACLE_CLOCK, ORACLE_CALENDAR_RESOLVER } from './oracle.tokens'; + +const clockProvider: Provider = { + provide: ORACLE_CLOCK, + useFactory: (configService: ConfigService) => { + return new ClockService(configService); + }, + inject: [ConfigService], +}; + +const calendarResolverProvider: Provider = { + provide: ORACLE_CALENDAR_RESOLVER, + useFactory: ( + configService: ConfigService, + clock: ClockService, + holidaySource: HolidayCalendarSource, + configServiceForFeeds: FeedCalendarConfigService, + ) => { + return new FeedCalendarResolver( + configService, + clock, + holidaySource, + configServiceForFeeds, + ); + }, + inject: [ + ConfigService, + ClockService, + HolidayCalendarSource, + FeedCalendarConfigService, + ], +}; + +const attestationVerifierProvider: Provider = { + provide: AttestationVerifierRegistry, + useFactory: ( + configService: ConfigService, + nitroVerifier: NitroEnclaveAttestationVerifier, + sgxVerifier: SgxAttestationVerifier, + ): AttestationVerifierRegistry => { + const registry = new AttestationVerifierRegistry(configService); + const verifiers: AttestationVerifier[] = [nitroVerifier, sgxVerifier]; + for (const verifier of verifiers) { + registry.register(verifier); + } + return registry; + }, + inject: [ConfigService, NitroEnclaveAttestationVerifier, SgxAttestationVerifier], +}; + +@Module({ + imports: [ConfigModule], + controllers: [OracleController], + providers: [ + // Clock & calendar abstraction (issue #291) + ClockService, + clockProvider, + FeedCalendarConfigService, + HolidayCalendarSource, + FeedCalendarResolver, + calendarResolverProvider, + + // TEE attestation verifiers (issue #293) + NitroEnclaveAttestationVerifier, + SgxAttestationVerifier, + AttestationVerifierRegistry, + attestationVerifierProvider, + AttestationPolicyService, + AttestationTierService, + + // Weighting + SourceIndependenceService, + + // Core oracle services + OracleService, + OracleMonitorService, + FreshnessEnforcementService, + ], + exports: [ + OracleService, + OracleMonitorService, + FreshnessEnforcementService, + ClockService, + ORACLE_CLOCK, + FeedCalendarResolver, + ORACLE_CALENDAR_RESOLVER, + AttestationVerifierRegistry, + AttestationPolicyService, + AttestationTierService, + SourceIndependenceService, + ], +}) +export class OracleModule {} \ No newline at end of file diff --git a/src/oracle/oracle.service.ts b/src/oracle/oracle.service.ts index e69de29..f633776 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -0,0 +1,766 @@ +import { EventEmitter } from 'events'; +import { randomUUID } from 'crypto'; +import { + OracleServiceConfig, + OracleFeed, + OracleReading, + OracleSubmission, + OracleSource, + OracleAlert, + OracleAlertSeverity, + OracleAlertType, + OracleMetrics, + OracleEvidence, + OracleEvidenceKind, + FreshnessEvaluation, + StalenessEvaluation, + PlausibilityEvaluation, + PlausibilityStatus, + FeedActivityState, + FeedCalendarDeclaration, + AttestationTier, +} from './types'; +import { OracleError, OracleErrorCode } from './errors'; +import { CalendarResolver } from './calendar/calendar-resolver'; +import { Clock } from './calendar/clock'; +import { AttestationVerifier } from './attestation/attestation-verifier'; +import { SourceIndependencePolicy } from './attestation/source-independence-policy'; + +/** + * OracleService + * + * Central orchestration for oracle feed ingestion, freshness/staleness + * evaluation, plausibility checks, and alerting. + * + * Time is always evaluated through the injected {@link Clock} and feed + * activity is always resolved through the injected {@link CalendarResolver} + * so that: + * - tests can freeze time deterministically, and + * - freshness/staleness/plausibility respect market hours, holidays, + * DST transitions, and scheduled quiet periods. + */ +export class OracleService extends EventEmitter { + private readonly config: OracleServiceConfig; + private readonly clock: Clock; + private readonly calendarResolver: CalendarResolver; + private readonly attestationVerifier?: AttestationVerifier; + private readonly independencePolicy?: SourceIndependencePolicy; + + private readonly feeds: Map = new Map(); + private readonly readings: Map = new Map(); + private readonly alerts: Map = new Map(); + private readonly evidence: OracleEvidence[] = []; + private readonly metrics: OracleMetrics; + + /** + * Tracks the last time we observed a feed that *should* have been + * publishing but was not. Used to bound unscheduled-halt alerting. + */ + private readonly unscheduledHaltSince: Map = new Map(); + + constructor( + config: OracleServiceConfig, + clock: Clock, + calendarResolver: CalendarResolver, + attestationVerifier?: AttestationVerifier, + independencePolicy?: SourceIndependencePolicy, + ) { + super(); + this.config = config; + this.clock = clock; + this.calendarResolver = calendarResolver; + this.attestationVerifier = attestationVerifier; + this.independencePolicy = independencePolicy; + + this.metrics = { + readingsIngested: 0, + readingsRejected: 0, + alertsRaised: 0, + freshnessEvaluations: 0, + stalenessEvaluations: 0, + plausibilityEvaluations: 0, + plausibilitySuspended: 0, + unscheduledHaltAlerts: 0, + attestationVerified: 0, + attestationFailed: 0, + byFeed: {}, + }; + } + + // --------------------------------------------------------------------------- + // Feed registration + // --------------------------------------------------------------------------- + + public registerFeed(feed: OracleFeed): void { + if (this.feeds.has(feed.id)) { + throw new OracleError( + OracleErrorCode.FEED_ALREADY_REGISTERED, + `Feed ${feed.id} is already registered`, + ); + } + this.feeds.set(feed.id, feed); + this.readings.set(feed.id, []); + this.metrics.byFeed[feed.id] = { + readingsIngested: 0, + readingsRejected: 0, + alertsRaised: 0, + lastReadingAt: null, + lastFreshnessStatus: null, + lastStalenessStatus: null, + lastPlausibilityStatus: null, + lastActivityState: null, + plausibilitySuspended: 0, + unscheduledHaltAlerts: 0, + }; + } + + public getFeed(feedId: string): OracleFeed | undefined { + return this.feeds.get(feedId); + } + + public listFeeds(): OracleFeed[] { + return Array.from(this.feeds.values()); + } + + // --------------------------------------------------------------------------- + // Ingestion + // --------------------------------------------------------------------------- + + public async ingestReading(submission: OracleSubmission): Promise { + const feed = this.feeds.get(submission.feedId); + if (!feed) { + throw new OracleError( + OracleErrorCode.FEED_NOT_FOUND, + `Feed ${submission.feedId} is not registered`, + ); + } + + const source = feed.sources.find((s) => s.id === submission.sourceId); + if (!source) { + throw new OracleError( + OracleErrorCode.SOURCE_NOT_FOUND, + `Source ${submission.sourceId} is not registered for feed ${feed.id}`, + ); + } + + // Attestation is verified as a separate, independently testable step. + let attestationTier: AttestationTier = AttestationTier.BASE; + if (submission.attestation) { + if (!this.attestationVerifier) { + this.recordAttestationFailure(feed, source, submission, 'verifier_unavailable'); + throw new OracleError( + OracleErrorCode.ATTESTATION_FAILED, + 'Attestation submitted but no verifier is configured', + ); + } + const result = await this.attestationVerifier.verify(submission, source); + if (!result.valid) { + this.recordAttestationFailure(feed, source, submission, result.reason); + this.metrics.attestationFailed += 1; + throw new OracleError( + OracleErrorCode.ATTESTATION_FAILED, + `Attestation verification failed: ${result.reason}`, + ); + } + attestationTier = result.tier; + this.metrics.attestationVerified += 1; + } + + const now = this.clock.now(); + const reading: OracleReading = { + id: randomUUID(), + feedId: feed.id, + sourceId: source.id, + value: submission.value, + observedAt: submission.observedAt, + receivedAt: now, + attestationTier, + metadata: submission.metadata ?? {}, + }; + + const feedReadings = this.readings.get(feed.id)!; + feedReadings.push(reading); + if (feedReadings.length > this.config.maxReadingsPerFeed) { + feedReadings.splice(0, feedReadings.length - this.config.maxReadingsPerFeed); + } + + this.metrics.readingsIngested += 1; + this.metrics.byFeed[feed.id].readingsIngested += 1; + this.metrics.byFeed[feed.id].lastReadingAt = now; + + // A reading arrived, so any pending unscheduled-halt tracking clears. + this.unscheduledHaltSince.delete(feed.id); + + this.emit('reading', reading); + return reading; + } + + private recordAttestationFailure( + feed: OracleFeed, + source: OracleSource, + submission: OracleSubmission, + reason: string, + ): void { + this.addEvidence({ + id: randomUUID(), + kind: OracleEvidenceKind.ATTESTATION_FAILURE, + feedId: feed.id, + sourceId: source.id, + recordedAt: this.clock.now(), + details: { + reason, + teeType: submission.attestation?.teeType ?? null, + nonce: submission.attestation?.nonce ?? null, + }, + }); + } + + // --------------------------------------------------------------------------- + // Calendar-aware activity resolution + // --------------------------------------------------------------------------- + + /** + * Resolve whether the feed is expected to be active at the given instant, + * using the feed's calendar declaration. If the calendar is unavailable we + * fail safe: treat the feed as expected-active so that we do not silently + * accept a down feed as "quiet". + */ + public resolveActivityState(feed: OracleFeed, at: number = this.clock.now()): FeedActivityState { + const declaration: FeedCalendarDeclaration | undefined = feed.calendar; + if (!declaration) { + // No calendar declared: assume continuous 24/7 activity. + return { + feedId: feed.id, + at, + expectedActive: true, + quietReason: null, + calendarAvailable: true, + timeZone: null, + }; + } + + try { + const resolved = this.calendarResolver.resolve(declaration, at); + return { + feedId: feed.id, + at, + expectedActive: resolved.expectedActive, + quietReason: resolved.quietReason, + calendarAvailable: true, + timeZone: resolved.timeZone, + }; + } catch (err) { + // Fail safe, not silently open: treat as expected-active so that a + // genuinely down feed still accrues staleness and alerts. + this.addEvidence({ + id: randomUUID(), + kind: OracleEvidenceKind.CALENDAR_UNAVAILABLE, + feedId: feed.id, + sourceId: null, + recordedAt: this.clock.now(), + details: { + error: err instanceof Error ? err.message : String(err), + declaration, + }, + }); + return { + feedId: feed.id, + at, + expectedActive: true, + quietReason: 'calendar_unavailable', + calendarAvailable: false, + timeZone: declaration.timeZone ?? null, + }; + } + } + + // --------------------------------------------------------------------------- + // Freshness / staleness evaluation + // --------------------------------------------------------------------------- + + /** + * Evaluate freshness against the feed calendar. While the feed is in a + * declared quiet window, elapsed time does not accrue against the freshness + * window. When the feed is expected to be active, elapsed time counts fully. + */ + public evaluateFreshness(feedId: string, at: number = this.clock.now()): FreshnessEvaluation { + const feed = this.requireFeed(feedId); + const activity = this.resolveActivityState(feed, at); + const lastReading = this.getLatestReading(feedId); + + this.metrics.freshnessEvaluations += 1; + + if (!lastReading) { + const status = activity.expectedActive ? 'stale' : 'quiet'; + this.metrics.byFeed[feedId].lastFreshnessStatus = status; + this.metrics.byFeed[feedId].lastActivityState = activity; + return { + feedId, + evaluatedAt: at, + status, + lastReadingAt: null, + effectiveAgeMs: null, + freshnessWindowMs: feed.freshnessWindowMs, + activity, + suspended: !activity.expectedActive, + }; + } + + const effectiveAgeMs = this.computeEffectiveAgeMs(feed, lastReading.receivedAt, at, activity); + + let status: FreshnessEvaluation['status']; + if (!activity.expectedActive) { + // Quiet period: do not accrue staleness. Only flag if the reading is + // older than the window *plus* the quiet duration (i.e. it was already + // stale before the quiet window began). + status = effectiveAgeMs > feed.freshnessWindowMs ? 'stale' : 'quiet'; + } else { + status = effectiveAgeMs > feed.freshnessWindowMs ? 'stale' : 'fresh'; + } + + this.metrics.byFeed[feedId].lastFreshnessStatus = status; + this.metrics.byFeed[feedId].lastActivityState = activity; + + const evaluation: FreshnessEvaluation = { + feedId, + evaluatedAt: at, + status, + lastReadingAt: lastReading.receivedAt, + effectiveAgeMs, + freshnessWindowMs: feed.freshnessWindowMs, + activity, + suspended: !activity.expectedActive, + }; + + if (status === 'stale' && activity.expectedActive) { + this.handleUnscheduledHalt(feed, at, effectiveAgeMs); + } else { + this.unscheduledHaltSince.delete(feed.id); + } + + return evaluation; + } + + /** + * Evaluate staleness. Staleness is the complement of freshness but is + * evaluated separately so that callers can distinguish "closed market" + * from "feed is down". + */ + public evaluateStaleness(feedId: string, at: number = this.clock.now()): StalenessEvaluation { + const feed = this.requireFeed(feedId); + const activity = this.resolveActivityState(feed, at); + const lastReading = this.getLatestReading(feedId); + + this.metrics.stalenessEvaluations += 1; + + if (!lastReading) { + const stale = activity.expectedActive; + this.metrics.byFeed[feedId].lastStalenessStatus = stale ? 'stale' : 'quiet'; + return { + feedId, + evaluatedAt: at, + stale, + status: stale ? 'stale' : 'quiet', + lastReadingAt: null, + effectiveAgeMs: null, + stalenessWindowMs: feed.stalenessWindowMs, + activity, + suspended: !activity.expectedActive, + }; + } + + const effectiveAgeMs = this.computeEffectiveAgeMs(feed, lastReading.receivedAt, at, activity); + const stale = effectiveAgeMs > feed.stalenessWindowMs; + + let status: StalenessEvaluation['status']; + if (!activity.expectedActive) { + status = stale ? 'stale' : 'quiet'; + } else { + status = stale ? 'stale' : 'ok'; + } + + this.metrics.byFeed[feedId].lastStalenessStatus = status; + + if (stale && activity.expectedActive) { + this.handleUnscheduledHalt(feed, at, effectiveAgeMs); + } + + return { + feedId, + evaluatedAt: at, + stale, + status, + lastReadingAt: lastReading.receivedAt, + effectiveAgeMs, + stalenessWindowMs: feed.stalenessWindowMs, + activity, + suspended: !activity.expectedActive, + }; + } + + /** + * Compute the age of a reading, excluding time spent inside declared quiet + * windows. This is what makes a closed market not accrue staleness while a + * feed that should be publishing does. + */ + private computeEffectiveAgeMs( + feed: OracleFeed, + lastReadingAt: number, + at: number, + activity: FeedActivityState, + ): number { + if (at <= lastReadingAt) { + return 0; + } + + // Fast path: no calendar, or currently active with no quiet history to + // subtract. We still need to subtract any quiet windows that occurred + // between lastReadingAt and `at`. + if (!feed.calendar) { + return at - lastReadingAt; + } + + try { + const quietMs = this.calendarResolver.quietDurationBetween( + feed.calendar, + lastReadingAt, + at, + ); + const total = at - lastReadingAt; + const effective = total - quietMs; + return effective < 0 ? 0 : effective; + } catch (err) { + // Calendar unavailable: fail safe by counting full elapsed time so a + // genuinely down feed still alerts. + this.addEvidence({ + id: randomUUID(), + kind: OracleEvidenceKind.CALENDAR_UNAVAILABLE, + feedId: feed.id, + sourceId: null, + recordedAt: this.clock.now(), + details: { + error: err instanceof Error ? err.message : String(err), + context: 'computeEffectiveAgeMs', + }, + }); + return at - lastReadingAt; + } + } + + /** + * Unscheduled halt handling: a feed that should be publishing but is not + * must alert after a bounded grace period. The grace period is configured + * per-feed (or globally) and is measured in *active* time, so a halt that + * begins just before a scheduled quiet window does not alert until the + * market reopens and the grace period elapses in active time. + */ + private handleUnscheduledHalt(feed: OracleFeed, at: number, effectiveAgeMs: number): void { + const graceMs = feed.unscheduledHaltGraceMs ?? this.config.defaultUnscheduledHaltGraceMs; + const threshold = feed.stalenessWindowMs + graceMs; + + if (effectiveAgeMs <= threshold) { + return; + } + + const since = this.unscheduledHaltSince.get(feed.id); + if (since === undefined) { + this.unscheduledHaltSince.set(feed.id, at); + } + + const existing = this.findOpenAlert(feed.id, OracleAlertType.UNSCHEDULED_HALT); + if (existing) { + return; + } + + this.metrics.unscheduledHaltAlerts += 1; + this.metrics.byFeed[feed.id].unscheduledHaltAlerts += 1; + + this.raiseAlert({ + feedId: feed.id, + type: OracleAlertType.UNSCHEDULED_HALT, + severity: OracleAlertSeverity.CRITICAL, + message: `Feed ${feed.id} expected to be active but has not published within grace period (effective age ${effectiveAgeMs}ms, threshold ${threshold}ms)`, + details: { + effectiveAgeMs, + stalenessWindowMs: feed.stalenessWindowMs, + graceMs, + threshold, + }, + }); + } + + // --------------------------------------------------------------------------- + // Plausibility / anomaly evaluation + // --------------------------------------------------------------------------- + + /** + * Evaluate plausibility. During declared quiet periods, plausibility checks + * are suspended (or relaxed per the feed's allowed quiet behavior). The + * suspension is recorded both in evidence and in metric labels so it is + * auditable. + */ + public evaluatePlausibility(feedId: string, at: number = this.clock.now()): PlausibilityEvaluation { + const feed = this.requireFeed(feedId); + const activity = this.resolveActivityState(feed, at); + const lastReading = this.getLatestReading(feedId); + + this.metrics.plausibilityEvaluations += 1; + + if (!activity.expectedActive) { + const behavior = feed.calendar?.allowedQuietBehavior ?? 'suspend'; + const suspended = behavior === 'suspend'; + + if (suspended) { + this.metrics.plausibilitySuspended += 1; + this.metrics.byFeed[feedId].plausibilitySuspended += 1; + } + + this.addEvidence({ + id: randomUUID(), + kind: OracleEvidenceKind.PLAUSIBILITY_SUSPENDED, + feedId, + sourceId: null, + recordedAt: at, + details: { + quietReason: activity.quietReason, + behavior, + suspended, + timeZone: activity.timeZone, + }, + }); + + const status: PlausibilityStatus = suspended + ? PlausibilityStatus.SUSPENDED + : PlausibilityStatus.RELAXED; + + this.metrics.byFeed[feedId].lastPlausibilityStatus = status; + + return { + feedId, + evaluatedAt: at, + status, + activity, + suspended, + relaxed: !suspended, + reason: activity.quietReason, + checks: [], + }; + } + + if (!lastReading) { + const status = PlausibilityStatus.UNKNOWN; + this.metrics.byFeed[feedId].lastPlausibilityStatus = status; + return { + feedId, + evaluatedAt: at, + status, + activity, + suspended: false, + relaxed: false, + reason: 'no_reading', + checks: [], + }; + } + + const checks = this.runPlausibilityChecks(feed, lastReading, at); + const failed = checks.filter((c) => !c.passed); + const status = failed.length === 0 ? PlausibilityStatus.OK : PlausibilityStatus.FAILED; + + this.metrics.byFeed[feedId].lastPlausibilityStatus = status; + + if (status === PlausibilityStatus.FAILED) { + this.raiseAlert({ + feedId, + type: OracleAlertType.PLAUSIBILITY_FAILURE, + severity: OracleAlertSeverity.WARNING, + message: `Feed ${feedId} failed plausibility checks: ${failed.map((c) => c.name).join(', ')}`, + details: { checks }, + }); + } + + return { + feedId, + evaluatedAt: at, + status, + activity, + suspended: false, + relaxed: false, + reason: null, + checks, + }; + } + + private runPlausibilityChecks( + feed: OracleFeed, + reading: OracleReading, + at: number, + ): PlausibilityEvaluation['checks'] { + const checks: PlausibilityEvaluation['checks'] = []; + + if (feed.plausibility?.minValue !== undefined) { + checks.push({ + name: 'min_value', + passed: reading.value >= feed.plausibility.minValue, + details: { value: reading.value, min: feed.plausibility.minValue }, + }); + } + + if (feed.plausibility?.maxValue !== undefined) { + checks.push({ + name: 'max_value', + passed: reading.value <= feed.plausibility.maxValue, + details: { value: reading.value, max: feed.plausibility.maxValue }, + }); + } + + if (feed.plausibility?.maxDeviationPct !== undefined) { + const previous = this.getPreviousReading(feed.id, reading.id); + if (previous && previous.value !== 0) { + const deviationPct = Math.abs((reading.value - previous.value) / previous.value) * 100; + checks.push({ + name: 'max_deviation_pct', + passed: deviationPct <= feed.plausibility.maxDeviationPct, + details: { + value: reading.value, + previous: previous.value, + deviationPct, + maxDeviationPct: feed.plausibility.maxDeviationPct, + }, + }); + } + } + + if (feed.plausibility?.maxFutureSkewMs !== undefined) { + const skew = reading.observedAt - at; + checks.push({ + name: 'max_future_skew', + passed: skew <= feed.plausibility.maxFutureSkewMs, + details: { skew, maxFutureSkewMs: feed.plausibility.maxFutureSkewMs }, + }); + } + + return checks; + } + + // --------------------------------------------------------------------------- + // Alerting + // --------------------------------------------------------------------------- + + private raiseAlert(input: { + feedId: string; + type: OracleAlertType; + severity: OracleAlertSeverity; + message: string; + details?: Record; + }): OracleAlert { + const alert: OracleAlert = { + id: randomUUID(), + feedId: input.feedId, + type: input.type, + severity: input.severity, + message: input.message, + details: input.details ?? {}, + raisedAt: this.clock.now(), + resolvedAt: null, + }; + this.alerts.set(alert.id, alert); + this.metrics.alertsRaised += 1; + this.metrics.byFeed[input.feedId].alertsRaised += 1; + this.emit('alert', alert); + return alert; + } + + private findOpenAlert(feedId: string, type: OracleAlertType): OracleAlert | undefined { + for (const alert of this.alerts.values()) { + if (alert.feedId === feedId && alert.type === type && alert.resolvedAt === null) { + return alert; + } + } + return undefined; + } + + public resolveAlert(alertId: string): void { + const alert = this.alerts.get(alertId); + if (!alert) { + throw new OracleError(OracleErrorCode.ALERT_NOT_FOUND, `Alert ${alertId} not found`); + } + if (alert.resolvedAt === null) { + alert.resolvedAt = this.clock.now(); + this.emit('alertResolved', alert); + } + } + + public listAlerts(feedId?: string): OracleAlert[] { + const all = Array.from(this.alerts.values()); + return feedId ? all.filter((a) => a.feedId === feedId) : all; + } + + // --------------------------------------------------------------------------- + // Evidence + // --------------------------------------------------------------------------- + + private addEvidence(evidence: OracleEvidence): void { + this.evidence.push(evidence); + if (this.evidence.length > this.config.maxEvidenceEntries) { + this.evidence.splice(0, this.evidence.length - this.config.maxEvidenceEntries); + } + this.emit('evidence', evidence); + } + + public listEvidence(feedId?: string): OracleEvidence[] { + return feedId ? this.evidence.filter((e) => e.feedId === feedId) : [...this.evidence]; + } + + // --------------------------------------------------------------------------- + // Readings + // --------------------------------------------------------------------------- + + public getLatestReading(feedId: string): OracleReading | undefined { + const feedReadings = this.readings.get(feedId); + if (!feedReadings || feedReadings.length === 0) { + return undefined; + } + return feedReadings[feedReadings.length - 1]; + } + + private getPreviousReading(feedId: string, currentId: string): OracleReading | undefined { + const feedReadings = this.readings.get(feedId); + if (!feedReadings) { + return undefined; + } + const idx = feedReadings.findIndex((r) => r.id === currentId); + if (idx <= 0) { + return undefined; + } + return feedReadings[idx - 1]; + } + + public listReadings(feedId: string): OracleReading[] { + return [...(this.readings.get(feedId) ?? [])]; + } + + // --------------------------------------------------------------------------- + // Metrics + // --------------------------------------------------------------------------- + + public getMetrics(): OracleMetrics { + return { + ...this.metrics, + byFeed: Object.fromEntries( + Object.entries(this.metrics.byFeed).map(([k, v]) => [k, { ...v }]), + ), + }; + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + private requireFeed(feedId: string): OracleFeed { + const feed = this.feeds.get(feedId); + if (!feed) { + throw new OracleError(OracleErrorCode.FEED_NOT_FOUND, `Feed ${feedId} is not registered`); + } + return feed; + } +} \ No newline at end of file diff --git a/test/integration/jest.integration.config.ts b/test/integration/jest.integration.config.ts index 0dfaa34..73fd871 100644 --- a/test/integration/jest.integration.config.ts +++ b/test/integration/jest.integration.config.ts @@ -25,3 +25,803 @@ const config: Config = { }; export default config; +``` + +File: test/contracts/jest.contracts.config.ts +```typescript +import type { Config } from "jest"; + +/** + * Consumer-driven contract verification suite. + * + * Verifies that refract-backend's actual responses and encodings match the + * checked-in contract fixtures owned by refract-frontend and refract-contracts. + * + * Run: npx jest --config test/contracts/jest.contracts.config.ts + * + * Design notes: + * - No live database or network is required. Fixtures are checked in under + * test/contracts/fixtures//.contract.json. + * - The consumer owns the expectation; the producer (this repo) verifies it. + * - Additive changes (new optional fields) pass automatically. + * - Breaking changes (renamed/removed fields, changed units) fail with a + * readable message naming the offending field. + */ +const config: Config = { + preset: "ts-jest", + testEnvironment: "node", + rootDir: "../..", + testMatch: ["/test/contracts/**/*.contract.ts"], + testTimeout: 15_000, + transform: { + "^.+\\.tsx?$": ["ts-jest", { tsconfig: "/tsconfig.json" }], + }, +}; + +export default config; +``` + +File: test/contracts/types.ts +```typescript +/** + * Shared types for the consumer-driven contract verification suite. + * + * A contract fixture is owned by a consumer (refract-frontend or + * refract-contracts) and describes the exact shape the consumer expects from + * a producer interface exposed by refract-backend. + */ + +export type ContractConsumer = "refract-frontend" | "refract-contracts"; + +export type ContractInterface = + | "consensus-decision" + | "provenance" + | "trust-status" + | "onchain-reading"; + +/** + * A single field expectation within a contract. + * + * - `type` is the JSON-ish type the consumer expects. + * - `required` defaults to true; optional fields are additive-friendly. + * - `unit` pins the semantic unit (e.g. "bps", "ms", "stroops") so a unit + * change is treated as breaking even when the type is unchanged. + * - `enum` pins the allowed literal values. + */ +export interface FieldExpectation { + type: "string" | "number" | "boolean" | "object" | "array" | "null"; + required?: boolean; + unit?: string; + enum?: Array; + /** Nested object shape, for object-typed fields. */ + fields?: Record; + /** Element shape for array-typed fields. */ + items?: FieldExpectation; +} + +export interface ContractFixture { + /** Consumer that owns this expectation. */ + consumer: ContractConsumer; + /** Producer interface this contract pins. */ + interface: ContractInterface; + /** + * Contract version. Breaking changes require a bump and consumer opt-in; + * additive changes do not. + */ + version: number; + /** Human-readable description of what is being pinned. */ + description: string; + /** Expected top-level shape. */ + shape: Record; +} + +export interface ContractViolation { + /** Dotted path to the offending field, e.g. "decision.confidence". */ + path: string; + /** Human-readable reason. */ + reason: string; + /** Expected description, if applicable. */ + expected?: string; + /** Actual description, if applicable. */ + actual?: string; +} +``` + +File: test/contracts/verifier.ts +```typescript +/** + * Generated verifier for consumer-driven contract fixtures. + * + * Compares a producer payload (an actual backend response or encoding) against + * a checked-in contract fixture and returns a list of violations. An empty + * list means the producer satisfies the consumer's expectation. + * + * Semantics: + * - Missing required fields are violations (breaking). + * - Renamed fields surface as a missing required field + an unexpected field, + * producing a readable named-field message. + * - Type mismatches are violations (breaking). + * - Unit mismatches are violations (breaking) even when the type matches. + * - Enum mismatches are violations (breaking). + * - Extra fields on the producer are allowed (additive changes pass). + * - Optional fields may be absent. + */ +import type { + ContractFixture, + ContractViolation, + FieldExpectation, +} from "./types"; + +function describeType(value: unknown): string { + if (value === null) return "null"; + if (Array.isArray(value)) return "array"; + return typeof value; +} + +function matchesType(value: unknown, expected: FieldExpectation["type"]): boolean { + switch (expected) { + case "null": + return value === null; + case "array": + return Array.isArray(value); + case "object": + return typeof value === "object" && value !== null && !Array.isArray(value); + case "number": + return typeof value === "number" && Number.isFinite(value); + case "string": + return typeof value === "string"; + case "boolean": + return typeof value === "boolean"; + default: + return false; + } +} + +function verifyField( + path: string, + value: unknown, + expectation: FieldExpectation, + violations: ContractViolation[], +): void { + const required = expectation.required !== false; + + if (value === undefined) { + if (required) { + violations.push({ + path, + reason: "required field is missing", + expected: expectation.type, + actual: "undefined", + }); + } + return; + } + + if (!matchesType(value, expectation.type)) { + violations.push({ + path, + reason: "field type mismatch", + expected: expectation.type, + actual: describeType(value), + }); + return; + } + + if (expectation.enum && !expectation.enum.includes(value as string | number | boolean)) { + violations.push({ + path, + reason: "field value not in allowed enum", + expected: expectation.enum.map(String).join(" | "), + actual: String(value), + }); + } + + if (expectation.type === "object" && expectation.fields) { + const obj = value as Record; + for (const [childKey, childExpectation] of Object.entries(expectation.fields)) { + verifyField(`${path}.${childKey}`, obj[childKey], childExpectation, violations); + } + } + + if (expectation.type === "array" && expectation.items) { + const arr = value as unknown[]; + arr.forEach((item, index) => { + verifyField(`${path}[${index}]`, item, expectation.items as FieldExpectation, violations); + }); + } +} + +/** + * Verify a producer payload against a contract fixture. + * + * @returns violations — empty when the payload satisfies the contract. + */ +export function verifyContract( + fixture: ContractFixture, + payload: unknown, +): ContractViolation[] { + const violations: ContractViolation[] = []; + + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) { + violations.push({ + path: "", + reason: "payload is not an object", + expected: "object", + actual: describeType(payload), + }); + return violations; + } + + const record = payload as Record; + for (const [key, expectation] of Object.entries(fixture.shape)) { + verifyField(key, record[key], expectation, violations); + } + + return violations; +} + +/** + * Format violations into a readable, named-field message suitable for a + * failing CI build. + */ +export function formatViolations( + fixture: ContractFixture, + violations: ContractViolation[], +): string { + const header = + `Contract violation(s) for ${fixture.consumer} :: ${fixture.interface} ` + + `(v${fixture.version}) — ${fixture.description}`; + const lines = violations.map((v) => { + const expected = v.expected !== undefined ? ` (expected: ${v.expected})` : ""; + const actual = v.actual !== undefined ? ` (actual: ${v.actual})` : ""; + return ` - field "${v.path}": ${v.reason}${expected}${actual}`; + }); + return [header, ...lines].join("\n"); +} + +/** + * Assert that a producer payload satisfies a contract fixture. Throws a + * readable error naming the offending field(s) on failure. + */ +export function assertContract(fixture: ContractFixture, payload: unknown): void { + const violations = verifyContract(fixture, payload); + if (violations.length > 0) { + throw new Error(formatViolations(fixture, violations)); + } +} +``` + +File: test/contracts/fixtures.ts +```typescript +/** + * Loader for checked-in contract fixtures. + * + * Fixtures live under test/contracts/fixtures//.contract.json + * and are owned by the consuming repo. They are checked in so the producer can + * verify against them without a broker or network access. + */ +import { readFileSync, readdirSync } from "fs"; +import { join } from "path"; +import type { ContractConsumer, ContractFixture } from "./types"; + +const FIXTURES_ROOT = join(__dirname, "fixtures"); + +const CONSUMERS: ContractConsumer[] = ["refract-frontend", "refract-contracts"]; + +export function loadFixtures(): ContractFixture[] { + const fixtures: ContractFixture[] = []; + for (const consumer of CONSUMERS) { + const dir = join(FIXTURES_ROOT, consumer); + let entries: string[]; + try { + entries = readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.endsWith(".contract.json")) continue; + const raw = readFileSync(join(dir, entry), "utf8"); + fixtures.push(JSON.parse(raw) as ContractFixture); + } + } + return fixtures; +} + +export function loadFixture( + consumer: ContractConsumer, + iface: ContractFixture["interface"], +): ContractFixture { + const match = loadFixtures().find( + (f) => f.consumer === consumer && f.interface === iface, + ); + if (!match) { + throw new Error( + `No contract fixture found for ${consumer} :: ${iface}. ` + + `Expected test/contracts/fixtures/${consumer}/${iface}.contract.json`, + ); + } + return match; +} +``` + +File: test/contracts/fixtures/refract-frontend/consensus-decision.contract.json +```json +{ + "consumer": "refract-frontend", + "interface": "consensus-decision", + "version": 1, + "description": "Consensus decision record rendered by the trust badge.", + "shape": { + "id": { "type": "string" }, + "policyId": { "type": "string" }, + "outcome": { + "type": "string", + "enum": ["approve", "reject", "abstain"] + }, + "confidence": { "type": "number", "unit": "bps" }, + "decidedAt": { "type": "string", "unit": "iso8601" }, + "votes": { + "type": "array", + "items": { + "type": "object", + "fields": { + "oracleId": { "type": "string" }, + "vote": { "type": "string", "enum": ["approve", "reject", "abstain"] }, + "weight": { "type": "number", "unit": "bps" } + } + } + } + } +} +``` + +File: test/contracts/fixtures/refract-frontend/provenance.contract.json +```json +{ + "consumer": "refract-frontend", + "interface": "provenance", + "version": 1, + "description": "Provenance attestation surfaced in the trust detail panel.", + "shape": { + "attestationId": { "type": "string" }, + "oracleId": { "type": "string" }, + "subjectId": { "type": "string" }, + "attestedAt": { "type": "string", "unit": "iso8601" }, + "signature": { "type": "string" }, + "metadata": { "type": "object", "required": false } + } +} +``` + +File: test/contracts/fixtures/refract-frontend/trust-status.contract.json +```json +{ + "consumer": "refract-frontend", + "interface": "trust-status", + "version": 1, + "description": "Trust status response rendered by the trust badge.", + "shape": { + "subjectId": { "type": "string" }, + "status": { + "type": "string", + "enum": ["trusted", "untrusted", "pending", "revoked"] + }, + "score": { "type": "number", "unit": "bps" }, + "updatedAt": { "type": "string", "unit": "iso8601" }, + "reason": { "type": "string", "required": false } + } +} +``` + +File: test/contracts/fixtures/refract-contracts/onchain-reading.contract.json +```json +{ + "consumer": "refract-contracts", + "interface": "onchain-reading", + "version": 1, + "description": "Encoded oracle reading published on-chain; consumed by the Soroban contract.", + "shape": { + "subjectId": { "type": "string" }, + "value": { "type": "number", "unit": "stroops" }, + "decimals": { "type": "number", "unit": "count" }, + "timestamp": { "type": "number", "unit": "unix-seconds" }, + "signature": { "type": "string" } + } +} +``` + +File: test/contracts/consensus-decision.contract.ts +```typescript +/** + * Producer verification: consensus decision record. + * + * Verifies that the backend's consensus decision payload satisfies the + * refract-frontend contract. + */ +import { loadFixture } from "./fixtures"; +import { assertContract } from "./verifier"; + +describe("contract: refract-frontend :: consensus-decision", () => { + const fixture = loadFixture("refract-frontend", "consensus-decision"); + + it("accepts a well-formed decision record", () => { + const payload = { + id: "dec_123", + policyId: "pol_456", + outcome: "approve", + confidence: 9500, + decidedAt: "2024-01-01T00:00:00.000Z", + votes: [ + { oracleId: "oracle_a", vote: "approve", weight: 6000 }, + { oracleId: "oracle_b", vote: "approve", weight: 3500 }, + ], + }; + expect(() => assertContract(fixture, payload)).not.toThrow(); + }); + + it("passes additive changes (extra fields)", () => { + const payload = { + id: "dec_123", + policyId: "pol_456", + outcome: "approve", + confidence: 9500, + decidedAt: "2024-01-01T00:00:00.000Z", + votes: [], + // additive field not in the contract + traceId: "trace_789", + }; + expect(() => assertContract(fixture, payload)).not.toThrow(); + }); + + it("fails with a named field when a required field is renamed", () => { + const payload = { + id: "dec_123", + policyId: "pol_456", + outcome: "approve", + // renamed: confidence -> confidenceBps + confidenceBps: 9500, + decidedAt: "2024-01-01T00:00:00.000Z", + votes: [], + }; + expect(() => assertContract(fixture, payload)).toThrow(/field "confidence"/); + }); + + it("fails when the confidence unit changes", () => { + const payload = { + id: "dec_123", + policyId: "pol_456", + outcome: "approve", + confidence: "0.95", + decidedAt: "2024-01-01T00:00:00.000Z", + votes: [], + }; + expect(() => assertContract(fixture, payload)).toThrow(/field "confidence"/); + }); +}); +``` + +File: test/contracts/provenance.contract.ts +```typescript +/** + * Producer verification: provenance attestation. + */ +import { loadFixture } from "./fixtures"; +import { assertContract } from "./verifier"; + +describe("contract: refract-frontend :: provenance", () => { + const fixture = loadFixture("refract-frontend", "provenance"); + + it("accepts a well-formed attestation", () => { + const payload = { + attestationId: "att_1", + oracleId: "oracle_a", + subjectId: "subj_1", + attestedAt: "2024-01-01T00:00:00.000Z", + signature: "0xdeadbeef", + }; + expect(() => assertContract(fixture, payload)).not.toThrow(); + }); + + it("passes additive changes (optional metadata present)", () => { + const payload = { + attestationId: "att_1", + oracleId: "oracle_a", + subjectId: "subj_1", + attestedAt: "2024-01-01T00:00:00.000Z", + signature: "0xdeadbeef", + metadata: { source: "chain" }, + }; + expect(() => assertContract(fixture, payload)).not.toThrow(); + }); + + it("fails with a named field when signature is renamed", () => { + const payload = { + attestationId: "att_1", + oracleId: "oracle_a", + subjectId: "subj_1", + attestedAt: "2024-01-01T00:00:00.000Z", + // renamed: signature -> sig + sig: "0xdeadbeef", + }; + expect(() => assertContract(fixture, payload)).toThrow(/field "signature"/); + }); +}); +``` + +File: test/contracts/trust-status.contract.ts +```typescript +/** + * Producer verification: trust status response. + */ +import { loadFixture } from "./fixtures"; +import { assertContract } from "./verifier"; + +describe("contract: refract-frontend :: trust-status", () => { + const fixture = loadFixture("refract-frontend", "trust-status"); + + it("accepts a well-formed trust status", () => { + const payload = { + subjectId: "subj_1", + status: "trusted", + score: 8700, + updatedAt: "2024-01-01T00:00:00.000Z", + }; + expect(() => assertContract(fixture, payload)).not.toThrow(); + }); + + it("passes additive changes (optional reason present)", () => { + const payload = { + subjectId: "subj_1", + status: "untrusted", + score: 1200, + updatedAt: "2024-01-01T00:00:00.000Z", + reason: "policy violation", + }; + expect(() => assertContract(fixture, payload)).not.toThrow(); + }); + + it("fails with a named field when status is renamed", () => { + const payload = { + subjectId: "subj_1", + // renamed: status -> state + state: "trusted", + score: 8700, + updatedAt: "2024-01-01T00:00:00.000Z", + }; + expect(() => assertContract(fixture, payload)).toThrow(/field "status"/); + }); + + it("fails when status is outside the allowed enum", () => { + const payload = { + subjectId: "subj_1", + status: "unknown", + score: 8700, + updatedAt: "2024-01-01T00:00:00.000Z", + }; + expect(() => assertContract(fixture, payload)).toThrow(/field "status"/); + }); +}); +``` + +File: test/contracts/onchain-reading.contract.ts +```typescript +/** + * Producer verification: encoded on-chain reading. + * + * Verifies that the oracle reading encoder produces a payload that satisfies + * the refract-contracts contract. + */ +import { loadFixture } from "./fixtures"; +import { assertContract } from "./verifier"; + +describe("contract: refract-contracts :: onchain-reading", () => { + const fixture = loadFixture("refract-contracts", "onchain-reading"); + + it("accepts a well-formed encoded reading", () => { + const payload = { + subjectId: "subj_1", + value: 1234567890, + decimals: 7, + timestamp: 1704067200, + signature: "0xdeadbeef", + }; + expect(() => assertContract(fixture, payload)).not.toThrow(); + }); + + it("passes additive changes (extra fields)", () => { + const payload = { + subjectId: "subj_1", + value: 1234567890, + decimals: 7, + timestamp: 1704067200, + signature: "0xdeadbeef", + // additive field not in the contract + nonce: 42, + }; + expect(() => assertContract(fixture, payload)).not.toThrow(); + }); + + it("fails with a named field when value is renamed", () => { + const payload = { + subjectId: "subj_1", + // renamed: value -> amount + amount: 1234567890, + decimals: 7, + timestamp: 1704067200, + signature: "0xdeadbeef", + }; + expect(() => assertContract(fixture, payload)).toThrow(/field "value"/); + }); + + it("fails when value is encoded as a string instead of stroops number", () => { + const payload = { + subjectId: "subj_1", + value: "1234567890", + decimals: 7, + timestamp: 1704067200, + signature: "0xdeadbeef", + }; + expect(() => assertContract(fixture, payload)).toThrow(/field "value"/); + }); +}); +``` + +File: test/contracts/proof.contract.ts +```typescript +/** + * Proof test: deliberately rename a field and confirm the verifier fails with + * a readable, named-field message. + * + * This test exists to demonstrate that a breaking change in the producer is + * caught by CI. It does not mutate any real fixture; it constructs an + * in-memory fixture and a payload that violates it. + */ +import { formatViolations, verifyContract } from "./verifier"; +import type { ContractFixture } from "./types"; + +describe("contract verifier proof", () => { + const fixture: ContractFixture = { + consumer: "refract-frontend", + interface: "trust-status", + version: 1, + description: "proof fixture", + shape: { + subjectId: { type: "string" }, + status: { type: "string", enum: ["trusted", "untrusted"] }, + }, + }; + + it("fails and names the renamed field", () => { + const renamedPayload = { + subjectId: "subj_1", + // renamed: status -> state + state: "trusted", + }; + + const violations = verifyContract(fixture, renamedPayload); + expect(violations).toHaveLength(1); + expect(violations[0].path).toBe("status"); + expect(violations[0].reason).toMatch(/missing/); + + const message = formatViolations(fixture, violations); + expect(message).toContain('field "status"'); + expect(message).toContain("refract-frontend"); + expect(message).toContain("trust-status"); + }); + + it("passes when the field is present (additive change)", () => { + const additivePayload = { + subjectId: "subj_1", + status: "trusted", + // additive field + reason: "ok", + }; + expect(verifyContract(fixture, additivePayload)).toHaveLength(0); + }); +}); +``` + +File: test/contracts/README.md +```markdown +# Consumer-Driven Contract Tests + +This directory contains the consumer-driven contract verification suite for +`refract-backend`. It pins the shapes of oracle trust data produced by the +backend against the expectations of `refract-frontend` and `refract-contracts`. + +## Layout + +``` +test/contracts/ + jest.contracts.config.ts # Jest project for the suite + types.ts # Shared contract types + verifier.ts # Generated verifier + fixtures.ts # Fixture loader + fixtures/ + refract-frontend/ + consensus-decision.contract.json + provenance.contract.json + trust-status.contract.json + refract-contracts/ + onchain-reading.contract.json + *.contract.ts # Producer verification tests + proof.contract.ts # Proof test: renamed field fails +``` + +## Running + +```sh +npx jest --config test/contracts/jest.contracts.config.ts +``` + +The suite requires no live database and no network access. It runs within the +existing CI time budget. + +## Semantics + +- **Additive changes pass.** Extra fields on the producer payload are allowed. + New optional fields may be added to a contract without a version bump. +- **Breaking changes fail.** Missing required fields, type mismatches, unit + mismatches, and enum mismatches are violations. The failure message names the + offending field (e.g. `field "confidence"`). +- **Unit changes are breaking.** A field pinned with `"unit": "bps"` that + changes to a fraction is a violation even if the type stays `number`. + +## Updating a contract + +Contracts are owned by the consumer. The process is: + +1. **Consumer proposes the change.** The consumer repo (`refract-frontend` or + `refract-contracts`) opens a PR that edits the relevant + `*.contract.json` fixture. +2. **Classify the change.** + - *Additive* (new optional field, new enum value the consumer tolerates): + no version bump required. The producer's existing tests continue to pass. + - *Breaking* (rename, removal, type change, unit change, enum narrowing): + bump `version` in the fixture and note the change in the PR description. +3. **Producer verifies.** `refract-backend` CI runs + `npx jest --config test/contracts/jest.contracts.config.ts`. A breaking + change fails the build with a named field until the producer is updated. +4. **Consumer opts in.** The consumer merges the fixture change only after the + producer has shipped a compatible payload (or the consumer has updated its + rendering to match). The version bump signals the opt-in boundary. +5. **Sync the fixture.** The updated fixture is copied into + `test/contracts/fixtures//` in this repo. + +## Proof test + +`proof.contract.ts` deliberately renames a field and asserts that the verifier +fails with a readable, named-field message. This demonstrates that a breaking +change in a branch will fail CI. +``` + +File: package.json (add scripts) +```json +{ + "scripts": { + "test:contracts": "jest --config test/contracts/jest.contracts.config.ts" + } +} +``` + +File: .github/workflows/contracts.yml +```yaml +name: Contract Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + contracts: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + - name: Verify producer against consumer contracts + run: npm run test:contracts \ No newline at end of file