Repository navigation
feat(resolver): committed multi-oracle quorum resolution with median aggregation (#28) - #241
Open
achiever2110 wants to merge 5 commits into
Open
achiever2110 wants to merge 5 commits into
achiever2110 wants to merge 5 commits into
Conversation
…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>
Open
13 of 15 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 intoresolution_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
FeedRejectedper non-Ok feed, thenQuorumResolution, then the existingPriceResolution/PriceResolutionV2pair viasettle_price.accepted.len() < min_responsesreturnsQuorumNotReachedwithout 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_v2needs no change: theResolutionSpecV2.ruleenvelope picks the new variant up automatically.Validation at reveal time:
1 <= min_responses <= feeds.len() <= MAX_FEEDS; strict majoritymin_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); everydecimals <= 18; and the threshold must normalise to the target scale without overflow.Deviations from the issue text (all verified against
develop@5ede00afirst)resolve_priceuses a non-try_call so a panicking feed aborts the transaction. At tip,read_feed(contracts/resolver/src/lib.rs:2124) already usesReflectorPriceClient::try_priceand maps every failure toPriceUnavailable;validate_pricealready returnsStalePrice;outcome_foralready applies the committed comparison. This PR does not reimplement that discipline — it reuses it per feed: the freshness predicate is factored verbatim intostaleness_ok(called viavalidate_priceand directly byclassify_feed), and the comparison intooutcome_at(called viaoutcome_for). The only new distinction is reporting: quorum separates a trap (Failed) from a missing record (Unavailable) soFeedRejectedreasons are honest.Ok(Err(ConversionError))classifies asFailed, never a panic.decimals()on the feed interface.ReflectorPrice(resolver lib.rs:625) exposes exactlyprice(env, asset, timestamp) -> Option<PriceData>;PriceDatais{ price, timestamp }. The issue'sc.try_decimals()does not exist, and adding adecimalsmethod 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 isInvalid, never an overflow.oracledesk-types(appends),resolver(main work), andscripts/spec-hash(newprice-quorumsubcommand). The issue/guides expect new match arms incontracts/treasury/src/lib.rs:2931andcontracts/router/src/lib.rs:333— but both matches already end in wildcard arms (_ => falseat 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@5ede00athe 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_quorumcallscore_callwith the same convention the existing 21 call sites use. Additionallycargo +stable checkcannot even reach the resolver here: it dies earlier on 21 pre-existingoracledesk-typesattribute-macro errors (banked evidence.bank/l145-gate-probe-stable.txt).Acceptance criteria mapping
DataKey::Quorum(u64)included inassert_unconfigured(a quorum market cannot be re-revealed into another mode).FeedRejected+QuorumResolutionevents in the house#[contractevent]style.price-quorumsubcommand withverifysupport; feed grammar<reflector>:<asset>:<decimals>,...(first/last-colon split sostellar:/other:asset prefixes survive).QuorumNotReachedretryable + 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, andlegacy_specs_hash_unchanged_by_price_quorum_appendin thegoldenmodule.*.wasm/*.wasm.sha256without a reproducible build would be worse than not touching them. Zero wasm files are in this diff (verified withgit diff --name-only | grep -c '\.wasm'= 0).make bindings. No node_modules, codegen needs built wasm. Note the two new events and the newResolutionSpecvariant 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 -- --checkper 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 resolverErrorenum: 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 = 5is reasoned, not measured: onetry_pricesub-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)
DataKey:Quorum(u64)afterSnapshot(u64)Error:QuorumNotReached = 44,FeedsDisagree = 45afterPendingAdminExpired = 43ResolutionSpec:PriceQuorum(QuorumPriceConfig)afterHybrid(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'slegacy_specs_hash_unchanged_by_price_quorum_appendare 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;Error44/45;DataKey::Quorum;FeedRejectionReason+FeedRejected/QuorumResolutionevents;FeedState;register_quorum_spec;resolve_pricequorum dispatch;classify_feed/normalize_price/quorum_target_decimals/insert_sorted/median/assert_spread/quorum_config_is_valid/resolve_quorum;staleness_ok/outcome_atfactored out ofvalidate_price/outcome_for(behaviour-preserving);validate_rule/store_rule/assert_unconfigured/price_configarms;MockReflector::set_panic; 14 tests + 1 golden test.oracledesk-stellar/scripts/spec-hash/src/lib.rs:parse_asset/parse_directionextraction,parse_price_quorum,price-quorumin 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.