Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 129 additions & 1 deletion docs/threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

36 changes: 21 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Loading
Loading