Skip to content

feat(resolver): committed multi-oracle quorum resolution with median aggregation (#28) - #241

Open
achiever2110 wants to merge 5 commits into
OracleDesk:developfrom
achiever2110:feat/quorum-resolution-28
Open

achiever2110 wants to merge 5 commits into
OracleDesk:developfrom
achiever2110:feat/quorum-resolution-28

Conversation

@achiever2110

Copy link
Copy Markdown

This PR resolves the following assigned issues:

Multi-oracle quorum resolution: a market can commit to a set of Reflector-style feeds and settle on their median, with per-feed rejection instead of all-or-nothing.

Architecture

commitment (ResolutionSpec::PriceQuorum(QuorumPriceConfig) hashed into resolution_hash)
-> registration validation (register_quorum_spec -> reveal: assert_unconfigured, validate_rule, assert_matches_commitment, store_rule — the pipeline is unchanged, the new variant just flows through it)
-> per-feed try_ classification (Ok(normalized) | Unavailable | Stale | Invalid | Failed)
-> normalisation to max(threshold_decimals, max feed decimals) (capped at 18, checked_mul/checked_div only)
-> median by insertion sort (odd: middle; even: floored average of the two middle values)
-> spread guard (max - min) * 10_000 / median > max_deviation_bps -> FeedsDisagree (deliberately stricter than distance-from-median: one outlier among quorate feeds is a disagreement the median would hide)
-> events: one FeedRejected per non-Ok feed, then QuorumResolution, then the existing PriceResolution/PriceResolutionV2 pair via settle_price.

accepted.len() < min_responses returns QuorumNotReached without writing anything — retryability is the point; liveness-to-void stays out of scope (issue #31). Nothing per-feed is persisted; the whole resolution computes in one transaction. register_spec_v2 needs no change: the ResolutionSpecV2.rule envelope picks the new variant up automatically.

Validation at reveal time: 1 <= min_responses <= feeds.len() <= MAX_FEEDS; strict majority min_responses * 2 > feeds.len() (two disjoint subsets can never both meet quorum with conflicting answers on a retry); duplicate (reflector, asset) pairs rejected; threshold >= 0; max_staleness > 0; max_deviation_bps <= 10_000 (the issue's bound, kept); every decimals <= 18; and the threshold must normalise to the target scale without overflow.

Deviations from the issue text (all verified against develop@5ede00a first)

  1. The isolation premise is already done. The issue says resolve_price uses a non-try_ call so a panicking feed aborts the transaction. At tip, read_feed (contracts/resolver/src/lib.rs:2124) already uses ReflectorPriceClient::try_price and maps every failure to PriceUnavailable; validate_price already returns StalePrice; outcome_for already applies the committed comparison. This PR does not reimplement that discipline — it reuses it per feed: the freshness predicate is factored verbatim into staleness_ok (called via validate_price and directly by classify_feed), and the comparison into outcome_at (called via outcome_for). The only new distinction is reporting: quorum separates a trap (Failed) from a missing record (Unavailable) so FeedRejected reasons are honest. Ok(Err(ConversionError)) classifies as Failed, never a panic.
  2. There is no decimals() on the feed interface. ReflectorPrice (resolver lib.rs:625) exposes exactly price(env, asset, timestamp) -> Option<PriceData>; PriceData is { price, timestamp }. The issue's c.try_decimals() does not exist, and adding a decimals method to the trait would make every real Reflector deployment fail the call. Precision is instead declared by the creator inside the commitment: FeedRef { reflector, asset, decimals }, validated <= MAX_DECIMALS (18) and covered by the spec hash. A feed reporting an absurd scale is Invalid, never an overflow.
  3. Blast radius, and where it turned out smaller than expected. The change spans oracledesk-types (appends), resolver (main work), and scripts/spec-hash (new price-quorum subcommand). The issue/guides expect new match arms in contracts/treasury/src/lib.rs:2931 and contracts/router/src/lib.rs:333 — but both matches already end in wildcard arms (_ => false at treasury:2939, _ => return Err(Error::UnsupportedRule) at router:343), so the new variant compiles unchanged there and is correctly rejected by both agent flows (quorum markets reveal through the resolver directly). No treasury/router edits are made; this also keeps the merge surface smaller.

Unrelated pre-existing tip breakage (NOT touched, NOT fixed)

At develop@5ede00a the resolver references three free functions that are defined nowhere in the crate: core_call (first use :929), registry_call (:2415), to_core_outcome (:1521). The resolver therefore does not compile on any toolchain at tip, independent of this PR. I did not repair it (out of scope per the sweep rules); resolve_quorum calls core_call with the same convention the existing 21 call sites use. Additionally cargo +stable check cannot even reach the resolver here: it dies earlier on 21 pre-existing oracledesk-types attribute-macro errors (banked evidence .bank/l145-gate-probe-stable.txt).

Acceptance criteria mapping

  • committed feed set with median aggregation, per-feed rejection, strict-majority quorum, retryable failures — implemented as above.
  • DataKey::Quorum(u64) included in assert_unconfigured (a quorum market cannot be re-revealed into another mode).
  • FeedRejected + QuorumResolution events in the house #[contractevent] style.
  • spec-hash CLI: price-quorum subcommand with verify support; feed grammar <reflector>:<asset>:<decimals>,... (first/last-colon split so stellar:/other: asset prefixes survive).
  • listed tests authored: median odd/even (floored), panicking feed ignored, stale/None ignored with exact event sequence, QuorumNotReached retryable + writes nothing, FeedsDisagree, mixed decimals, outlier-does-not-move-median (plus spread guard rejects it), duplicate-feed / non-majority / over-MAX_FEEDS / decimals>18 / deviation>10_000 registration rejections, snapshot+pre-close+double-reveal guards, and legacy_specs_hash_unchanged_by_price_quorum_append in the golden module.
  • Refused: vendored wasm regeneration. This host cannot build the workspace (toolchain pin from Replace vendored market_core.wasm with a deterministic build and hash-pinned import #58 + the breakage above); touching *.wasm/*.wasm.sha256 without a reproducible build would be worse than not touching them. Zero wasm files are in this diff (verified with git diff --name-only | grep -c '\.wasm' = 0).
  • Refused: make bindings. No node_modules, codegen needs built wasm. Note the two new events and the new ResolutionSpec variant do change the interface spec: bindings regeneration is a required follow-up commit for someone with a working toolchain, before release.

Verification actually performed (and its honest limit)

No test was run — none can run on this host; every test above is authored only. Do not read anything below as "tests pass".

  • cargo +stable fmt -- --check per crate, baseline on the untouched tip vs after: resolver 12 findings -> 12 (same 12, shifted lines; the content multiset of the findings is byte-identical to baseline), oracledesk-types 0 -> 0, spec-hash 2 -> 2 (identical content), router/treasury untouched. This also proves rustfmt parses every changed file — i.e. the additions are syntactically valid Rust. It demonstrates nothing stronger; type errors would only surface on a machine that can compile.
  • grep -oE '= [0-9]+' over the resolver Error enum: no duplicate discriminants after appending 44/45.
  • grep -rn 'ResolutionSpec::' contracts/ scripts/: every consumer re-audited (see deviation 3).
  • bash -n scripts/seed-market.sh: OK (file unchanged; the new subcommand is additive to its grammar).
  • MAX_FEEDS = 5 is reasoned, not measured: one try_price sub-call + O(n) bookkeeping per feed, the issue's own budget note puts n=5 with n-1 trapping feeds well under the instruction limit, and 5 sits at the conservative end of the issue's 5-7 suggestion. Residual risk disclosed in code comments: Soroban sub-calls have no isolated budget, so a committed malicious feed can burn the transaction budget — at worst this forces a retry or a guardian void, never a wrong outcome.

Enum tails appended (XDR append-only)

  • resolver DataKey: Quorum(u64) after Snapshot(u64)
  • resolver Error: QuorumNotReached = 44, FeedsDisagree = 45 after PendingAdminExpired = 43
  • oracledesk-types ResolutionSpec: PriceQuorum(QuorumPriceConfig) after Hybrid(HybridConfig)

Tail collisions expected with our sibling PRs #184, #171, #237, #239, #240 which also append to these enums (this branch deliberately does not rebase onto them — it stands alone against develop@5ede00a8). Whichever merges later must re-append at the then-current tail; the resolver golden tests + this PR's legacy_specs_hash_unchanged_by_price_quorum_append are the drift tripwire.

Changes

  • oracledesk-stellar/contracts/oracledesk-types/src/lib.rs: FeedRef, QuorumPriceConfig, ResolutionSpec::PriceQuorum (append-only), test extended.
  • oracledesk-stellar/contracts/resolver/src/lib.rs: re-exports; MAX_FEEDS/MAX_DECIMALS/MAX_DEVIATION_BPS; Error 44/45; DataKey::Quorum; FeedRejectionReason + FeedRejected/QuorumResolution events; FeedState; register_quorum_spec; resolve_price quorum dispatch; classify_feed/normalize_price/quorum_target_decimals/insert_sorted/median/assert_spread/quorum_config_is_valid/resolve_quorum; staleness_ok/outcome_at factored out of validate_price/outcome_for (behaviour-preserving); validate_rule/store_rule/assert_unconfigured/price_config arms; MockReflector::set_panic; 14 tests + 1 golden test.
  • oracledesk-stellar/scripts/spec-hash/src/lib.rs: parse_asset/parse_direction extraction, parse_price_quorum, price-quorum in USAGE/parse_rule, 4 CLI tests.

Frozen shapes respected: no field/variant reordered or removed anywhere; all additions are at enum tails or new items.

…racleDesk#28)

Additive-only XDR surface for multi-oracle quorum resolution:
- FeedRef { reflector, asset, decimals }: precision is creator-declared in
  the commitment because the Reflector price interface exposes no
  decimals() method to probe.
- QuorumPriceConfig: feeds, min_responses, threshold + threshold_decimals,
  direction, max_staleness, max_deviation_bps.
- ResolutionSpec::PriceQuorum appended after Hybrid; existing variant
  encodings are unpinned-shifted (contracttype enums encode case names,
  and the resolver golden tests pin every legacy hash).

Tests authored (unexecuted on this host: workspace does not compile at
develop tip, see PR OracleDesk#28 body).
ResolutionSpec::PriceQuorum is registered through the same commit/reveal
pipeline (register_quorum_spec -> reveal), validated entirely at reveal
time (bounds, strict majority, duplicate (reflector, asset) rejection,
decimals <= 18, threshold normalisation), stored under the new tail
DataKey::Quorum key, and resolved by resolve_price's dispatch:

- per-feed classification Ok | Unavailable | Stale | Invalid | Failed
  reusing read_feed's try_price isolation and the staleness rule, now
  factored into staleness_ok and shared via validate_price;
- fixed-point normalisation to max(threshold_decimals, max feed decimals)
  capped at 18, checked_mul/checked_div only;
- median by insertion sort, floored even-count average;
- (max - min) * 10_000 / median > max_deviation_bps -> FeedsDisagree;
- accepted.len() < min_responses -> QuorumNotReached (pure retry, nothing
  written, never void);
- one FeedRejected per rejected feed, then QuorumResolution, then the
  existing PriceResolution/V2 pair via settle_price.

Appends Error::QuorumNotReached = 44 / FeedsDisagree = 45 and DataKey::
Quorum(u64) at the XDR tails. MAX_FEEDS = 5 is reasoned, not measured on
this host (body explains).
…leDesk#28)

Median odd/even (floored), panicking/None/stale feed rejection with
FeedRejected events, QuorumNotReached retryability, FeedsDisagree spread
guard, mixed-decimals normalisation, outlier-vs-median unit check, and the
registration rejections (duplicate feeds, non-majority quorum, over
MAX_FEEDS, decimals > 18, deviation > 10_000 bps). MockReflector gains a
set_panic toggle (existing harness, no new mock). golden mod gains
legacy_specs_hash_unchanged_by_price_quorum_append: the pre-existing
b6f24583/4373448f vectors re-checked after the PriceQuorum append.

All tests are authored, NOT executed on this host (workspace does not
compile at develop tip; see PR body).
Adds
  spec-hash [V2 FLAGS] price-quorum <min_responses> <threshold>
    <threshold_decimals> <direction> <max_staleness> <max_deviation_bps>
    <reflector>:<asset>:<decimals>,...
with the first/last-colon split so assets keep their stellar:/other: prefix,
extracts shared parse_asset/parse_direction helpers (parse_price now reuses
them), registers the subcommand in parse_rule/USAGE (so verify covers it),
and adds roundtrip, field-binding and malformed-input tests.

Authored, not executed here: scripts/spec-hash depends on resolver +
soroban-sdk 27.0.6, which does not compile on this host (see PR body); the
existing golden vectors in this file already prove the pre-quorum hashes.
anitahhhh added a commit to anitahhhh/OracleDesk-SmartContract that referenced this pull request Oct 21, 2026
…acleDesk#23)

Adds `guardian_void_market_v2(market_id, reason, at_fault)` — the same
validation as `guardian_void_market` (shared `guardian_void_inner`) plus
an explicit at-fault flag for the creator's refundable bond. With
`at_fault = true` the void is forwarded to market-core's
`void_market_with_reason(id, GuardianFault)` via **name dispatch**
(`env.try_invoke_contract` + `Symbol`), because `contractimport!` binds
this crate to the vendored `market_core.wasm`, whose generated client
does not know the new function until `make build` regenerates it.
`VoidReason` is an all-unit-variant `#[contracttype]` enum and encodes as
a plain `ScVal::Symbol`, mirrored locally so the call is wire-compatible
across that boundary.

`guardian_void_market` itself keeps its exact signature, call path
(generated `void_market` client = NoFault) and event set: existing
callers, tests and the Invalid-verdict, expire_dispute and
finalize_signers void paths are untouched, per the frozen-entrypoint
rule. A new `GuardianVoided { market_id, at_fault }` event is emitted
only on the v2 entrypoint, alongside the existing `GuardianAction`.
Appends `Error::MarketCoreCallFailed = 44` (append-only tail; OracleDesk#241 also
claims 44 on its branch — last merger re-appends).

3 resolver tests authored (NoFault parity + event, at-fault forwarding +
event, shared validation and admin auth). The at-fault test passes only
after `market_core.wasm` regeneration; with the committed pre-OracleDesk#23 blob
the dispatch reverts the whole call with `MarketCoreCallFailed` and no
state is touched. Tests not executed here (resolver does not compile at
develop tip: `core_call` is used but defined nowhere — pre-existing,
out of scope; my additions are self-contained and avoid it).

Gate: `cargo +stable fmt -p resolver -- --check` flags exactly the same
12 pre-existing hunks as the untouched-tip baseline (bodies verified
identical, only line numbers shifted); zero new formatting diffs in
added ranges.
anitahhhh added a commit to anitahhhh/OracleDesk-SmartContract that referenced this pull request Oct 22, 2026
…acleDesk#23)

Adds `guardian_void_market_v2(market_id, reason, at_fault)` — the same
validation as `guardian_void_market` (shared `guardian_void_inner`) plus
an explicit at-fault flag for the creator's refundable bond. With
`at_fault = true` the void is forwarded to market-core's
`void_market_with_reason(id, GuardianFault)` via **name dispatch**
(`env.try_invoke_contract` + `Symbol`), because `contractimport!` binds
this crate to the vendored `market_core.wasm`, whose generated client
does not know the new function until `make build` regenerates it.
`VoidReason` is an all-unit-variant `#[contracttype]` enum and encodes as
a plain `ScVal::Symbol`, mirrored locally so the call is wire-compatible
across that boundary.

`guardian_void_market` itself keeps its exact signature, call path
(generated `void_market` client = NoFault) and event set: existing
callers, tests and the Invalid-verdict, expire_dispute and
finalize_signers void paths are untouched, per the frozen-entrypoint
rule. A new `GuardianVoided { market_id, at_fault }` event is emitted
only on the v2 entrypoint, alongside the existing `GuardianAction`.
Appends `Error::MarketCoreCallFailed = 44` (append-only tail; OracleDesk#241 also
claims 44 on its branch — last merger re-appends).

3 resolver tests authored (NoFault parity + event, at-fault forwarding +
event, shared validation and admin auth). The at-fault test passes only
after `market_core.wasm` regeneration; with the committed pre-OracleDesk#23 blob
the dispatch reverts the whole call with `MarketCoreCallFailed` and no
state is touched. Tests not executed here (resolver does not compile at
develop tip: `core_call` is used but defined nowhere — pre-existing,
out of scope; my additions are self-contained and avoid it).

Gate: `cargo +stable fmt -p resolver -- --check` flags exactly the same
12 pre-existing hunks as the untouched-tip baseline (bodies verified
identical, only line numbers shifted); zero new formatting diffs in
added ranges.
anitahhhh added a commit to anitahhhh/OracleDesk-SmartContract that referenced this pull request Oct 22, 2026
…ils precisely (OracleDesk#24)

ADR 0010 listed the Python mirror as follow-up work it could not do. That was
wrong about the constraint: model/fpmm_model.py runs here, so its checks are
executed rather than merely authored. Only the TypeScript mirror stays out,
because Node on this host cannot strip types.

Also drops the claim that resolver's Error 44 collides with OracleDesk#239/OracleDesk#240/OracleDesk#241: it
is OracleDesk#241 (QuorumNotReached) and OracleDesk#242 (EventTimeMismatch) that reach 44, verified
from each PR's own patch.

Co-Authored-By: Claude <noreply@anthropic.com>
anitahhhh added a commit to anitahhhh/OracleDesk-SmartContract that referenced this pull request Oct 22, 2026
…riant rule (OracleDesk#23, OracleDesk#24)

`develop`'s resolver enum ends at 43, so `MarketCoreCallFailed = 44` reaches for
the same number as OracleDesk#241's `QuorumNotReached` and OracleDesk#242's `EventTimeMismatch`. The
renumber rule is worth writing down where the next contributor will hit it:
asserting the named variant survives the renumber, asserting the numeric literal
does not.

Co-Authored-By: Claude <noreply@anthropic.com>
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.

Add multi-oracle quorum resolution with median aggregation and per-feed fallback

1 participant