diff --git a/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs b/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs index 4662608..00747e9 100644 --- a/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs +++ b/oracledesk-stellar/contracts/oracledesk-types/src/lib.rs @@ -169,6 +169,49 @@ pub struct HybridConfig { pub fallback_after: u64, } +/// One oracle feed inside a [`QuorumPriceConfig`]. +/// +/// `decimals` is creator-declared at registration, not read from the feed: +/// the Reflector-style price interface (`price(asset, timestamp)`) exposes no +/// `decimals()` method, and probing for one would fail against every deployed +/// Reflector. Carrying it in the commitment keeps the normalisation scale +/// covered by the `resolution_hash` instead of trusting a live oracle call. +/// Validated `<= 18` (`MAX_DECIMALS`) before the spec is ever stored. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeedRef { + pub reflector: Address, + pub asset: OracleAsset, + /// Decimal places by which this feed's `PriceData::price` is scaled. + pub decimals: u32, +} + +/// Multi-oracle quorum rule: read every committed feed, reject the ones that +/// fail per-feed checks, take the median of the survivors, and compare it to +/// `threshold` like [`PriceConfig`] does. Registration validates +/// `1 <= min_responses <= feeds.len() <= MAX_FEEDS`, a strict majority +/// (`min_responses * 2 > feeds.len()`), distinct `(reflector, asset)` pairs, +/// `max_deviation_bps <= 10_000`, and per-feed decimals `<= 18`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuorumPriceConfig { + pub feeds: Vec, + /// Minimum feeds that must produce a usable price. Fewer resolves nothing + /// (retryable), it never voids. + pub min_responses: u32, + /// Strike compared against the median, at `threshold_decimals` scale. + pub threshold: i128, + /// Decimal places of [`Self::threshold`]. The resolution normalises every + /// price and the threshold to `max(threshold_decimals, max feed decimals)` + /// (capped at 18). + pub threshold_decimals: u32, + pub direction: PriceDirection, + pub max_staleness: u64, + /// Maximum tolerated `(max - min) / median` spread across accepted feeds, + /// in basis points. A wider spread is `FeedsDisagree`, not a median. + pub max_deviation_bps: u32, +} + /// The full resolution rule a market's `resolution_hash` commits to. Hashing /// this (rather than the bare `PriceConfig`/`SignerConfig`) keeps the two /// modes in disjoint hash spaces even if their field encodings could @@ -182,6 +225,11 @@ pub enum ResolutionSpec { SignersDisputable(DisputableSignerConfig), Committee(CommitteeRef), Hybrid(HybridConfig), + /// Append-only tail: `#[contracttype]` enums encode the *case name* as an + /// `ScVal` union, and every existing hash is over `sha256(XDR(spec))`, so + /// appending a variant cannot shift or re-encode `Price`/`Signers`/... — + /// the `golden` tests in `contracts/resolver` pin those exact hashes. + PriceQuorum(QuorumPriceConfig), } /// Versioned commitment envelope. Unlike a bare [`ResolutionSpec`] it binds the @@ -259,10 +307,26 @@ mod test { ResolutionSpec::Price(price.clone()), ResolutionSpec::Signers(signer.clone()), ResolutionSpec::Hybrid(HybridConfig { - price, - fallback: signer, + price: price.clone(), + fallback: signer.clone(), fallback_after: 3_600, }), + ResolutionSpec::PriceQuorum(QuorumPriceConfig { + feeds: Vec::from_array( + &env, + [FeedRef { + reflector: price.reflector.clone(), + asset: price.asset.clone(), + decimals: 8, + }], + ), + min_responses: 1, + threshold: price.threshold, + threshold_decimals: 8, + direction: price.direction, + max_staleness: price.max_staleness, + max_deviation_bps: 100, + }), ]; for (i, a) in specs.iter().enumerate() { for (j, b) in specs.iter().enumerate() { diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index edfcc9b..7ad1f79 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -30,9 +30,9 @@ use market_core::Client as MarketCoreClient; // them so existing `resolver::PriceConfig` etc. paths (and `scripts/spec-hash`) // keep working. pub use oracledesk_types::{ - CommitteeRef, DisputableSignerConfig, Escalation, HybridConfig, OracleAsset, Outcome, - PriceConfig, PriceData, PriceDirection, ResolutionSpec, ResolutionSpecV2, SignerConfig, - WeightedKey, WeightedKeyConfig, + CommitteeRef, DisputableSignerConfig, Escalation, FeedRef, HybridConfig, OracleAsset, Outcome, + PriceConfig, PriceData, PriceDirection, QuorumPriceConfig, ResolutionSpec, ResolutionSpecV2, + SignerConfig, WeightedKey, WeightedKeyConfig, }; #[allow(clippy::too_many_arguments)] @@ -79,6 +79,23 @@ pub const SIGNER_GRACE_SECS: u64 = 7 * 86_400; /// Maximum number of proposal cancellations per market allowed by the guardian. pub const MAX_CANCELS_PER_MARKET: u32 = 2; +/// Most feeds a quorum spec may commit. Reasoned, not measured on the PR +/// host (it cannot build this workspace): each feed is one `try_price` +/// sub-call plus `O(n)` bookkeeping in an `O(n^2)` insert-sorted median, and +/// the issue's budget note puts the worst case (`n - 1` trapping feeds) +/// under 60% of the instruction limit for `n` in 5..7; 5 is the conservative +/// end of that band. Residual risk, unavoidable at this layer: Soroban gives +/// sub-calls no isolated budget, so a committed malicious feed can burn the +/// whole transaction budget — at worst resolution fails and retries (or is +/// voided past the deadline), it can never produce a wrong outcome. +pub const MAX_FEEDS: u32 = 5; +/// Fixed-point scale ceiling enforced on every `FeedRef::decimals` and on +/// `QuorumPriceConfig::threshold_decimals`. +pub const MAX_DECIMALS: u32 = 18; +/// Largest acceptable `max_deviation_bps` (100%): above it the disagreement +/// guard is decoration. The issue's bound, kept as-is. +pub const MAX_DEVIATION_BPS: u32 = 10_000; + #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ResolutionState { @@ -335,6 +352,13 @@ pub enum Error { NoPendingAdmin = 42, /// A proposed admin handover was not accepted within its `expires_at`. PendingAdminExpired = 43, + /// Fewer feeds produced a usable price than `min_responses` requires. + /// Returned, never fatal: nothing is written, so the resolution is + /// retryable once feeds recover. Liveness-to-void is not this path's job. + QuorumNotReached = 44, + /// Accepted feeds disagree beyond `max_deviation_bps`. Retryable like + /// `QuorumNotReached`: a later call re-reads every feed from scratch. + FeedsDisagree = 45, } #[contracttype] @@ -382,6 +406,10 @@ pub enum DataKey { CountV2(u64, AttestedOutcome), ProposalV2(u64), Snapshot(u64), + /// Committed [`QuorumPriceConfig`] of a multi-oracle market (#28). + /// Appended at the tail: `DataKey` is an XDR union with positional + /// discriminants, so no existing key shifts. + Quorum(u64), } /// The registered signer group/authority, whichever kind the market committed to. @@ -622,11 +650,69 @@ pub struct AdminTransferred { #[derive(Clone, Debug, Eq, PartialEq)] pub struct AdminTransferCancelled {} +/// Why a feed was dropped from a quorum resolution (`FeedRejected::reason`). +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FeedRejectionReason { + /// The call succeeded but the feed has no record for `close_time` + /// (`try_price` returned `Ok(None)`). + Unavailable, + /// A price exists but fails the committed staleness rule — the same + /// `StalePrice` condition single-feed resolution surfaces. + Stale, + /// A fresh price whose committed decimals cannot be normalised to the + /// target scale (absurd `decimals`, or a checked rescale that overflowed). + Invalid, + /// The sub-call trapped or the feed contract errored (`Err` from + /// `try_price`, outer or inner): isolated, never propagated. + Failed, +} + +/// Emitted once per non-`Ok` feed of a quorum resolution, before any +/// `QuorumResolution`. Indexable by feed so an operator can see which oracle +/// the quorum rejected without decoding the median. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeedRejected { + #[topic] + pub market_id: u64, + #[topic] + pub reflector: Address, + pub asset: OracleAsset, + pub reason: FeedRejectionReason, +} + +/// The aggregate result of a quorum resolution. `median` is scaled by +/// `decimals` (the committed target scale, `max(threshold_decimals, +/// max feed decimals)` capped at 18). Emitted alongside the existing +/// `PriceResolution`/`PriceResolutionV2` pair from `settle_price`. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct QuorumResolution { + #[topic] + pub market_id: u64, + pub median: i128, + pub decimals: u32, + pub accepted: u32, + pub rejected: u32, + pub outcome: Outcome, +} + #[soroban_sdk::contractclient(name = "ReflectorPriceClient")] pub trait ReflectorPrice { fn price(env: Env, asset: OracleAsset, timestamp: u64) -> Option; } +/// Per-feed classification inside a quorum resolution. `Failed` absorbs every +/// call-level fault (trap, contract error, decode error) exactly the way +/// `read_feed` already absorbs a single feed's panic into +/// `PriceUnavailable`: no feed can abort the transaction. +enum FeedState { + /// Accepted, normalised to the target decimal scale. + Ok(i128), + Rejected(FeedRejectionReason), +} + #[contract] pub struct Resolver; @@ -793,6 +879,20 @@ impl Resolver { Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) } + /// See `register_price_spec` — same commit/reveal rule, multi-oracle + /// quorum mode (#28). The whole [`QuorumPriceConfig`] — feed set, + /// per-feed decimals, quorum and deviation bounds — is inside the hashed + /// rule, so the resolution reads exactly the oracle set the creator + /// committed to; `validate_rule` gates it before storage is touched. + pub fn register_quorum_spec( + env: Env, + market_id: u64, + spec: QuorumPriceConfig, + ) -> Result<(), Error> { + let rule = ResolutionSpec::PriceQuorum(spec); + Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) + } + /// Creates a reusable committee with an admin, member set, threshold, and time-lock delay. pub fn create_committee( env: Env, @@ -1214,6 +1314,12 @@ impl Resolver { } pub fn resolve_price(env: Env, market_id: u64) -> Result { + // Quorum markets carry their config under `DataKey::Quorum` + // (`assert_unconfigured` keeps the modes exclusive); everything else + // follows the single-feed and hybrid paths unchanged. + if let Ok(config) = Self::load::(&env, DataKey::Quorum(market_id)) { + return Self::resolve_quorum(&env, market_id, &config); + } let (config, is_hybrid) = if let Ok(cfg) = Self::load::(&env, DataKey::Price(market_id)) { @@ -2098,19 +2204,34 @@ impl Resolver { close_time: u64, data: &PriceData, ) -> Result<(), Error> { - if data.timestamp > close_time || close_time - data.timestamp > config.max_staleness { - return Err(Error::StalePrice); + if Self::staleness_ok(config.max_staleness, close_time, data) { + Ok(()) + } else { + Err(Error::StalePrice) } - Ok(()) + } + + /// The committed freshness rule itself, factored out of `validate_price` + /// so the quorum path classifies per-feed staleness through the same + /// predicate instead of duplicating it. + fn staleness_ok(max_staleness: u64, close_time: u64, data: &PriceData) -> bool { + data.timestamp <= close_time && close_time - data.timestamp <= max_staleness } /// Applies the committed comparison. fn outcome_for(config: &PriceConfig, price: i128) -> Outcome { - let hit = match config.direction { - PriceDirection::Above => price > config.threshold, - PriceDirection::AtOrAbove => price >= config.threshold, - PriceDirection::Below => price < config.threshold, - PriceDirection::AtOrBelow => price <= config.threshold, + Self::outcome_at(config.direction, config.threshold, price) + } + + /// The committed comparison, split from `PriceConfig` so the quorum path + /// (whose strike lives in `QuorumPriceConfig`, normalised) reuses it + /// verbatim. + fn outcome_at(direction: PriceDirection, threshold: i128, price: i128) -> Outcome { + let hit = match direction { + PriceDirection::Above => price > threshold, + PriceDirection::AtOrAbove => price >= threshold, + PriceDirection::Below => price < threshold, + PriceDirection::AtOrBelow => price <= threshold, }; if hit { Outcome::Yes @@ -2131,6 +2252,262 @@ impl Resolver { Ok(data) } + /// Rescale a fixed-point price from `from` decimals to `to` decimals with + /// checked arithmetic only; `None` stands for "cannot be represented", + /// which the quorum path surfaces as `Invalid`, never an overflow panic. + /// Down-scaling truncates toward zero, so at worst it rounds each feed + /// toward 0 by less than one target unit — too small to manufacture an + /// agreement the raw prices did not have. + fn normalize_price(value: i128, from: u32, to: u32) -> Option { + if from == to { + return Some(value); + } + if to > from { + let factor = 10i128.checked_pow(to - from)?; + value.checked_mul(factor) + } else { + let factor = 10i128.checked_pow(from - to)?; + value.checked_div(factor) + } + } + + /// The scale every quorum price is normalised to before the median: + /// `max(threshold_decimals, max feed decimals)` capped at `MAX_DECIMALS`. + /// Registration validates each component `<= MAX_DECIMALS`, so the cap is + /// belt-and-braces against a config that predates validation. + fn quorum_target_decimals(config: &QuorumPriceConfig) -> u32 { + let mut target = config.threshold_decimals; + for feed in config.feeds.iter() { + if feed.decimals > target { + target = feed.decimals; + } + } + if target > MAX_DECIMALS { + MAX_DECIMALS + } else { + target + } + } + + /// One feed's classified contribution to a quorum resolution. Isolation + /// and freshness reuse the exact predicates `read_feed`/`validate_price` + /// apply to a single feed; only the *reporting* differs — quorum needs to + /// tell a trap (`Failed`) from a missing record (`Unavailable`) so the + /// rejection events are honest. + fn classify_feed( + env: &Env, + feed: &FeedRef, + max_staleness: u64, + close_time: u64, + target_dec: u32, + ) -> FeedState { + if feed.decimals > MAX_DECIMALS { + return FeedState::Rejected(FeedRejectionReason::Invalid); + } + let call = + ReflectorPriceClient::new(env, &feed.reflector).try_price(&feed.asset, &close_time); + let data = match call { + Ok(Ok(Some(data))) => data, + Ok(Ok(None)) => return FeedState::Rejected(FeedRejectionReason::Unavailable), + // Outer `Err` = trap/invocation failure, inner `Err` = the feed + // contract's own error; a host-side decode/`ConversionError` + // lands here too. All of them `Failed`, none a panic. + _ => return FeedState::Rejected(FeedRejectionReason::Failed), + }; + if !Self::staleness_ok(max_staleness, close_time, &data) { + return FeedState::Rejected(FeedRejectionReason::Stale); + } + match Self::normalize_price(data.price, feed.decimals, target_dec) { + Some(price) => FeedState::Ok(price), + None => FeedState::Rejected(FeedRejectionReason::Invalid), + } + } + + /// Insert `value` into an ascending `sorted` list (O(n) scan, O(n^2) to + /// build n feeds, n <= MAX_FEEDS: no sort algorithm, no Vec sort feature). + fn insert_sorted(sorted: &mut Vec, value: i128) { + let mut pos = sorted.len(); + while pos > 0 { + match sorted.get(pos - 1) { + Some(prev) if prev > value => pos -= 1, + _ => break, + } + } + sorted.insert_at(pos, value); + } + + /// Median of an ascending list: the middle value for an odd count, the + /// floored average of the two middle values for an even count (checked + /// addition; `None` only on absurd overflowing inputs, surfaced as + /// `InvalidPrice`, never a panic). + fn median(sorted: &Vec) -> Option { + let count = sorted.len(); + if count == 0 { + return None; + } + if count % 2 == 1 { + return sorted.get(count / 2); + } + let low = sorted.get(count / 2 - 1)?; + let high = sorted.get(count / 2)?; + Some(low.checked_add(high)?.div_euclid(2)) + } + + /// `(max - min) * 10_000 / median > max_deviation_bps` -> `FeedsDisagree`. + /// Deliberately the full accepted spread, not distance-from-median: one + /// outlier among otherwise-quorate feeds is a disagreement a median would + /// hide. Any nonzero spread around a median of 0 is an unbounded relative + /// deviation, so it disagrees too. + fn assert_spread( + sorted: &Vec, + median: i128, + max_deviation_bps: u32, + ) -> Result<(), Error> { + let min = sorted.get(0).ok_or(Error::InvalidPrice)?; + let max = sorted.get(sorted.len() - 1).ok_or(Error::InvalidPrice)?; + let spread = max.checked_sub(min).ok_or(Error::FeedsDisagree)?; + if spread == 0 { + return Ok(()); + } + if median <= 0 { + return Err(Error::FeedsDisagree); + } + let deviation_bps = spread + .checked_mul(10_000) + .ok_or(Error::FeedsDisagree)? + .checked_div(median) + .ok_or(Error::FeedsDisagree)?; + if deviation_bps > max_deviation_bps as i128 { + return Err(Error::FeedsDisagree); + } + Ok(()) + } + + /// Registration-time gate for the quorum rule, inside the commitment: + /// every condition a resolution would otherwise hit mid-flight is decided + /// here (bounds, strict majority, duplicate `(reflector, asset)` pairs, + /// absurd decimals, threshold normalisation, deviation cap). + fn quorum_config_is_valid(config: &QuorumPriceConfig) -> bool { + let count = config.feeds.len(); + if count == 0 || count > MAX_FEEDS { + return false; + } + if config.min_responses == 0 || config.min_responses > count { + return false; + } + // Strict majority: two disjoint feed subsets can never both reach + // quorum, so a retry cannot legitimise a second conflicting answer. + if config.min_responses * 2 <= count { + return false; + } + if config.max_staleness == 0 || config.threshold < 0 { + return false; + } + if config.max_deviation_bps > MAX_DEVIATION_BPS || config.threshold_decimals > MAX_DECIMALS + { + return false; + } + for (index, feed) in config.feeds.iter().enumerate() { + if feed.decimals > MAX_DECIMALS { + return false; + } + let duplicate = + config.feeds.iter().take(index).any(|earlier| { + earlier.reflector == feed.reflector && earlier.asset == feed.asset + }); + if duplicate { + return false; + } + } + let target = Self::quorum_target_decimals(config); + Self::normalize_price(config.threshold, config.threshold_decimals, target).is_some() + } + + /// The quorum path of `resolve_price`. Every committed feed is classified + /// through the existing per-feed rules (`try_price` isolation, the + /// `staleness_ok` freshness check) into + /// `Ok(normalized) | Unavailable | Stale | Invalid | Failed`; rejected + /// feeds emit `FeedRejected`, survivors are normalised to the target + /// scale and medianed, the spread guard runs, and the median settles + /// through `settle_price` so market-core finalisation and the + /// `PriceResolution` events stay identical to single-feed resolution. + /// Too few usable feeds is `QuorumNotReached` — a plain return with + /// nothing written, so retrying after feeds recover always works. Nothing + /// per-feed is persisted: the whole resolution computes in one call. + fn resolve_quorum( + env: &Env, + market_id: u64, + config: &QuorumPriceConfig, + ) -> Result { + let market_core: Address = Self::instance_get(env, DataKey::MarketCore)?; + let market = + core_call(MarketCoreClient::new(env, &market_core).try_get_market(&market_id))?; + if env.ledger().timestamp() < market.close_time { + return Err(Error::MarketNotClosed); + } + if Self::resolution_state(env, market_id).is_terminal() { + return Err(Error::AlreadyFinalized); + } + let target_dec = Self::quorum_target_decimals(config); + let mut accepted = Vec::::new(env); + let mut rejected: u32 = 0; + for feed in config.feeds.iter() { + match Self::classify_feed( + env, + &feed, + config.max_staleness, + market.close_time, + target_dec, + ) { + FeedState::Ok(price) => Self::insert_sorted(&mut accepted, price), + FeedState::Rejected(reason) => { + rejected += 1; + FeedRejected { + market_id, + reflector: feed.reflector.clone(), + asset: feed.asset.clone(), + reason, + } + .publish(env); + } + } + } + if accepted.len() < config.min_responses { + return Err(Error::QuorumNotReached); + } + let median = Self::median(&accepted).ok_or(Error::InvalidPrice)?; + Self::assert_spread(&accepted, median, config.max_deviation_bps)?; + let threshold = + Self::normalize_price(config.threshold, config.threshold_decimals, target_dec) + .ok_or(Error::InvalidPrice)?; + let outcome = Self::outcome_at(config.direction, threshold, median); + QuorumResolution { + market_id, + median, + decimals: target_dec, + accepted: accepted.len(), + rejected, + outcome: outcome.clone(), + } + .publish(env); + // The median at `close_time` stands in for the single feed read: + // `PriceResolution::price` is then the value the market actually + // settled on, at the committed target scale (`decimals` on + // `QuorumResolution` gives its unit). + Self::settle_price( + env, + &market_core, + market_id, + &outcome, + &PriceData { + price: median, + timestamp: market.close_time, + }, + false, + )?; + Ok(outcome) + } + /// The outcome the market's price rule gives, from its stored snapshot /// when there is one (re-validated, no oracle call), else from a live /// read. The flag reports whether the snapshot was used. @@ -2157,6 +2534,11 @@ impl Resolver { /// `WrongMode`; markets with no revealed spec `MarketNotConfigured`. fn price_config(env: &Env, market_id: u64) -> Result { let storage = env.storage().persistent(); + if storage.has(&DataKey::Quorum(market_id)) { + // Quorum markets have no single feed to pin a snapshot against; + // `snapshot_price` is a single-oracle recovery tool. + return Err(Error::WrongMode); + } if let Some(cfg) = storage.get(&DataKey::Price(market_id)) { return Ok(cfg); } @@ -2215,6 +2597,7 @@ impl Resolver { || storage.has(&DataKey::Disputable(market_id)) || storage.has(&DataKey::CommitteeSpec(market_id)) || storage.has(&DataKey::Hybrid(market_id)) + || storage.has(&DataKey::Quorum(market_id)) { return Err(Error::MarketAlreadyConfigured); } @@ -2257,6 +2640,7 @@ impl Resolver { && cfg.fallback_after > 0 && cfg.fallback_after >= cfg.price.max_staleness } + ResolutionSpec::PriceQuorum(cfg) => Self::quorum_config_is_valid(cfg), }; if valid { Ok(()) @@ -2298,6 +2682,11 @@ impl Resolver { storage.set(&key, cfg); (key, ResolutionState::HybridPricePending) } + ResolutionSpec::PriceQuorum(cfg) => { + let key = DataKey::Quorum(market_id); + storage.set(&key, cfg); + (key, ResolutionState::Unconfigured) + } }; storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); if let Some((_, policy)) = Self::evidence_config_internal(env) { @@ -2614,7 +3003,22 @@ mod test { .set(&0u32, &PriceData { price, timestamp }); } + /// Quorum tests (#28): toggles `price` to trap, standing in for a + /// malicious or broken oracle. `try_price` must classify it `Failed` + /// and the resolution must carry on. + pub fn set_panic(env: Env, should_panic: bool) { + env.storage().instance().set(&1u32, &should_panic); + } + pub fn price(env: Env, _asset: OracleAsset, _timestamp: u64) -> Option { + let should_panic = env + .storage() + .instance() + .get::<_, bool>(&1u32) + .unwrap_or(false); + if should_panic { + panic!("mock reflector trap"); + } env.storage().instance().get(&0u32) } } @@ -5435,6 +5839,62 @@ mod golden { } } + /// Issue #28 appends `ResolutionSpec::PriceQuorum` at the tail of the + /// union. A `#[contracttype]` enum XDR-encodes per-variant by case name, + /// so an append cannot reindex or re-encode `Price`/`Signers`/...: these + /// are the pre-append golden vectors re-checked *after* the append, plus a + /// proof the new variant lives in its own hash space (no collision with + /// any legacy spec). Stranding a legacy `resolution_hash` would change one + /// of the constants above, which must never be "fixed" by editing them. + #[test] + fn legacy_specs_hash_unchanged_by_price_quorum_append() { + let env = Env::default(); + let xlm = OracleAsset::Other(Symbol::new(&env, "XLM")); + assert_eq!( + hex_of( + &env, + &price(&env, xlm.clone(), 2_000, PriceDirection::Above, 10) + ), + "b6f2458326d5f76bd9b53a93a103ea6c0fed7129c5496e91e3faef9a9a23d469" + ); + assert_eq!( + hex_of(&env, &signers(&env, &[G1, G2, G3], 2, 3_600)), + "4373448f97b0dd998a70efd9a1bfd3b3b8639bebaf682d9fc083c11f248a99ee" + ); + let quorum = ResolutionSpec::PriceQuorum(QuorumPriceConfig { + feeds: Vec::from_array( + &env, + [ + FeedRef { + reflector: Address::from_str(&env, C1), + asset: xlm.clone(), + decimals: 8, + }, + FeedRef { + reflector: Address::from_str(&env, C2), + asset: xlm.clone(), + decimals: 8, + }, + ], + ), + min_responses: 2, + threshold: 2_000, + threshold_decimals: 8, + direction: PriceDirection::Above, + max_staleness: 10, + max_deviation_bps: 1_000, + }); + let quorum_hash = hex_of(&env, &quorum); + assert_ne!( + quorum_hash, + "b6f2458326d5f76bd9b53a93a103ea6c0fed7129c5496e91e3faef9a9a23d469" + ); + assert_ne!( + quorum_hash, + "4373448f97b0dd998a70efd9a1bfd3b3b8639bebaf682d9fc083c11f248a99ee" + ); + } + /// V2 golden vector, re-derived independently from raw XDR (no Soroban) in /// `scripts/spec-hash` tests. The ledger network id is fixed explicitly so /// the vector never depends on the test `Env` default. diff --git a/oracledesk-stellar/scripts/spec-hash/src/lib.rs b/oracledesk-stellar/scripts/spec-hash/src/lib.rs index 2233ee6..e09fce9 100644 --- a/oracledesk-stellar/scripts/spec-hash/src/lib.rs +++ b/oracledesk-stellar/scripts/spec-hash/src/lib.rs @@ -6,9 +6,9 @@ //! `spec_v2_hash`, so the tool cannot drift from the contract. use resolver::{ - legacy_spec_hash, spec_v2_hash, CommitteeRef, DisputableSignerConfig, Escalation, HybridConfig, - OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, ResolutionSpecV2, SignerConfig, - WeightedKey, WeightedKeyConfig, + legacy_spec_hash, spec_v2_hash, CommitteeRef, DisputableSignerConfig, Escalation, FeedRef, + HybridConfig, OracleAsset, PriceConfig, PriceDirection, QuorumPriceConfig, ResolutionSpec, + ResolutionSpecV2, SignerConfig, WeightedKey, WeightedKeyConfig, }; use soroban_sdk::{Address, Bytes, BytesN, Env, Symbol, Vec as SorobanVec}; @@ -24,7 +24,10 @@ usage: is `guardian` or `committee::,...` spec-hash [V2 FLAGS] committee spec-hash [V2 FLAGS] hybrid -- ... - spec-hash [V2 FLAGS] verify ... + spec-hash [V2 FLAGS] price-quorum + ::,::,... + one entry per committed feed; uses the same `stellar:`/`other:` form as `price` + spec-hash [V2 FLAGS] verify ... spec-hash gen-salt V2 FLAGS (all four are required together; without them the legacy hash is printed): @@ -112,30 +115,36 @@ pub fn parse_signers(env: &Env, args: &[String]) -> Result Result { + if let Some(address) = value.strip_prefix("stellar:") { + Ok(OracleAsset::Stellar(parse_address(env, address)?)) + } else if let Some(symbol) = value.strip_prefix("other:") { + Ok(OracleAsset::Other(Symbol::new(env, symbol))) + } else { + Err("asset must be `stellar:
` or `other:`".into()) + } +} + +pub fn parse_direction(value: &str) -> Result { + match value { + "above" => Ok(PriceDirection::Above), + "at-or-above" => Ok(PriceDirection::AtOrAbove), + "below" => Ok(PriceDirection::Below), + "at-or-below" => Ok(PriceDirection::AtOrBelow), + other => Err(format!( + "unknown direction '{other}': expected above|at-or-above|below|at-or-below" + )), + } +} + pub fn parse_price(env: &Env, args: &[String]) -> Result { let [reflector, asset, threshold, direction, max_staleness] = args else { return Err( "price needs ".into(), ); }; - let asset = if let Some(address) = asset.strip_prefix("stellar:") { - OracleAsset::Stellar(parse_address(env, address)?) - } else if let Some(symbol) = asset.strip_prefix("other:") { - OracleAsset::Other(Symbol::new(env, symbol)) - } else { - return Err("asset must be `stellar:
` or `other:`".into()); - }; - let direction = match direction.as_str() { - "above" => PriceDirection::Above, - "at-or-above" => PriceDirection::AtOrAbove, - "below" => PriceDirection::Below, - "at-or-below" => PriceDirection::AtOrBelow, - other => { - return Err(format!( - "unknown direction '{other}': expected above|at-or-above|below|at-or-below" - )) - } - }; + let asset = parse_asset(env, asset)?; + let direction = parse_direction(direction)?; Ok(ResolutionSpec::Price(PriceConfig { reflector: parse_address(env, reflector)?, asset, @@ -149,6 +158,59 @@ pub fn parse_price(env: &Env, args: &[String]) -> Result })) } +/// `::` per feed, comma-separated. Split on the +/// FIRST and LAST colon so the asset's own `stellar:`/`other:` colon survives +/// in the middle without escaping. +pub fn parse_price_quorum(env: &Env, args: &[String]) -> Result { + let [min_responses, threshold, threshold_decimals, direction, max_staleness, max_deviation_bps, feeds @ ..] = + args + else { + return Err("price-quorum needs ::,...".into()); + }; + if feeds.len() != 1 || feeds[0].is_empty() { + return Err("price-quorum needs exactly one comma-separated feed list".into()); + } + let mut list = SorobanVec::new(env); + for entry in feeds[0].split(',') { + let Some((reflector, rest)) = entry.split_once(':') else { + return Err(format!( + "feed '{entry}' is not ::" + )); + }; + let Some((asset, decimals)) = rest.rsplit_once(':') else { + return Err(format!( + "feed '{entry}' is not ::" + )); + }; + list.push_back(FeedRef { + reflector: parse_address(env, reflector)?, + asset: parse_asset(env, asset)?, + decimals: decimals + .parse() + .map_err(|_| format!("decimals in '{entry}' must be a u32"))?, + }); + } + Ok(ResolutionSpec::PriceQuorum(QuorumPriceConfig { + feeds: list, + min_responses: min_responses + .parse() + .map_err(|_| "min_responses must be a u32".to_string())?, + threshold: threshold + .parse() + .map_err(|_| "threshold must be an i128".to_string())?, + threshold_decimals: threshold_decimals + .parse() + .map_err(|_| "threshold_decimals must be a u32".to_string())?, + direction: parse_direction(direction)?, + max_staleness: max_staleness + .parse() + .map_err(|_| "max_staleness must be a u64".to_string())?, + max_deviation_bps: max_deviation_bps + .parse() + .map_err(|_| "max_deviation_bps must be a u32".to_string())?, + })) +} + pub fn parse_weighted_keys(env: &Env, args: &[String]) -> Result { let [weight_threshold, dispute_window, keys @ ..] = args else { return Err( @@ -285,7 +347,8 @@ fn parse_rule(env: &Env, args: &[String]) -> Result { [kind, rest @ ..] if kind == "signers-disputable" => parse_signers_disputable(env, rest), [kind, rest @ ..] if kind == "committee" => parse_committee(rest), [kind, rest @ ..] if kind == "hybrid" => parse_hybrid(env, rest), - _ => Err("expected `signers`, `price`, `weighted-keys`, `signers-disputable`, `committee` or `hybrid`".into()), + [kind, rest @ ..] if kind == "price-quorum" => parse_price_quorum(env, rest), + _ => Err("expected `signers`, `price`, `weighted-keys`, `signers-disputable`, `committee`, `hybrid` or `price-quorum`".into()), } } @@ -608,4 +671,101 @@ mod tests { assert!(matches!(run(&bad), Err(CliError::Usage(_))), "{bad:?}"); } } + + fn quorum_parts() -> [&'static str; 7] { + [ + "price-quorum", + "2", + "2000000000000", + "8", + "above", + "600", + "1000", + ] + } + + fn quorum_feeds() -> String { + format!("{C1}:other:XLM:8,{C2}:stellar:{C3}:4") + } + + fn price_quorum_args() -> Vec { + quorum_parts() + .iter() + .map(|s| s.to_string()) + .chain(std::iter::once(quorum_feeds())) + .collect() + } + + #[test] + fn price_quorum_roundtrips_through_verify() { + let rule = price_quorum_args(); + let hash = hash_of(&rule.iter().map(String::as_str).collect::>()); + let mut verify: Vec = vec!["verify".into(), hash.clone()]; + verify.extend(rule); + let verify_refs: Vec<&str> = verify.iter().map(String::as_str).collect(); + assert_eq!(hash_of(&verify_refs), "match"); + } + + #[test] + fn price_quorum_hash_binds_every_field() { + let base_hash = hash_of( + &price_quorum_args() + .iter() + .map(String::as_str) + .collect::>(), + ); + // Each variant stays *parseable*; only the committed value changes, + // so a difference here proves the field is inside the hash. + let variants: [(&str, &str); 6] = [ + ("2", "1"), // min_responses + ("2000000000000", "2000"), // threshold + ("8", "7"), // threshold_decimals + ("above", "below"), // direction + ("600", "601"), // max_staleness + ("1000", "999"), // max_deviation_bps + ]; + for (from, to) in variants { + let mut parts = price_quorum_args(); + let slot = parts + .iter_mut() + .find(|arg| arg.as_str() == from) + .unwrap_or_else(|| panic!("field '{from}' missing from base args")); + *slot = to.to_string(); + assert_ne!( + hash_of(&parts.iter().map(String::as_str).collect::>()), + base_hash, + "field '{from}' is not covered by the commitment" + ); + } + // The feed list itself: same fields, different decimals. + let mut other_feeds = price_quorum_args(); + other_feeds[7] = format!("{C1}:other:XLM:12,{C2}:stellar:{C3}:4"); + assert_ne!( + hash_of( + &other_feeds + .iter() + .map(String::as_str) + .collect::>() + ), + base_hash + ); + } + + #[test] + fn price_quorum_rejects_malformed_input() { + let good = price_quorum_args(); + let mut too_few = good.clone(); + too_few.remove(too_few.len() - 1); // feed list missing + let mut bad_decimals = good.clone(); + bad_decimals[7] = bad_decimals[7].replace(":8,", ":x,"); + let mut no_decimals = good.clone(); + no_decimals[7] = no_decimals[7].replace(":8,", ","); + let mut bad_asset = good.clone(); + bad_asset[7] = bad_asset[7].replace("other:XLM", "bogus"); + let mut empty_feeds = good.clone(); + empty_feeds[7] = String::new(); + for bad in [too_few, bad_decimals, no_decimals, bad_asset, empty_feeds] { + assert!(matches!(run(&bad), Err(CliError::Usage(_))), "{bad:?}"); + } + } }