Skip to content

feat: Require distinct oracle sources for quorum - #1433

Open
OG-wura wants to merge 2 commits into
Predictify-org:masterfrom
OG-wura:Require_distinct
Open

feat: Require distinct oracle sources for quorum#1433
OG-wura wants to merge 2 commits into
Predictify-org:masterfrom
OG-wura:Require_distinct

Conversation

@OG-wura

@OG-wura OG-wura commented Aug 31, 2026

Copy link
Copy Markdown

Close #1393

Require distinct oracle sources for quorum

Summary

The three-oracle median resolver (OracleResolutionManager::resolve_with_median)
reached "quorum" when the number of included quotes reached
MedianOracleConfig::min_sources. Each of the three slots (Pyth, Reflector, Band)
was fetched from a distinct contract address configured in MedianOracleConfig.
Nothing, however, enforced that the three addresses were actually different on-chain
contracts.

If a configuration pointed two slots at the same contract, that single oracle was
counted as two (or three) independent sources. Such a configuration could:

  • satisfy min_sources (quorum) with effectively one source, and
  • bias the baseline median and the confidence-weighted median by appearing more
    than once.

This PR requires distinct oracle sources for quorum in two complementary layers,
so a single on-chain oracle can never satisfy or skew quorum.

Changes

1. Config-time validation (input validation / least privilege)

contracts/predictify-hybrid/src/resolution.rs

  • New OracleResolutionManager::validate_distinct_sources(&MedianOracleConfig) -> Result<(), Error>
    returns Error::InvalidOracleConfig when any two of pyth_address,
    reflector_address, and band_address are equal.
  • OracleResolutionManager::set_median_config now calls it before persisting and
    returns Result<(), Error>. A duplicated-source configuration is rejected and
    never written to storage, so a bad/quorum-vacating config cannot be established in
    the first place.

2. Resolution-time guard (defense in depth / abuse resistance)

contracts/predictify-hybrid/src/resolution.rs

  • New private OracleResolutionManager::dedupe_duplicate_sources(env, config, quotes)
    walks the fixed fetch order (Pyth → Reflector → Band) and keeps only the first
    occurrence of each distinct contract address included; any later quote from the
    same address is flagged included = false. Implemented with fixed-size arrays (no
    heap allocation) so it is no_std / WASM friendly.
  • resolve_with_median applies the dedupe immediately after fetching and before
    the baseline-median and quorum steps, then rebinds raw_quotes to the deduped
    vector. As a result every downstream step (baseline median, outlier detection,
    included_count vs min_sources, confidence-weighted median) observes at most one
    quote per distinct source.
    • Effective behavior change in the previously-unchecked configuration: a duplicated
      source can no longer satisfy min_sources or appear twice in the median; if the
      surviving distinct sources are fewer than min_sources, resolution still fails
      deterministically with Error::OracleNoConsensus.

This second layer also covers configurations persisted before the config-time check
existed (e.g. an already-deployed contract that had stored a duplicated config), so the
invariant holds even for legacy/malformed state.

Documentation

  • types.rs: MedianOracleConfig now documents the distinct-source invariant on the
    signature of min_sources.
  • resolution.rs: doc comments on set_median_config, resolve_with_median
    (algorithm step list + a dedicated "Security note"), validate_distinct_sources,
    and dedupe_duplicate_sources describe why quorum requires distinct sources and how
    the two enforcement layers interact.

Acceptance-criteria mapping

