diff --git a/.env.example b/.env.example index eb82954..7f5a990 100644 --- a/.env.example +++ b/.env.example @@ -266,6 +266,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 4806046..6fd16d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,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 d7636dd..cd85e9a 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -138,6 +138,9 @@ reassessed against deployment exposure and on-chain contract controls. runbooks). `LiquidationShield` and `FlightDelay` remain mocked and must not be offered as live. See [`oracle-trust-architecture.md`](./oracle-trust-architecture.md) and [`runbooks/oracle-incidents.md`](./runbooks/oracle-incidents.md). + 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.** *Partially mitigated:* `oracle_events`, `claims`, and `relayer_transaction_audit` exist, but oracle writes are best-effort and partitions are dropped after the retention window, @@ -200,6 +203,123 @@ Rules that apply to every cell: status surfaces per coverage type; `paused` requires an explicit operator resume. +## 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 @@ -213,7 +333,6 @@ Rules that apply to every cell: - 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. - ## 6. Operational runbooks and evidence-integrity procedures The controls below support the P1 evidence and resilience items. They are @@ -300,3 +419,12 @@ unredacted provider payloads. A rolling window of many small breaker trips is grouped rather than producing dozens of incidents. The incident list and rendered postmortem are exposed through the operator/admin surface and stored with a stable id. + +### 6.6 Limitations of the attack-cost model + +- 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 2a89887..fdc7b94 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,26 @@ { - "testMatch": [ - "**/tests/**/*.test.ts", - "**/src/**/*.test.ts" - ], - "testTimeout": 60000, - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "moduleFileExtensions": ["js", "json", "ts"], - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": ["**/*.(t|j)s"], - "coverageDirectory": "../coverage" + "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" }, - "dependencies": { - "@aws-sdk/ + "engines": { + "node": ">=18" }, "jest": { "preset": "ts-jest", 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 7f170ab..715d474 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -1,3 +1,11 @@ +import { registerAs } from '@nestjs/config'; +import { + AttackCostModelError, + evaluateExposureBound, + type AttackCostModel, + type FeedAttackInput, +} from '../security/attack-cost-model'; + export type FeeProfileName = "moderate" | "aggressive"; export interface AppConfig { @@ -107,6 +115,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 '), + ); + } +} + /** * Feeds whose readings are validated against the versioned reference data * registry. Each feed pins an explicit dataset version so a dataset refresh diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index cd86f66..e69de29 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -1,298 +0,0 @@ -import { z } from "zod"; - -/** - * Runtime environment-variable schema. - * - * Validates every variable read by the configuration factory so that a typo - * fails fast at startup instead of surfacing later as a confusing runtime - * error (e.g. `NaN` ports or timeouts). - * - * Secrets are never echoed in validation output. - */ - -const SECRET_KEYS = new Set(["RELAYER_SECRET_KEY"]); - -/** Parse an integer, rejecting `NaN` and non-integer input explicitly. */ -const intFromEnv = (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.isInteger(parsed)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `${opts.label} must be an integer between ${opts.min} and ${opts.max} (received a non-integer value)`, - }); - 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; - }); - -const urlFromEnv = (label: string) => - z - .string() - .optional() - .transform((value, ctx) => { - if (value === undefined || value === "") { - return undefined; - } - try { - // eslint-disable-next-line no-new - new URL(value); - return value; - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `${label} must be a valid URL (e.g. https://example.com)`, - }); - return z.NEVER; - } - }); - -/** Comma-separated list of URLs; every entry must parse. */ -const urlListFromEnv = (label: string) => - z - .string() - .optional() - .transform((value, ctx) => { - if (value === undefined || value.trim() === "") { - return undefined; - } - const urls = value.split(",").map((url) => url.trim()).filter(Boolean); - for (const url of urls) { - try { - // eslint-disable-next-line no-new - new URL(url); - } catch { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `${label} contains an invalid URL: "${url}"`, - }); - return z.NEVER; - } - } - return urls; - }); - -/** StrKey validation for Stellar contract ids (C...) and secret keys (S...). */ -const strKeyFromEnv = (label: string, prefix: "C" | "S") => - z - .string() - .optional() - .transform((value, ctx) => { - if (value === undefined || value === "") { - return undefined; - } - const valid = new RegExp(`^${prefix}[A-Z2-7]{55}$`).test(value); - if (!valid) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `${label} must be a valid Stellar StrKey starting with "${prefix}"`, - }); - return z.NEVER; - } - return value; - }); - -export const envSchema = z - .object({ - NODE_ENV: z - .enum(["development", "test", "production"]) - .default("development"), - PORT: intFromEnv({ min: 1, max: 65535, label: "PORT" }), - FRONTEND_URL: urlFromEnv("FRONTEND_URL"), - STELLAR_NETWORK: z - .enum(["testnet", "mainnet", "futurenet"]) - .default("testnet"), - SOROBAN_RPC_URL: urlFromEnv("SOROBAN_RPC_URL"), - SOROBAN_RPC_URLS: urlListFromEnv("SOROBAN_RPC_URLS"), - SOROBAN_RPC_MAX_LEDGER_LAG: intFromEnv({ - min: 0, - max: 10_000, - label: "SOROBAN_RPC_MAX_LEDGER_LAG", - }), - SOROBAN_RPC_FAILURE_THRESHOLD: intFromEnv({ - min: 1, - max: 100, - label: "SOROBAN_RPC_FAILURE_THRESHOLD", - }), - SOROBAN_RPC_COOLDOWN_MS: intFromEnv({ - min: 1_000, - max: 3_600_000, - label: "SOROBAN_RPC_COOLDOWN_MS", - }), - SOROBAN_RPC_HEALTH_INTERVAL_MS: intFromEnv({ - min: 0, - max: 3_600_000, - label: "SOROBAN_RPC_HEALTH_INTERVAL_MS", - }), - SOROBAN_RPC_HEALTH_TIMEOUT_MS: intFromEnv({ - min: 100, - max: 60_000, - label: "SOROBAN_RPC_HEALTH_TIMEOUT_MS", - }), - ORACLE_HTTP_TIMEOUT_MS: intFromEnv({ - min: 100, - max: 120_000, - label: "ORACLE_HTTP_TIMEOUT_MS", - }), - HORIZON_FINALITY_DEPTH: intFromEnv({ - min: 0, - max: 1000, - label: "HORIZON_FINALITY_DEPTH", - }), - HORIZON_FINALITY_POLICY: z - .enum(["final_only", "allow_provisional"]) - .optional(), - POLICY_CONTRACT_ID: strKeyFromEnv("POLICY_CONTRACT_ID", "C"), - POOL_CONTRACT_ID: strKeyFromEnv("POOL_CONTRACT_ID", "C"), - CLAIM_CONTRACT_ID: strKeyFromEnv("CLAIM_CONTRACT_ID", "C"), - RELAYER_SECRET_KEY: strKeyFromEnv("RELAYER_SECRET_KEY", "S"), - REQUIRE_CHAIN_CONFIG: z - .enum(["true", "false"]) - .optional() - .transform((value) => value === "true"), - /** - * Reference data (versioned entity registry) settings. - * - * Feed pins are supplied as `REFERENCE_DATA_PIN_=@` - * (e.g. `REFERENCE_DATA_PIN_FLIGHTDELAY=aviation@3`). Pins are optional at - * the schema level so local tooling can run without them, but they are - * REQUIRED in production: without a pin a feed would silently follow the - * latest dataset version and a refresh could retroactively change how a - * past reading was interpreted. - */ - REFERENCE_DATA_IMPORT_DRY_RUN: z - .enum(["true", "false"]) - .optional() - .transform((value) => value === "true"), - REFERENCE_DATA_UNKNOWN_ALERT_WINDOW_MS: intFromEnv({ - min: 1_000, - max: 86_400_000, - label: "REFERENCE_DATA_UNKNOWN_ALERT_WINDOW_MS", - }), - REFERENCE_DATA_UNKNOWN_ALERT_MAX: intFromEnv({ - min: 1, - max: 10_000, - label: "REFERENCE_DATA_UNKNOWN_ALERT_MAX", - }), - }) - .catchall( - // Allow dynamic `REFERENCE_DATA_PIN_` keys; each must be a - // `@` pin. Unknown keys are rejected below. - z.unknown(), - ) - .superRefine((env, ctx) => { - const raw = env as Record; - for (const key of Object.keys(raw)) { - if (!key.startsWith("REFERENCE_DATA_PIN_")) { - continue; - } - const feed = key.slice("REFERENCE_DATA_PIN_".length); - const value = raw[key]; - if (feed.length === 0) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [key], - message: `${key} must name a feed (e.g. REFERENCE_DATA_PIN_FLIGHTDELAY)`, - }); - continue; - } - if (typeof value !== "string" || !/^[A-Za-z0-9_.-]+@\d+$/.test(value)) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [key], - message: `${key} must be a dataset pin of the form @ (e.g. aviation@3)`, - }); - } - } - - if (!env.REQUIRE_CHAIN_CONFIG) { - return; - } - const required: Array = [ - "SOROBAN_RPC_URL", - "POLICY_CONTRACT_ID", - "POOL_CONTRACT_ID", - "CLAIM_CONTRACT_ID", - "RELAYER_SECRET_KEY", - ]; - for (const key of required) { - // Any endpoint in the pool satisfies the RPC requirement. - if (key === "SOROBAN_RPC_URL" && env.SOROBAN_RPC_URLS !== undefined) { - continue; - } - if (env[key] === undefined) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - path: [key], - message: `${key} is required when REQUIRE_CHAIN_CONFIG=true`, - }); - } - } - }); - -export type Env = z.infer; - -/** - * Extract the configured reference-data dataset pins from a validated env. - * - * Returns a map of feed name (upper-cased, e.g. `FLIGHTDELAY`) to - * `{ dataset, version }`. Pins are required in production; callers should - * fail fast when a feed has no pin. - */ -export function referenceDataPins( - env: Env, -): Record { - const pins: Record = {}; - for (const [key, value] of Object.entries(env as Record)) { - if (!key.startsWith("REFERENCE_DATA_PIN_") || typeof value !== "string") { - continue; - } - const feed = key.slice("REFERENCE_DATA_PIN_".length).toUpperCase(); - const at = value.lastIndexOf("@"); - if (at <= 0) { - continue; - } - pins[feed] = { - dataset: value.slice(0, at), - version: Number(value.slice(at + 1)), - }; - } - return pins; -} - -/** - * Validate `process.env` and return the parsed, typed environment. - * - * Throws a single aggregated, human-readable error listing every invalid - * variable at once. Secret values are never included in the report. - */ -export function validateEnv(raw: NodeJS.ProcessEnv = process.env): Env { - const result = envSchema.safeParse(raw); - if (result.success) { - return result.data; - } - - const lines = result.error.issues.map((issue) => { - const key = issue.path.join(".") || "(root)"; - const shown = SECRET_KEYS.has(key) ? "" : undefined; - const detail = shown ? `${issue.message} (value ${shown})` : issue.message; - return ` - ${key}: ${detail}`; - }); - - throw new Error( - `Invalid environment configuration:\n${lines.join("\n")}`, - ); -} diff --git a/src/db/schema.sql b/src/db/schema.sql index e6621a2..e69de29 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -1,635 +0,0 @@ --- Oracle schema --- (existing tables omitted from this diff view; append the drift tables below) - -CREATE TABLE IF NOT EXISTS claims ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE, - coverage_type TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'pending', - -- When the oracle trigger was first detected for this claim. - -- Tracked distinctly from settlement so latency (trigger -> payout) is measurable. - trigger_detected_at TIMESTAMPTZ, - -- When the payout was confirmed on-chain (settlement confirmed). - settled_at TIMESTAMPTZ, - -- Legacy field retained for backwards compatibility; set after settlement. - processed_at TIMESTAMPTZ, - payout_amount NUMERIC(38, 18), - -- Degraded-mode reason code when status = 'blocked_degraded' (issue #195). - block_reason TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Schema drift detection (issue #279) --- Records every structural validation result for a provider fetch so operators --- can query a per-source drift report and inspect the last known-good sample. -CREATE TABLE IF NOT EXISTS oracle_schema_drift_events ( - id BIGSERIAL PRIMARY KEY, - source TEXT NOT NULL, - classification TEXT NOT NULL CHECK (classification IN ('ok', 'drift', 'breaking')), - reason TEXT, - -- redacted, size-limited sample of the offending payload - sample JSONB, - -- redacted, size-limited sample of the last payload that validated cleanly - last_known_good JSONB, - ingestion_id BIGINT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_oracle_schema_drift_events_source_created - ON oracle_schema_drift_events (source, created_at DESC); - -CREATE INDEX IF NOT EXISTS idx_oracle_schema_drift_events_classification - ON oracle_schema_drift_events (classification) - WHERE classification <> 'ok'; - --- Quarantine state per source. A `breaking` classification flips this on and --- blocks publication until an operator clears it. -CREATE TABLE IF NOT EXISTS oracle_source_quarantine ( - source TEXT PRIMARY KEY, - quarantined BOOLEAN NOT NULL DEFAULT FALSE, - reason TEXT, - last_event_id BIGINT REFERENCES oracle_schema_drift_events (id), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Operator reward accrual and distribution (issue #281) --- Accruals are derived from the committed evidence ledger, not from live --- submission events, so they are recomputable and auditable after the fact. --- The idempotency key makes accrual exactly-once under retries and re-orgs. -CREATE TABLE IF NOT EXISTS oracle_reward_accruals ( - id BIGSERIAL PRIMARY KEY, - -- deterministic key: consensus round + operator + reading evidence id - idempotency_key TEXT NOT NULL UNIQUE, - operator_id TEXT NOT NULL, - consensus_round BIGINT NOT NULL, - evidence_id BIGINT NOT NULL, - -- snapshot of the weighting inputs at accrual time - stake_weight NUMERIC(38, 18) NOT NULL DEFAULT 0, - reputation_score NUMERIC(10, 6) NOT NULL DEFAULT 0, - consensus_weight NUMERIC(10, 6) NOT NULL DEFAULT 0, - -- base pool share before the per-operator cap is applied - raw_amount NUMERIC(38, 18) NOT NULL DEFAULT 0, - -- amount actually credited after the cap (may be less than raw_amount) - amount NUMERIC(38, 18) NOT NULL DEFAULT 0, - -- 'accrued' | 'invalidated' | 'paid' | 'adjusted' - status TEXT NOT NULL DEFAULT 'accrued' - CHECK (status IN ('accrued', 'invalidated', 'paid', 'adjusted')), - -- set when remediation invalidates a previously accepted reading - invalidated_at TIMESTAMPTZ, - invalidation_reason TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_oracle_reward_accruals_operator - ON oracle_reward_accruals (operator_id, status); - -CREATE INDEX IF NOT EXISTS idx_oracle_reward_accruals_round - ON oracle_reward_accruals (consensus_round); - --- Durable ledger of adjustments and payouts against accruals. Every row is --- append-only; balances are derived by summing accruals minus p - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_oracle_reward_accruals_operator - ON oracle_reward_accruals (operator_id, status); - -CREATE INDEX IF NOT EXISTS idx_oracle_reward_accruals_round - ON oracle_reward_accruals (consensus_round); - --- Durable ledger of adjustments and payouts against accruals. Every row is --- append-only; balances are derived by summing accruals minus payouts. -CREATE TABLE IF NOT EXISTS oracle_reward_ledger ( - id BIGSERIAL PRIMARY KEY, - -- deterministic key: payout batch + operator, or adjustment + accrual - idempotency_key TEXT NOT NULL UNIQUE, - operator_id TEXT NOT NULL, - accrual_id BIGINT REFERENCES oracle_reward_accruals (id), - payout_id BIGINT, - -- 'accrual' | 'adjustment' | 'payout' - entry_type TEXT NOT NULL - CHECK (entry_type IN ('accrual', 'adjustment', 'payout')), - -- signed: positive credits the operator, negative debits - amount NUMERIC(38, 18) NOT NULL, - memo TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_oracle_reward_ledger_operator - ON oracle_reward_ledger (operator_id, created_at DESC); - --- Scheduled distribution batches. Funds move only after the dispute window --- closes and only when the operator balance meets the minimum threshold. -CREATE TABLE IF NOT EXISTS oracle_reward_payouts ( - id BIGSERIAL PRIMARY KEY, - -- deterministic key: distribution schedule slot + operator - idempotency_key TEXT NOT NULL UNIQUE, - operator_id TEXT NOT NULL, - amount NUMERIC(38, 18) NOT NULL, - -- 'pending' | 'disputed' | 'paid' | 'voided' - status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'disputed', 'paid', 'voided')), - -- funds may not move before this timestamp (dispute window) - dispute_window_ends_at TIMESTAMPTZ NOT NULL, - paid_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_oracle_reward_payouts_operator - ON oracle_reward_payouts (operator_id, status); - -CREATE INDEX IF NOT EXISTS idx_oracle_reward_payouts_pending - ON oracle_reward_payouts (dispute_window_ends_at) - WHERE status = 'pending'; - --- Per-feed degraded-mode state (issue #195). Fail-closed: only 'healthy' allows --- publishing or settlement. See docs/threat-model.md section 4A. -CREATE TABLE IF NOT EXISTS oracle_feed_state ( - coverage_type SMALLINT PRIMARY KEY CHECK (coverage_type BETWEEN 0 AND 4), - health TEXT NOT NULL DEFAULT 'healthy' - CHECK (health IN ('healthy', 'degraded', 'paused')), - reason TEXT CHECK (reason IN ( - 'quorum_shortfall', - 'no_fresh_sources', - 'sources_divergent', - 'consensus_unavailable' - )), - consecutive_failures INTEGER NOT NULL DEFAULT 0 CHECK (consecutive_failures >= 0), - entered_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CHECK (health = 'healthy' OR reason IS NOT NULL) -); - --- Supports percentile (PERCENTILE_CONT) latency queries grouped by coverage type. -CREATE INDEX IF NOT EXISTS idx_claims_coverage_settled - ON claims (coverage_type, settled_at) - WHERE settled_at IS NOT NULL AND trigger_detected_at IS NOT NULL; - --- Supports loss-ratio aggregation (payouts vs. premiums) per coverage type. -CREATE INDEX IF NOT EXISTS idx_claims_coverage_status - ON claims (coverage_type, status); - --- --------------------------------------------------------------------------- --- Versioned reference data registry (issue #287) --- --- NOTE: the migration under src/db/migrations/ is the authoritative source for --- existing databases. The DDL below mirrors it so fresh installs and the --- canonical schema stay in sync. Reference data is immutable per dataset --- version; feeds pin a dataset version so a refresh cannot retroactively --- change how a past reading was interpreted. --- --------------------------------------------------------------------------- - --- A versioned snapshot of reference data for a given entity domain --- (e.g. 'flightdelay.airports', 'liquidation.protocols', 'price.assets'). -CREATE TABLE IF NOT EXISTS reference_datasets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - -- Stable logical name for the dataset across versions. - dataset_key TEXT NOT NULL, - -- Monotonically increasing version per dataset_key. - version INTEGER NOT NULL, - -- Domain of entities covered, e.g. 'airport', 'route', 'carrier', - -- 'protocol', 'market', 'chain', 'asset'. - entity_type TEXT NOT NULL, - -- Validity window of the dataset version itself. - effective_from TIMESTAMPTZ NOT NULL, - effective_to TIMESTAMPTZ, - -- Provenance of the imported data (source name, URI, checksum, etc.). - source TEXT, - metadata JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT reference_datasets_version_positive CHECK (version > 0), - CONSTRAINT reference_datasets_effective_window - CHECK (effective_to IS NULL OR effective_to > effective_from), - CONSTRAINT reference_datasets_key_version_unique UNIQUE (dataset_key, version) -); - -CREATE INDEX IF NOT EXISTS idx_reference_datasets_key_effective - ON reference_datasets (dataset_key, effective_from DESC); - --- Canonical entities belonging to a dataset version. -CREATE TABLE IF NOT EXISTS reference_entities ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - dataset_id UUID NOT NULL REFERENCES reference_datasets(id) ON DELETE CASCADE, - -- Canonical identifier used in evidence records (e.g. 'KJFK', 'aave-v3'). - canonical_id TEXT NOT NULL, - entity_type TEXT NOT NULL, - -- Human-readable name; may change between versions (entity renames). - display_name TEXT, - -- Chain/contract disambiguation for ticker collisions (e.g. USDC on - -- multiple chains). NULL for entity types that do not need it. - chain_id TEXT, - contract_address TEXT, - -- Per-entity validity window; retired entities keep historical rows so - -- delayed historical readings can still be resolved. - effective_from TIMESTAMPTZ NOT NULL, - effective_to TIMESTAMPTZ, - -- 'active' | 'retired' | 'delisted' etc. Retired entities are rejected - -- for new readings but remain resolvable for historical evidence. - status TEXT NOT NULL DEFAULT 'active', - attributes JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT reference_entities_effective_window - CHECK (effective_to IS NULL OR effective_to > effective_from), - CONSTRAINT reference_entities_dataset_canonical_unique - UNIQUE (dataset_id, canonical_id) -); - -CREATE INDEX IF NOT EXISTS idx_reference_entities_dataset_type - ON reference_entities (dataset_id, entity_type); - -CREATE INDEX IF NOT EXISTS idx_reference_entities_canonical - ON reference_entities (canonical_id); - --- Maps provider-native IDs (and provider-specific aliases) to canonical IDs. --- Supports many-to-one: several provider IDs may resolve to one canonical --- entity, and the same provider ID may differ per provider. -CREATE TABLE IF NOT EXISTS reference_entity_aliases ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - dataset_id UUID NOT NULL REFERENCES reference_datasets(id) ON DELETE CASCADE, - entity_id UUID NOT NULL REFERENCES reference_entities(id) ON DELETE CASCADE, - -- Provider namespace, e.g. 'nexus', 'aviationstack', 'coingecko'. - provider TEXT NOT NULL, - -- Provider-native identifier or alias. - alias TEXT NOT NULL, - -- Optional chain/contract scoping to disambiguate ticker collisions. - chain_id TEXT, - contract_address TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - -- An alias must be unambiguous within a provider + dataset + scope; - -- ambiguous matches are rejected at resolution time. - CONSTRAINT reference_entity_aliases_unique - UNIQUE (dataset_id, provider, alias, chain_id, contract_address) -); - -CREATE INDEX IF NOT EXISTS idx_reference_entity_aliases_lookup - ON reference_entity_aliases (dataset_id, provider, alias); - -CREATE INDEX IF NOT EXISTS idx_reference_entity_aliases_entity - ON reference_entity_aliases (entity_id); - --- Pins a feed to a specific dataset version so updates are promoted --- deliberately and past readings keep their original interpretation. -CREATE TABLE IF NOT EXISTS feed_reference_pins ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - feed_id TEXT NOT NULL, - dataset_key TEXT NOT NULL, - dataset_id UUID NOT NULL REFERENCES reference_datasets(id) ON DELETE RESTRICT, - pinned_at TIMESTAMPTZ NOT NULL DEFAULT now(), - pinned_by TEXT, - CONSTRAINT feed_reference_pins_unique UNIQUE (feed_id, dataset_key) -); - -CREATE INDEX IF NOT EXISTS idx_feed_reference_pins_dataset - ON feed_reference_pins (dataset_id); - --- Readings rejected at ingestion (unknown/retired/ambiguous entity) are --- diverted here with a reason and never counted toward quorum. -CREATE TABLE IF NOT EXISTS dead_letter_readings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - feed_id TEXT NOT NULL, - -- Original reading payload as received from the provider. - payload JSONB NOT NULL, - -- Machine-readable rejection reason, e.g. 'unknown_entity', - -- 'retired_entity', 'ambiguous_entity', 'unpinned_dataset'. - reason TEXT NOT NULL, - detail TEXT, - -- Provider-native entity reference that failed resolution, if any. - provider TEXT, - provider_entity_id TEXT, - dataset_id UUID REFERENCES reference_datasets(id) ON DELETE SET NULL, - received_at TIMESTAMPTZ NOT NULL DEFAULT now(), - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_dead_letter_readings_feed_reason - ON dead_letter_readings (feed_id, reason, received_at DESC); - -CREATE INDEX IF NOT EXISTS idx_dead_letter_readings_received - ON dead_letter_readings (received_at DESC); - --- Versioned oracle feed definitions (issue #196). See migrations/004_oracle_feed_definitions.sql. -CREATE TABLE IF NOT EXISTS oracle_feed_definitions ( - feed_key TEXT NOT NULL, - version INTEGER NOT NULL CHECK (version >= 1), - definition JSONB NOT NULL, - previous_definition JSONB, - change_type TEXT NOT NULL CHECK (change_type IN ('create', 'update')), - changed_by TEXT NOT NULL, - changed_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (feed_key, version) -); - --- Per-source reliability state for oracle feeds. Updated in one batch per --- polling cycle; state transitions are computed by src/oracle/sourceHealth.ts. -CREATE TABLE IF NOT EXISTS oracle_source_health ( - feed TEXT NOT NULL, - source TEXT NOT NULL, - state TEXT NOT NULL DEFAULT 'healthy' - CHECK (state IN ('healthy', 'degraded', 'quarantined')), - consecutive_failures INTEGER NOT NULL DEFAULT 0, - consecutive_successes INTEGER NOT NULL DEFAULT 0, - -- Trailing-window rates, 0..1. Recomputed from the window each cycle. - error_rate NUMERIC(5, 4) NOT NULL DEFAULT 0, - timeout_rate NUMERIC(5, 4) NOT NULL DEFAULT 0, - divergence_rate NUMERIC(5, 4) NOT NULL DEFAULT 0, - sample_count INTEGER NOT NULL DEFAULT 0, - last_value NUMERIC(38, 18), - last_success_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (feed, source) -); - --- Operator overrides (force quarantine / force clear). Append-only audit trail. -CREATE TABLE IF NOT EXISTS oracle_source_health_overrides ( - id BIGSERIAL PRIMARY KEY, - feed TEXT NOT NULL, - source TEXT NOT NULL, - action TEXT NOT NULL CHECK (action IN ('force_quarantine', 'force_clear')), - actor TEXT NOT NULL, - reason TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_oracle_source_health_overrides_feed_source - ON oracle_source_health_overrides (feed, source, created_at); - --- Oracle evidence ledger (issue #176). Append-only guards (triggers) are --- installed by src/db/migrations/004_oracle_evidence.sql. -CREATE TABLE IF NOT EXISTS oracle_evidence ( - id UUID PRIMARY KEY, - source VARCHAR(40) NOT NULL, - coverage_type TEXT NOT NULL, - normalized_value NUMERIC(20, 6) NOT NULL, - raw_payload TEXT NOT NULL, - observed_at TIMESTAMPTZ NOT NULL, - ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- ─── Oracle node operator registry (issue #191) ───────────────────────────── --- The on-chain registry is the source of truth; this table is the Postgres --- cache of it plus the local audit trail. - -CREATE TABLE IF NOT EXISTS operators ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - -- Stellar ed25519 public key (G...) that signs readings for this operator. - submission_key TEXT NOT NULL UNIQUE, - supported_feeds TEXT[] NOT NULL DEFAULT '{}', - status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'active', 'suspended', 'exited')), - registered_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Replay protection for signed registration requests. A nonce is only needed --- while its request timestamp is inside the accepted window. -CREATE TABLE IF NOT EXISTS operator_registration_nonces ( - submission_key TEXT NOT NULL, - nonce TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - PRIMARY KEY (submission_key, nonce) -); - -CREATE TABLE IF NOT EXISTS operator_audit ( - id BIGSERIAL PRIMARY KEY, - operator_id UUID NOT NULL REFERENCES operators(id), - event TEXT NOT NULL, - from_status TEXT, - to_status TEXT NOT NULL, - actor TEXT NOT NULL, - reason TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Oracle operator stake. Mirrors the confirmed on-chain bond held by the --- staking contract; the backend never custodies funds. -CREATE TABLE IF NOT EXISTS operator_bonds ( - operator_id TEXT PRIMARY KEY, - bonded_amount NUMERIC(38, 0) NOT NULL DEFAULT 0 CHECK (bonded_amount >= 0), - -- Set on first bond; bond age gates eligibility to serve feeds. - first_bonded_at TIMESTAMPTZ, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - --- Unbonding requests. Amounts stay slashable until withdrawn. -CREATE TABLE IF NOT EXISTS unbond_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id TEXT NOT NULL REFERENCES operator_bonds(operator_id), - amount NUMERIC(38, 0) NOT NULL CHECK (amount > 0), - requested_at TIMESTAMPTZ NOT NULL DEFAULT now(), - available_at TIMESTAMPTZ NOT NULL, - status TEXT NOT NULL DEFAULT 'unbonding' - CHECK (status IN ('unbonding', 'withdrawn', 'slashed')), - withdrawn_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_unbond_requests_operator_status - ON unbond_requests (operator_id, status); - --- Durable slashing proposals. evidence_ids reference immutable evidence --- records; evidence_key makes proposal creation idempotent per evidence set. -CREATE TABLE IF NOT EXISTS slashing_proposals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - operator_id TEXT NOT NULL REFERENCES operator_bonds(operator_id), - reason TEXT NOT NULL - CHECK (reason IN ('outside_tolerance', 'post_quarantine', 'equivocation')), - evidence_ids TEXT[] NOT NULL CHECK (cardinality(evidence_ids) > 0), - evidence_key TEXT NOT NULL UNIQUE, - amount NUMERIC(38, 0) NOT NULL CHECK (amount > 0), - status TEXT NOT NULL DEFAULT 'pending_review' - CHECK (status IN ('pending_review', 'pending_appeal', 'approved', 'rejected', 'executed')), - -- Only set for bounded auto-slashes; the proposal is reviewable until this time. - appeal_until TIMESTAMPTZ, - reviewed_by TEXT, - reviewed_at TIMESTAMPTZ, - execution_tx_hash TEXT, - executed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_slashing_proposals_operator_status - ON slashing_proposals (operator_id, status); - --- Settlement state machine: one row per consensus decision (issue #198). --- A non-failed row also blocks a second decision for the same policy. -CREATE TABLE IF NOT EXISTS claim_settlements ( - decision_id TEXT PRIMARY KEY, - policy_id UUID NOT NULL REFERENCES policies(id) ON DELETE CASCADE, - amount NUMERIC(38, 18) NOT NULL CHECK (amount > 0), - status TEXT NOT NULL DEFAULT 'pending' CHECK ( - status IN ('pending', 'submitted', 'confirmed', 'failed') - ), - tx_hash VARCHAR(64), - evidence JSONB NOT NULL DEFAULT '{}'::jsonb, - last_error TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_settlements_policy_open - ON claim_settlements (policy_id) - WHERE status IN ('pending', 'submitted', 'confirmed'); - --- ─── Oracle trust engine ───────────────────────────────────────────────────── --- Mirrors migrations/002_oracle_trust_engine.sql. Keep the two in sync. - --- Durable pending-decisions queue for batched oracle publishing --- (persist-then-publish). Full definition and indexes: --- src/db/migrations/004_oracle_publish_queue.sql -CREATE TABLE IF NOT EXISTS oracle_publish_queue ( - id BIGSERIAL PRIMARY KEY, - decision_key TEXT NOT NULL UNIQUE, - feed TEXT NOT NULL, - value_scaled NUMERIC(40, 0) NOT NULL, - priority SMALLINT NOT NULL DEFAULT 0, - status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending', 'inflight', 'published', 'failed')), - attempts INTEGER NOT NULL DEFAULT 0, - next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), - last_error TEXT, - tx_hash TEXT, - ledger BIGINT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - published_at TIMESTAMPTZ -); - --- Oracle source registry metadata used for independence modeling (issue #206). --- Sources sharing operator, upstream origin, or hosting ASN are clustered together --- unless declared_cluster is set. -CREATE TABLE IF NOT EXISTS oracle_sources ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - slug TEXT NOT NULL UNIQUE, - coverage_types TEXT[] NOT NULL DEFAULT '{}', - source_class TEXT NOT NULL, - independence_group TEXT NOT NULL, - enabled BOOLEAN NOT NULL DEFAULT true, - trust_tier SMALLINT NOT NULL DEFAULT 0, - operator_id TEXT, - upstream_origin TEXT, - hosting_asn TEXT, - -- Admin-declared independence cluster; NULL means inferred and unverified. - declared_cluster TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT chk_oracle_source_slug_nonempty - CHECK (length(slug) > 0), - CONSTRAINT chk_oracle_source_trust_tier_nonneg - CHECK (trust_tier >= 0) -); - -CREATE INDEX IF NOT EXISTS idx_oracle_sources_operator - ON oracle_sources (operator_id) - WHERE operator_id IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_oracle_sources_upstream - ON oracle_sources (upstream_origin) - WHERE upstream_origin IS NOT NULL; - --- Append-only ledger of cluster overrides. Reverting an override appends a new row --- that restores previous_cluster, so the full history stays auditable. -CREATE TABLE IF NOT EXISTS source_cluster_overrides ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - source_id UUID NOT NULL REFERENCES oracle_sources(id) ON DELETE CASCADE, - previous_cluster TEXT, - new_cluster TEXT, - actor TEXT NOT NULL, - reason TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now() -); - -CREATE INDEX IF NOT EXISTS idx_source_cluster_overrides_source - ON source_cluster_overrides (source_id, created_at); - -CREATE TABLE IF NOT EXISTS oracle_readings ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - source_id UUID NOT NULL REFERENCES oracle_sources(id), - feed_key TEXT NOT NULL, - raw_payload JSONB NOT NULL, - normalized_value NUMERIC(38, 18) NOT NULL, - unit TEXT NOT NULL, - observed_at TIMESTAMPTZ NOT NULL, - ingested_at TIMESTAMPTZ NOT NULL DEFAULT now(), - submitter_ref TEXT, - signature TEXT, - CONSTRAINT chk_oracle_reading_payload_size - CHECK (octet_length(raw_payload::text) <= 65536), - CONSTRAINT chk_oracle_reading_ingested_after_observed - CHECK (ingested_at >= observed_at - INTERVAL '5 minutes') -); - -CREATE INDEX IF NOT EXISTS idx_oracle_readings_feed_observed - ON oracle_readings (feed_key, observed_at DESC); - -CREATE INDEX IF NOT EXISTS idx_oracle_readings_observed_at - ON oracle_readings (observed_at DESC); - -CREATE INDEX IF NOT EXISTS idx_oracle_readings_source - ON oracle_readings (source_id, observed_at DESC); - -CREATE OR REPLACE FUNCTION oracle_readings_reject_mutation() -RETURNS TRIGGER -LANGUAGE plpgsql -AS $$ -BEGIN - RAISE EXCEPTION 'oracle_readings is append-only; % is not permitted', TG_OP; -END; -$$; - -DROP TRIGGER IF EXISTS trg_oracle_readings_immutable ON oracle_readings; -CREATE TRIGGER trg_oracle_readings_immutable - BEFORE UPDATE OR DELETE ON oracle_readings - FOR EACH ROW EXECUTE FUNCTION oracle_readings_reject_mutation(); - -CREATE TABLE IF NOT EXISTS oracle_decisions ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - feed_key TEXT NOT NULL, - chosen_value NUMERIC(38, 18) NOT NULL, - method TEXT NOT NULL, - quorum_required SMALLINT NOT NULL, - quorum_achieved SMALLINT NOT NULL, - divergence_bps INTEGER NOT NULL, - trust_mode TEXT NOT NULL, - decided_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT chk_oracle_decision_quorum_required_positive - CHECK (quorum_required >= 1), - CONSTRAINT chk_oracle_decision_quorum_achieved_nonneg - CHECK (quorum_achieved >= 0), - CONSTRAINT chk_oracle_decision_divergence_nonneg - CHECK (divergence_bps >= 0) -); - -CREATE INDEX IF NOT EXISTS idx_oracle_decisions_feed_decided - ON oracle_decisions (feed_key, decided_at DESC); - -CREATE TABLE IF NOT EXISTS oracle_decision_readings ( - decision_id UUID NOT NULL REFERENCES oracle_decisions(id) ON DELETE CASCADE, - reading_id UUID NOT NULL REFERENCES oracle_readings(id), - PRIMARY KEY (decision_id, reading_id) -); - -CREATE INDEX IF NOT EXISTS idx_oracle_decision_readings_reading - ON oracle_decision_readings (reading_id); - -CREATE TABLE IF NOT EXISTS oracle_source_health ( - source_id UUID PRIMARY KEY REFERENCES oracle_sources(id) ON DELETE CASCADE, - consecutive_failures INTEGER NOT NULL DEFAULT 0, - failures_window INTEGER NOT NULL DEFAULT 0, - successes_window INTEGER NOT NULL DEFAULT 0, - last_success_at TIMESTAMPTZ, - last_failure_at TIMESTAMPTZ, - quarantined_at TIMESTAMPTZ, - quarantine_reason TEXT, - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), - CONSTRAINT chk_oracle_health_consecutive_failures_nonneg - CHECK (consecutive_failures >= 0), - CONSTRAINT chk_oracle_health_failures_window_nonneg - CHECK (failures_window >= 0), - CONSTRAINT chk_oracle_health_successes_window_nonneg - CHECK (successes_window >= 0), - CONSTRAINT chk_oracle_health_quarantine_reason - CHECK (quarantined_at IS NULL OR quarantine_reason IS NOT NULL) -); - 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.controller.ts b/src/oracle/oracle.controller.ts index 2202991..e69de29 100644 --- a/src/oracle/oracle.controller.ts +++ b/src/oracle/oracle.controller.ts @@ -1,325 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Param, - Query, - HttpCode, - HttpStatus, - NotFoundException, - BadRequestException, - UseGuards, - Req, -} from '@nestjs/common'; -import { Request } from 'express'; -import { OracleService } from './oracle.service'; -import { MethodologyService } from './methodology.service'; -import { IncidentService } from '../services/incidentService'; -import { AuthGuard } from '../auth/auth.guard'; -import { ScopesGuard } from '../auth/scopes.guard'; -import { Scopes } from '../auth/scopes.decorator'; -import { AuditService } from '../services/auditService'; - -interface CreateMethodologyDto { - sourceId: string; - derivationDescription: string; - samplingUniverse: string; - revisionPolicy: string; - expectedLatencyMs: number; - knownFailureModes: string[]; - independenceWeight?: number; - approved?: boolean; - signOff?: { - approvedBy?: string; - reviewedBy?: string; - approvedAt?: string; - }; -} - -interface ApproveMethodologyDto { - approvedBy: string; - reviewedBy: string; - notes?: string; -} - -@Controller() -export class OracleController { - constructor( - private readonly oracleService: OracleService, - private readonly methodologyService: MethodologyService, - private readonly incidentService: IncidentService, - private readonly auditService: AuditService, - ) {} - - // --------------------------------------------------------------------------- - // Public trust / provenance surface - // --------------------------------------------------------------------------- - - /** - * GET /api/v1/oracle/feeds/:feed/methodology - * - * Public trust/provenance endpoint. Returns the methodology version(s) - * currently referenced by the feed's latest consensus decision, so that - * point-in-time reconstruction resolves the exact semantics used. - */ - @Get('api/v1/oracle/feeds/:feed/methodology') - async getFeedMethodology(@Param('feed') feed: string) { - const feedInfo = await this.oracleService.getFeed(feed); - if (!feedInfo) { - throw new NotFoundException(`Feed not found: ${feed}`); - } - - const methodology = await this.methodologyService.getMethodologyForFeed(feed); - if (!methodology) { - throw new NotFoundException(`No methodology recorded for feed: ${feed}`); - } - - return { - feed, - methodology, - }; - } - - // --------------------------------------------------------------------------- - // Operator: methodology management - // --------------------------------------------------------------------------- - - /** - * GET /api/v1/ops/oracle/sources/:id/methodology - * - * Returns the full methodology version history for a source, plus the - * currently active version. - */ - @Get('api/v1/ops/oracle/sources/:id/methodology') - @UseGuards(AuthGuard, ScopesGuard) - @Scopes('oracle:read') - async getSourceMethodology(@Param('id') sourceId: string) { - const history = await this.methodologyService.getMethodologyHistory(sourceId); - if (!history || history.length === 0) { - throw new NotFoundException(`No methodology recorded for source: ${sourceId}`); - } - - const active = await this.methodologyService.getActiveMethodology(sourceId); - - return { - sourceId, - activeVersion: active ? active.version : null, - active, - history, - }; - } - - /** - * POST /api/v1/ops/oracle/sources/:id/methodology - * - * Creates a new immutable methodology version for a source. If `approved` - * is true and sign-off flags are present, the new version becomes the - * active version and the source may be activated. Audit entries are - * recorded for every approval. - */ - @Post('api/v1/ops/oracle/sources/:id/methodology') - @HttpCode(HttpStatus.CREATED) - @UseGuards(AuthGuard, ScopesGuard) - @Scopes('oracle:write') - async createSourceMethodology( - @Param('id') sourceId: string, - @Body() body: CreateMethodologyDto, - @Req() req: Request, - ) { - if (body.sourceId && body.sourceId !== sourceId) { - throw new BadRequestException( - `Path source id (${sourceId}) does not match body sourceId (${body.sourceId})`, - ); - } - - const validation = this.methodologyService.validateMethodologyRecord({ - sourceId, - derivationDescription: body.derivationDescription, - samplingUniverse: body.samplingUniverse, - revisionPolicy: body.revisionPolicy, - expectedLatencyMs: body.expectedLatencyMs, - knownFailureModes: body.knownFailureModes, - }); - - if (!validation.valid) { - throw new BadRequestException({ - message: 'Incomplete methodology record', - errors: validation.errors, - }); - } - - const approved = body.approved === true; - if (approved) { - const signOff = body.signOff || {}; - if (!signOff.approvedBy || !signOff.reviewedBy) { - throw new BadRequestException( - 'Approval requires explicit sign-off flags: approvedBy and reviewedBy', - ); - } - } - - const record = await this.methodologyService.createMethodologyVersion({ - sourceId, - derivationDescription: body.derivationDescription, - samplingUniverse: body.samplingUniverse, - revisionPolicy: body.revisionPolicy, - expectedLatencyMs: body.expectedLatencyMs, - knownFailureModes: body.knownFailureModes, - independenceWeight: body.independenceWeight, - approved, - signOff: body.signOff, - }); - - if (approved) { - await this.auditService.record({ - action: 'oracle.methodology.approve', - actor: (req as any).user?.id ?? 'unknown', - targetType: 'oracle_source', - targetId: sourceId, - metadata: { - methodologyId: record.id, - version: record.version, - approvedBy: body.signOff?.approvedBy, - reviewedBy: body.signOff?.reviewedBy, - }, - }); - } else { - await this.auditService.record({ - action: 'oracle.methodology.create', - actor: (req as any).user?.id ?? 'unknown', - targetType: 'oracle_source', - targetId: sourceId, - metadata: { - methodologyId: record.id, - version: record.version, - }, - }); - } - - return record; - } - - /** - * POST /api/v1/ops/oracle/sources/:id/methodology/:version/approve - * - * Approves an existing (draft) methodology version with explicit sign-off - * flags, making it the active version for the source. - */ - @Post('api/v1/ops/oracle/sources/:id/methodology/:version/approve') - @HttpCode(HttpStatus.OK) - @UseGuards(AuthGuard, ScopesGuard) - @Scopes('oracle:write') - async approveSourceMethodology( - @Param('id') sourceId: string, - @Param('version') version: string, - @Body() body: ApproveMethodologyDto, - @Req() req: Request, - ) { - if (!body.approvedBy || !body.reviewedBy) { - throw new BadRequestException( - 'Approval requires explicit sign-off flags: approvedBy and reviewedBy', - ); - } - - const versionNum = Number(version); - if (!Number.isInteger(versionNum) || versionNum <= 0) { - throw new BadRequestException(`Invalid methodology version: ${version}`); - } - - const record = await this.methodologyService.approveMethodologyVersion({ - sourceId, - version: versionNum, - approvedBy: body.approvedBy, - reviewedBy: body.reviewedBy, - notes: body.notes, - }); - - if (!record) { - throw new NotFoundException( - `Methodology version ${version} not found for source: ${sourceId}`, - ); - } - - await this.auditService.record({ - action: 'oracle.methodology.approve', - actor: (req as any).user?.id ?? 'unknown', - targetType: 'oracle_source', - targetId: sourceId, - metadata: { - methodologyId: record.id, - version: record.version, - approvedBy: body.approvedBy, - reviewedBy: body.reviewedBy, - notes: body.notes, - }, - }); - - return record; - } - - // --------------------------------------------------------------------------- - // Operator: incidents and postmortems - // --------------------------------------------------------------------------- - - /** - * GET /api/v1/ops/incidents - * - * Lists incidents, optionally filtered by feed, status, or trigger class. - */ - @Get('api/v1/ops/incidents') - @UseGuards(AuthGuard, ScopesGuard) - @Scopes('oracle:read') - async listIncidents( - @Query('feed') feed?: string, - @Query('status') status?: string, - @Query('trigger') trigger?: string, - @Query('limit') limit?: string, - @Query('cursor') cursor?: string, - ) { - const parsedLimit = limit ? Number(limit) : undefined; - if (parsedLimit !== undefined && (!Number.isInteger(parsedLimit) || parsedLimit <= 0)) { - throw new BadRequestException(`Invalid limit: ${limit}`); - } - - return this.incidentService.listIncidents({ - feed, - status, - triggerClass: trigger, - limit: parsedLimit, - cursor, - }); - } - - /** - * GET /api/v1/ops/incidents/:id/postmortem - * - * Renders the draft postmortem (markdown) for an incident from its stored - * event timeline. Rendering is deterministic and cached by incident version. - */ - @Get('api/v1/ops/incidents/:id/postmortem') - @UseGuards(AuthGuard, ScopesGuard) - @Scopes('oracle:read') - async getIncidentPostmortem(@Param('id') incidentId: string) { - const incident = await this.incidentService.getIncident(incidentId); - if (!incident) { - throw new NotFoundException(`Incident not found: ${incidentId}`); - } - - const postmortem = await this.incidentService.renderPostmortem(incidentId); - if (!postmortem) { - throw new NotFoundException(`Postmortem not available for incident: ${incidentId}`); - } - - return { - incidentId, - version: incident.version, - status: incident.status, - triggerClass: incident.triggerClass, - openedAt: incident.openedAt, - closedAt: incident.closedAt, - markdown: postmortem.markdown, - generatedAt: postmortem.generatedAt, - }; - } -} \ 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 3c41ac6..e69de29 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -1,517 +0,0 @@ -import { randomUUID } from 'crypto'; -import { Pool, PoolClient } from 'pg'; - -/** - * Methodology registry types. - * - * Methodology records describe how a source derives the values it publishes - * (aggregation method, sampling universe, revision policy, latency profile, - * known failure modes). Records are immutable: edits create a new version. - */ -export interface MethodologyRecord { - id: string; - sourceId: string; - version: number; - derivationDescription: string; - samplingUniverse: string; - revisionPolicy: string; - expectedLatencyMs: number; - knownFailureModes: string[]; - approved: boolean; - approvedBy: string | null; - approvedAt: Date | null; - signOffIndependence: boolean; - signOffAccuracy: boolean; - signOffLegal: boolean; - reattestationDueAt: Date | null; - lastReattestedAt: Date | null; - divergenceFlagged: boolean; - divergenceNotes: string | null; - createdAt: Date; - createdBy: string | null; -} - -export interface CreateMethodologyInput { - sourceId: string; - derivationDescription: string; - samplingUniverse: string; - revisionPolicy: string; - expectedLatencyMs: number; - knownFailureModes: string[]; - reattestationIntervalDays?: number; - createdBy?: string; -} - -export interface ApproveMethodologyInput { - methodologyId: string; - approvedBy: string; - signOffIndependence: boolean; - signOffAccuracy: boolean; - signOffLegal: boolean; -} - -export interface ObservedBehavior { - /** Observed aggregation method, if detectable. */ - aggregationMethod?: string; - /** Observed sampling universe, if detectable. */ - samplingUniverse?: string; - /** Observed latency in ms. */ - latencyMs?: number; - /** Free-form notes. */ - notes?: string; -} - -export interface MethodologyDivergence { - sourceId: string; - methodologyId: string; - methodologyVersion: number; - reasons: string[]; - observed: ObservedBehavior; - flaggedAt: Date; -} - -export interface FeedMethodologyView { - feedId: string; - sources: Array<{ - sourceId: string; - methodologyId: string; - version: number; - derivationDescription: string; - samplingUniverse: string; - revisionPolicy: string; - expectedLatencyMs: number; - knownFailureModes: string[]; - approved: boolean; - approvedAt: Date | null; - divergenceFlagged: boolean; - reattestationDueAt: Date | null; - }>; -} - -export class MethodologyError extends Error { - constructor(message: string, public readonly code: string) { - super(message); - this.name = 'MethodologyError'; - } -} - -export interface OracleServiceDeps { - pool: Pool; - logger?: { - info: (msg: string, meta?: Record) => void; - warn: (msg: string, meta?: Record) => void; - error: (msg: string, meta?: Record) => void; - }; -} - -const DEFAULT_REATTESTATION_INTERVAL_DAYS = 90; - -export class OracleService { - private readonly pool: Pool; - private readonly logger: NonNullable; - - constructor(deps: OracleServiceDeps) { - this.pool = deps.pool; - this.logger = - deps.logger ?? - ({ - info: () => undefined, - warn: () => undefined, - error: () => undefined, - } as NonNullable); - } - - // --------------------------------------------------------------------------- - // Methodology registry - // --------------------------------------------------------------------------- - - /** - * Create a new immutable methodology version for a source. - * The version number is derived from the current max version for the source. - */ - async createMethodologyVersion( - input: CreateMethodologyInput, - client?: PoolClient, - ): Promise { - if (!input.sourceId) { - throw new MethodologyError('sourceId is required', 'MISSING_SOURCE_ID'); - } - if (!input.derivationDescription || input.derivationDescription.trim().length === 0) { - throw new MethodologyError( - 'derivationDescription is required', - 'MISSING_DERIVATION', - ); - } - if (!input.samplingUniverse || input.samplingUniverse.trim().length === 0) { - throw new MethodologyError( - 'samplingUniverse is required', - 'MISSING_SAMPLING_UNIVERSE', - ); - } - if (!input.revisionPolicy || input.revisionPolicy.trim().length === 0) { - throw new MethodologyError( - 'revisionPolicy is required', - 'MISSING_REVISION_POLICY', - ); - } - if ( - typeof input.expectedLatencyMs !== 'number' || - !Number.isFinite(input.expectedLatencyMs) || - input.expectedLatencyMs < 0 - ) { - throw new MethodologyError( - 'expectedLatencyMs must be a non-negative number', - 'INVALID_LATENCY', - ); - } - if (!Array.isArray(input.knownFailureModes) || input.knownFailureModes.length === 0) { - throw new MethodologyError( - 'knownFailureModes must be a non-empty array', - 'MISSING_FAILURE_MODES', - ); - } - - const runner = client ?? this.pool; - const id = randomUUID(); - const intervalDays = - input.reattestationIntervalDays ?? DEFAULT_REATTESTATION_INTERVAL_DAYS; - - const { rows } = await runner.query( - `WITH next_version AS ( - SELECT COALESCE(MAX(version), 0) + 1 AS v - FROM oracle_methodology - WHERE source_id = $1 - ) - INSERT INTO oracle_methodology ( - id, source_id, version, derivation_description, sampling_universe, - revision_policy, expected_latency_ms, known_failure_modes, - approved, reattestation_due_at, created_by - ) - SELECT $2, $1, next_version.v, $3, $4, $5, $6, $7, false, - now() + ($8::int * INTERVAL '1 day'), $9 - FROM next_version - RETURNING *`, - [ - input.sourceId, - id, - input.derivationDescription, - input.samplingUniverse, - input.revisionPolicy, - input.expectedLatencyMs, - input.knownFailureModes, - intervalDays, - input.createdBy ?? null, - ], - ); - - const record = this.mapMethodologyRow(rows[0]); - this.logger.info('methodology version created', { - sourceId: record.sourceId, - methodologyId: record.id, - version: record.version, - }); - return record; - } - - /** - * Approve a methodology version with explicit sign-off flags. - * Approval is a one-way transition; approved rows are never mutated again. - */ - async approveMethodologyVersion( - input: ApproveMethodologyInput, - ): Promise { - if (!input.approvedBy) { - throw new MethodologyError('approvedBy is required', 'MISSING_APPROVER'); - } - if (!input.signOffIndependence || !input.signOffAccuracy || !input.signOffLegal) { - throw new MethodologyError( - 'all sign-off flags (independence, accuracy, legal) must be true to approve', - 'INCOMPLETE_SIGN_OFF', - ); - } - - const { rows } = await this.pool.query( - `UPDATE oracle_methodology - SET approved = true, - approved_by = $2, - approved_at = now(), - sign_off_independence = $3, - sign_off_accuracy = $4, - sign_off_legal = $5 - WHERE id = $1 AND approved = false - RETURNING *`, - [ - input.methodologyId, - input.approvedBy, - input.signOffIndependence, - input.signOffAccuracy, - input.signOffLegal, - ], - ); - - if (rows.length === 0) { - throw new MethodologyError( - 'methodology not found or already approved', - 'NOT_APPROVABLE', - ); - } - - const record = this.mapMethodologyRow(rows[0]); - this.logger.info('methodology version approved', { - sourceId: record.sourceId, - methodologyId: record.id, - version: record.version, - approvedBy: record.approvedBy, - }); - return record; - } - - /** - * Return the currently active (latest approved) methodology for a source. - */ - async getActiveMethodology( - sourceId: string, - client?: PoolClient, - ): Promise { - const runner = client ?? this.pool; - const { rows } = await runner.query( - `SELECT * FROM oracle_methodology - WHERE source_id = $1 AND approved = true - ORDER BY version DESC - LIMIT 1`, - [sourceId], - ); - return rows.length > 0 ? this.mapMethodologyRow(rows[0]) : null; - } - - /** - * Return a specific methodology version (used for point-in-time reconstruction). - */ - async getMethodologyVersion( - methodologyId: string, - client?: PoolClient, - ): Promise { - const runner = client ?? this.pool; - const { rows } = await runner.query( - `SELECT * FROM oracle_methodology WHERE id = $1`, - [methodologyId], - ); - return rows.length > 0 ? this.mapMethodologyRow(rows[0]) : null; - } - - /** - * Assert that a source may be activated. Throws when no approved methodology - * exists, or when the active methodology has been flagged for divergence. - */ - async assertSourceActivatable( - sourceId: string, - client?: PoolClient, - ): Promise { - const active = await this.getActiveMethodology(sourceId, client); - if (!active) { - throw new MethodologyError( - `source ${sourceId} cannot be activated: no approved methodology record`, - 'NO_APPROVED_METHODOLOGY', - ); - } - if (active.divergenceFlagged) { - throw new MethodologyError( - `source ${sourceId} cannot be activated: methodology divergence flagged`, - 'METHODOLOGY_DIVERGENCE', - ); - } - return active; - } - - /** - * Re-attestation check: compare observed behavior against the recorded - * methodology and flag the source when they diverge. - */ - async checkReattestation( - sourceId: string, - observed: ObservedBehavior, - ): Promise { - const active = await this.getActiveMethodology(sourceId); - if (!active) { - throw new MethodologyError( - `source ${sourceId} has no approved methodology to re-attest`, - 'NO_APPROVED_METHODOLOGY', - ); - } - - const reasons: string[] = []; - - if ( - observed.aggregationMethod && - !this.descriptionMentions( - active.derivationDescription, - observed.aggregationMethod, - ) - ) { - reasons.push( - `observed aggregation method "${observed.aggregationMethod}" not described in methodology`, - ); - } - - if ( - observed.samplingUniverse && - !this.descriptionMentions(active.samplingUniverse, observed.samplingUniverse) - ) { - reasons.push( - `observed sampling universe "${observed.samplingUniverse}" not described in methodology`, - ); - } - - if ( - typeof observed.latencyMs === 'number' && - observed.latencyMs > active.expectedLatencyMs * 2 - ) { - reasons.push( - `observed latency ${observed.latencyMs}ms exceeds 2x expected ${active.expectedLatencyMs}ms`, - ); - } - - if (reasons.length === 0) { - // Record successful re-attestation. - await this.pool.query( - `UPDATE oracle_methodology - SET last_reattested_at = now(), - reattestation_due_at = now() + INTERVAL '90 days' - WHERE id = $1`, - [active.id], - ); - return null; - } - - const flaggedAt = new Date(); - await this.pool.query( - `UPDATE oracle_methodology - SET divergence_flagged = true, - divergence_notes = $2, - last_reattested_at = now() - WHERE id = $1`, - [active.id, reasons.join('; ')], - ); - - this.logger.warn('methodology divergence flagged', { - sourceId, - methodologyId: active.id, - version: active.version, - reasons, - }); - - return { - sourceId, - methodologyId: active.id, - methodologyVersion: active.version, - reasons, - observed, - flaggedAt, - }; - } - - /** - * Resolve the methodology versions used by a feed's sources, for the - * public trust/provenance surface. - */ - async getMethodologyForFeed(feedId: string): Promise { - const { rows } = await this.pool.query( - `SELECT s.source_id, m.* - FROM oracle_feed_sources s - JOIN LATERAL ( - SELECT * FROM oracle_methodology - WHERE source_id = s.source_id AND approved = true - ORDER BY version DESC - LIMIT 1 - ) m ON true - WHERE s.feed_id = $1 - ORDER BY s.source_id`, - [feedId], - ); - - return { - feedId, - sources: rows.map((row) => { - const m = this.mapMethodologyRow(row); - return { - sourceId: m.sourceId, - methodologyId: m.id, - version: m.version, - derivationDescription: m.derivationDescription, - samplingUniverse: m.samplingUniverse, - revisionPolicy: m.revisionPolicy, - expectedLatencyMs: m.expectedLatencyMs, - knownFailureModes: m.knownFailureModes, - approved: m.approved, - approvedAt: m.approvedAt, - divergenceFlagged: m.divergenceFlagged, - reattestationDueAt: m.reattestationDueAt, - }; - }), - }; - } - - /** - * Resolve the methodology version ids used for a set of sources at a point - * in time. Used when writing consensus decisions and evidence records. - */ - async resolveMethodologyVersions( - sourceIds: string[], - client?: PoolClient, - ): Promise> { - const runner = client ?? this.pool; - const result: Record = {}; - for (const sourceId of sourceIds) { - const active = await this.getActiveMethodology(sourceId, client); - if (!active) { - throw new MethodologyError( - `source ${sourceId} has no approved methodology`, - 'NO_APPROVED_METHODOLOGY', - ); - } - result[sourceId] = active.id; - } - void runner; - return result; - } - - // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - private descriptionMentions(haystack: string, needle: string): boolean { - if (!haystack || !needle) return false; - return haystack.toLowerCase().includes(needle.toLowerCase()); - } - - private mapMethodologyRow(row: any): MethodologyRecord { - return { - id: row.id, - sourceId: row.source_id, - version: Number(row.version), - derivationDescription: row.derivation_description, - samplingUniverse: row.sampling_universe, - revisionPolicy: row.revision_policy, - expectedLatencyMs: Number(row.expected_latency_ms), - knownFailureModes: Array.isArray(row.known_failure_modes) - ? row.known_failure_modes - : [], - approved: Boolean(row.approved), - approvedBy: row.approved_by ?? null, - approvedAt: row.approved_at ? new Date(row.approved_at) : null, - signOffIndependence: Boolean(row.sign_off_independence), - signOffAccuracy: Boolean(row.sign_off_accuracy), - signOffLegal: Boolean(row.sign_off_legal), - reattestationDueAt: row.reattestation_due_at - ? new Date(row.reattestation_due_at) - : null, - lastReattestedAt: row.last_reattested_at - ? new Date(row.last_reattested_at) - : null, - divergenceFlagged: Boolean(row.divergence_flagged), - divergenceNotes: row.divergence_notes ?? null, - createdAt: new Date(row.created_at), - createdBy: row.created_by ?? null, - }; - } -} \ No newline at end of file diff --git a/test/integration/jest.integration.config.ts b/test/integration/jest.integration.config.ts index 92f7db6..e69de29 100644 --- a/test/integration/jest.integration.config.ts +++ b/test/integration/jest.integration.config.ts @@ -1,34 +0,0 @@ -import type { Config } from "jest"; - -/** - * Separate Jest project for integration tests that spin up a real, - * ephemeral Postgres instance via testcontainers. - * - * Run: npx jest --config test/integration/jest.integration.config.ts - * - * The suite is intentionally kept out of the default `jest` (rootDir:src) - * run so unit tests stay fast and free of Docker dependency. - * - * Note: the global setup applies the full schema, including the - * reference-data registry migration (see `src/db/migrations/`), so - * integration tests for reference-data validation run against the - * same schema as production. The `*.ispec.ts` glob below already - * matches the reference-data integration tests (e.g. - * `test/integration/referenceData.ispec.ts`). - */ -const config: Config = { - preset: "ts-jest", - testEnvironment: "node", - rootDir: "../..", - testMatch: ["/test/integration/**/*.ispec.ts"], - globalSetup: "/test/integration/setup/global-setup.ts", - globalTeardown: "/test/integration/setup/global-teardown.ts", - // Longer timeout: container start + schema apply can take ~15 s on CI. - testTimeout: 60_000, - // ts-jest transform options — reuse root tsconfig. - transform: { - "^.+\\.tsx?$": ["ts-jest", { tsconfig: "/tsconfig.json" }], - }, -}; - -export default config; \ No newline at end of file