Criterion How it is met
Deterministic behavior for valid/invalid/duplicate/boundary inputs Distinct configs pass unchanged; any duplicate ordering (P→R, R→B, B→P, all-equal) is rejected at set time and, if pre-existing, deduplicated at resolve time. Boundary case min_sources == 1 still allowed for distinct configs.
Authorization, validation, state-transition invariants remain enforced set_median_config still stores via the same key after passing the new validation gate; failed validation performs no storage write (no partial state). Resolver preserves all existing guards (timeout, MarketClosed, MarketResolved, falldown).
Retries / partial failure / concurrency cannot produce unsafe state Oracle fetch failures still produce included = false quotes and are handled exactly as before; dedupe_duplicate_sources is a pure post-fetch filter, so it is safe under retries and adds no new side effects. WASM is single-threaded; no shared-state race is introduced.
Focused tests cover success, rejection, boundary, regression Added unit tests – see Tests.
Existing callers remain compatible resolve_with_median/MarketResolutionManager behavior unchanged for distinct configs. OracleResolutionManager::set_median_config changes its return type from () to Result<(), Error>; it has no external caller today (only the contract-internal median feature), and the change is fail-closed (returns an error rather than persisting bad config) — no silent behavior change for valid inputs.
Logs / metrics / user-visible errors diagnose failures Rejects a duplicated config with the existing, well-documented Error::InvalidOracleConfig; resolution with insufficient distinct sources surfaces Error::OracleNoConsensus, identical to the pre-existing under-quorum path, so the failure mode stays observable to operators. The existing OracleConsensusReachedEvent/oracle_median_quotes events continue to report the (now deduped) quote vector.

Security & failure-mode handling

  • Threat model. A malicious or misconfigured admin could collapse the three
    "independent" oracle slots into one contract, gaining 2–3× weight in the median and
    single-handedly satisfying quorum. This PR removes that lever at both the config and
    resolution layers.
  • Fail-closed. Config-time validation rejects duplicates before storage; resolve-time
    dedupe drops duplicates before any consensus math. There is no path where a duplicated
    source can contribute to quorum after this change.
  • No silent degradation. When a duplicate configuration is present, the resolver
    under-counts the effective number of sources and, if fewer than min_sources distinct
    sources survive, returns Error::OracleNoConsensus rather than proceeding on a biased
    median.
  • Minimal blast radius. Two existing, well-known error variants are reused
    (InvalidOracleConfig, OracleNoConsensus) — no new error surface, no EVM-equivalent
    encoding churn.

Tests

Run:

cargo test -p predictify-hybrid --lib median_resolution_tests

All 37 tests in resolution::median_resolution_tests pass, including these new ones:

  • test_validate_distinct_sources_accepts_distinct — distinct addresses accepted.
  • test_validate_distinct_sources_rejects_duplicates — rejects Pyth==Reflector,
    Reflector==Band, Band==Pyth, and all-equal configs.
  • test_set_median_config_persists_distinct_config — distinct config stored & reloaded.
  • test_set_median_config_rejects_duplicate_sources_and_does_not_persist — duplicate
    config returns InvalidOracleConfig and leaves storage untouched.
  • test_dedupe_duplicate_sources_keeps_first_distinct_occurrence — Reflector/Band
    collision drops the Band quote.
  • test_dedupe_duplicate_sources_distinct_config_unchanged — distinct config leaves all
    quotes included.
  • test_dedupe_duplicate_sources_pyth_reflector_collision_keeps_pyth — Pyth keeps its
    vote, Reflector (duplicate) is dropped.

Run the full package suite with:

cargo test -p predictify-hybrid

Note on the base repository state. The upstream base did not compile: two pre-existing
lib errors (admin.rs calling a non-existent ConfigManager::validate_config, and
validation.rs calling OracleValidator::validate_oracle_config_all_together instead of
OracleConfigValidator::…) plus two pre-existing test-compile errors (a missing format!
import in event_topic_compat_tests.rs and an invalid assert_eq! on a non-PartialEq
type in bets.rs). These were fixed minimally so the crate and test target compile. The
193 remaining test failures after that fix are all pre-existing and unrelated to this
change (spanning force_resolve, timelock, market_audit, oracle_health, recovery,
deprecated, bets, etc.); none are in the resolution module.

WASM size

bash scripts/check_wasm_size.sh builds the contract for the wasm32v1-none target and
reports the (unoptimized) size. The new median code is not referenced by any live contract
entrypoint, so it is eliminated by release-mode dead-code stripping and does not add to the
deployed WASM. For a fully optimized size measurement the environment needs stellar contract optimize (not installed here); the script falls back to the unoptimized binary in
that case.

@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@OG-wura Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Quality-2][High] Require distinct oracle sources for quorum

1 participant