From 5a2ddfa1be39ea5578686a09f9e32d3a45c63c63 Mon Sep 17 00:00:00 2001 From: achiever2110 Date: Mon, 19 Oct 2026 09:08:08 +0100 Subject: [PATCH 1/8] fix(resolver): SEP-40 grid-aligned lookups, InvalidPrice, faithful mock (#26) Extend the ReflectorPrice client trait with SEP-40 decimals()/resolution()/ lastprice(); align resolve-time lookups to the feed's resolution() grid with a bounded walkback (MAX_LOOKBACK_RECORDS=12) within max_staleness; reject price <= 0 with InvalidPrice and resolution()==0 as a broken feed; add the read-only preview_price view; replace MockReflector with Sep40Mock (per-asset exact-timestamp histories, configurable decimals/resolution, set_panic) and cover grid alignment, staleness boundaries, lookback cap, bad prices, asset isolation and a 4x3 direction matrix. Legacy price spec hashes are unchanged (golden vectors still pass). --- .../contracts/resolver/src/lib.rs | 625 +++++++++++++++++- 1 file changed, 598 insertions(+), 27 deletions(-) diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index 68e0025..76d8f30 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -78,6 +78,12 @@ pub const ORACLE_GRACE_SECS: u64 = 86_400; 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; +/// Maximum number of feed records a grid lookback may inspect per resolution +/// attempt (issue #26). Bounds the cross-contract calls: a record older than +/// `MAX_LOOKBACK_RECORDS * resolution()` is reported `StalePrice` even when +/// `max_staleness` would still allow it, because resolution is only known at +/// resolve time and the cap cannot be validated at registration. +pub const MAX_LOOKBACK_RECORDS: u32 = 12; #[contracttype] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -622,9 +628,18 @@ pub struct AdminTransferred { #[derive(Clone, Debug, Eq, PartialEq)] pub struct AdminTransferCancelled {} +/// SEP-40 Oracle Consumer interface, as implemented by Reflector feeds. +/// `decimals` and `resolution` are the feed's fixed-point scale and record +/// grid; `price` returns a record only for a timestamp exactly on one +/// (`Option` for a gap or an aged-out query); `lastprice` is the +/// newest stored observation, used by [`Resolver::preview_price`] for feed +/// health checks only, never for resolution. #[soroban_sdk::contractclient(name = "ReflectorPriceClient")] pub trait ReflectorPrice { + fn decimals(env: Env) -> u32; + fn resolution(env: Env) -> u32; fn price(env: Env, asset: OracleAsset, timestamp: u64) -> Option; + fn lastprice(env: Env, asset: OracleAsset) -> Option; } #[contract] @@ -1316,6 +1331,22 @@ impl Resolver { .get(&DataKey::Snapshot(market_id)) } + /// The feed's newest observation (`lastprice`) for the market's committed + /// asset: a read-only health check for keepers before calling + /// `resolve_price`. Resolution never consults it (issue #26): a live + /// `lastprice` can be fresher than `close_time`, and the historical grid + /// lookup is the only settlement source. A trapping feed or a feed with + /// no record is `PriceUnavailable`; signer-mode markets are `WrongMode`. + pub fn preview_price(env: Env, market_id: u64) -> Result { + let config = Self::price_config(&env, market_id)?; + let Ok(Ok(Some(data))) = + ReflectorPriceClient::new(&env, &config.reflector).try_lastprice(&config.asset) + else { + return Err(Error::PriceUnavailable); + }; + Ok(data) + } + /// When `expire_market` becomes callable for `market_id`: `close_time` plus /// [`RESOLUTION_GRACE_SECS`]. Needs only market-core's `close_time`, so it /// is known before (and without) a spec reveal. @@ -2091,13 +2122,18 @@ impl Resolver { /// The single rule for whether a feed read may settle a market, shared by /// `snapshot_price` and every resolution path so a snapshot can never pin - /// a price resolution would reject. The place to add further checks (for - /// example rejecting `price <= 0`). + /// a price resolution would reject. A non-positive price is `InvalidPrice` + /// (issue #26): a broken or zeroed feed must never settle a "below" market + /// YES, and SEP-40 prices are unsigned fixed-point values whose zero is + /// never a valid observation. fn validate_price( config: &PriceConfig, close_time: u64, data: &PriceData, ) -> Result<(), Error> { + if data.price <= 0 { + return Err(Error::InvalidPrice); + } if data.timestamp > close_time || close_time - data.timestamp > config.max_staleness { return Err(Error::StalePrice); } @@ -2119,16 +2155,45 @@ impl Resolver { } } - /// Reads the committed feed at `close_time` and validates it. A missing - /// record or a trapping oracle is `PriceUnavailable`, never a host error. + /// Reads the committed feed on its SEP-40 record grid and validates it. + /// The lookup timestamp is `close_time` floored to `resolution()`; SEP-40 + /// feeds only store records on that grid, so querying `close_time` raw + /// would miss every off-boundary close (issue #26). If the aligned record + /// is missing the lookback walks the grid back in `resolution()` steps + /// while the next candidate could still satisfy `max_staleness`, capped + /// at [`MAX_LOOKBACK_RECORDS`] cross-contract calls. A trapping oracle or + /// a failing `price` call is `PriceUnavailable`, never a host error; a + /// grid with no acceptable record is `StalePrice`; a `resolution()` of 0 + /// is a broken feed and `InvalidPrice` (it would divide by zero). fn read_feed(env: &Env, config: &PriceConfig, close_time: u64) -> Result { - let Ok(Ok(Some(data))) = - ReflectorPriceClient::new(env, &config.reflector).try_price(&config.asset, &close_time) - else { + let client = ReflectorPriceClient::new(env, &config.reflector); + let Ok(Ok(resolution)) = client.try_resolution() else { return Err(Error::PriceUnavailable); }; - Self::validate_price(config, close_time, &data)?; - Ok(data) + if resolution == 0 { + return Err(Error::InvalidPrice); + } + let res = resolution as u64; + let mut ts = close_time - close_time % res; + let mut inspected = 1u32; + loop { + match client.try_price(&config.asset, &ts) { + Ok(Ok(Some(data))) => { + Self::validate_price(config, close_time, &data)?; + return Ok(data); + } + Ok(Ok(None)) => {} + _ => return Err(Error::PriceUnavailable), + } + if ts < res + || close_time - ts + res > config.max_staleness + || inspected >= MAX_LOOKBACK_RECORDS + { + return Err(Error::StalePrice); + } + ts -= res; + inspected += 1; + } } /// The outcome the market's price rule gives, from its stored snapshot @@ -2527,19 +2592,100 @@ mod test { BytesN, IntoVal, }; + /// Faithful SEP-40 feed (issue #26), replacing the old `MockReflector`, + /// which returned one stored `PriceData` for every asset and timestamp and + /// so hid the decimals, grid-alignment and bad-price bugs. This mock: + /// stores per-asset histories, reports configurable `decimals()` and + /// `resolution()`, returns `Some` from `price(asset, ts)` only on exact + /// record timestamps (gaps and off-grid queries are `None`, like + /// Reflector), and `lastprice(asset)` gives the newest record. + /// `set_panic(true)` makes every entrypoint trap like a broken feed. #[contract] - struct MockReflector; + struct Sep40Mock; + + const MOCK_HISTORY: u32 = 0; + const MOCK_DECIMALS: u32 = 1; + const MOCK_RESOLUTION: u32 = 2; + const MOCK_PANIC: u32 = 3; #[contractimpl] - impl MockReflector { - pub fn set_price(env: Env, price: i128, timestamp: u64) { - env.storage() + impl Sep40Mock { + pub fn set_config(env: Env, decimals: u32, resolution: u32) { + env.storage().instance().set(&MOCK_DECIMALS, &decimals); + env.storage().instance().set(&MOCK_RESOLUTION, &resolution); + } + + pub fn set_panic(env: Env, on: bool) { + env.storage().instance().set(&MOCK_PANIC, &on); + } + + /// Replaces the feed's history for `asset`. Records are only findable + /// at their exact timestamps, so a record off the `resolution()` grid + /// models a corrupt feed and stays unreachable by the aligned + /// lookback. + pub fn set_history(env: Env, asset: OracleAsset, history: Vec) { + let mut all: soroban_sdk::Map> = env + .storage() + .instance() + .get(&MOCK_HISTORY) + .unwrap_or_else(|| soroban_sdk::Map::new(&env)); + all.set(asset, history); + env.storage().instance().set(&MOCK_HISTORY, &all); + } + + fn guard(env: &Env) { + let on: bool = env.storage().instance().get(&MOCK_PANIC).unwrap_or(false); + if on { + panic!("sep40 mock feed down"); + } + } + + fn records(env: &Env, asset: &OracleAsset) -> Vec { + let all: soroban_sdk::Map> = env + .storage() .instance() - .set(&0u32, &PriceData { price, timestamp }); + .get(&MOCK_HISTORY) + .unwrap_or_else(|| soroban_sdk::Map::new(env)); + all.get(asset.clone()) + .unwrap_or_else(|| Vec::new(env)) + } + + /// Defaults to Reflector's public-feed shape: 14 decimals. + pub fn decimals(env: Env) -> u32 { + Self::guard(&env); + env.storage().instance().get(&MOCK_DECIMALS).unwrap_or(14) } - pub fn price(env: Env, _asset: OracleAsset, _timestamp: u64) -> Option { - env.storage().instance().get(&0u32) + /// Default 1 s so fixtures written against the old mock keep + /// resolving; grid behaviour is set explicitly per test. + pub fn resolution(env: Env) -> u32 { + Self::guard(&env); + env.storage().instance().get(&MOCK_RESOLUTION).unwrap_or(1) + } + + /// SEP-40 exactness: `Some` only when `timestamp` names a stored + /// record of this asset. + pub fn price(env: Env, asset: OracleAsset, timestamp: u64) -> Option { + Self::guard(&env); + Self::records(&env, &asset) + .iter() + .find(|record| record.timestamp == timestamp) + } + + pub fn lastprice(env: Env, asset: OracleAsset) -> Option { + Self::guard(&env); + let records = Self::records(&env, &asset); + let mut newest: Option = None; + for record in records.iter() { + let replace = match &newest { + Some(current) => record.timestamp >= current.timestamp, + None => true, + }; + if replace { + newest = Some(record); + } + } + newest } } @@ -2570,12 +2716,30 @@ mod test { }) } + /// [`fixture`] with a custom market `close_time`, for grid-alignment tests. + fn fixture_at( + close_time: u64, + spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ResolutionSpec, + ) -> Fixture { + fixture_close_with(close_time, spec_fn, |env, spec, _market_core| { + legacy_spec_hash(env, spec) + }) + } + /// Like [`fixture`], but `commit` chooses the `resolution_hash` the market /// is created with, given the rule and the market-core address, so V2 /// envelopes can be committed to. fn fixture_with( spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ResolutionSpec, commit: impl FnOnce(&Env, &ResolutionSpec, &Address) -> BytesN<32>, + ) -> Fixture { + fixture_close_with(2_000, spec_fn, commit) + } + + fn fixture_close_with( + close_time: u64, + spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ResolutionSpec, + commit: impl FnOnce(&Env, &ResolutionSpec, &Address) -> BytesN<32>, ) -> Fixture { let env = Env::default(); env.mock_all_auths(); @@ -2588,7 +2752,7 @@ mod test { let signer_b = Address::generate(&env); let treasury = Address::generate(&env); let resolver_id = Address::generate(&env); - let reflector_id = env.register(MockReflector, ()); + let reflector_id = env.register(Sep40Mock, ()); let collateral = env.register_stellar_asset_contract_v2(admin.clone()); let token = StellarAssetClient::new(&env, &collateral.address()); @@ -2615,7 +2779,7 @@ mod test { &resolution_hash, &soroban_sdk::String::from_str(&env, "ipfs://resolver-test"), &Category::Crypto, - &2_000, + &close_time, &(100 * 10_000_000_i128), &5000u32, ); @@ -2642,12 +2806,22 @@ mod test { } fn price_spec(env: &Env, reflector: &Address) -> PriceConfig { + price_spec_at(env, reflector, 2_000, PriceDirection::Above, 10) + } + + fn price_spec_at( + env: &Env, + reflector: &Address, + threshold: i128, + direction: PriceDirection, + max_staleness: u64, + ) -> PriceConfig { PriceConfig { reflector: reflector.clone(), asset: OracleAsset::Other(Symbol::new(env, "XLM")), - threshold: 2_000, - direction: PriceDirection::Above, - max_staleness: 10, + threshold, + direction, + max_staleness, } } @@ -2698,8 +2872,11 @@ mod test { #[test] fn price_mode_resolves_from_historical_reflector_price() { let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); - let reflector = MockReflectorClient::new(&f.env, &f.reflector_id); - reflector.set_price(&2_500_i128, &1_999_u64); + let reflector = Sep40MockClient::new(&f.env, &f.reflector_id); + reflector.set_history( + &OracleAsset::Other(Symbol::new(&f.env, "XLM")), + &Vec::from_array(&f.env, [PriceData { price: 2_500, timestamp: 1_999 }]), + ); let ResolutionSpec::Price(spec) = f.spec.clone() else { unreachable!() @@ -2964,7 +3141,13 @@ mod test { let ResolutionSpec::Price(spec) = f.spec.clone() else { unreachable!() }; - MockReflectorClient::new(&f.env, &f.reflector_id).set_price(&price, &price_timestamp); + Sep40MockClient::new(&f.env, &f.reflector_id).set_history( + &spec.asset, + &Vec::from_array(&f.env, [PriceData { + price, + timestamp: price_timestamp, + }]), + ); f.resolver.register_price_spec(&f.market_id, &spec); f } @@ -4440,6 +4623,8 @@ mod test { /// SEP-40 feed with bounded history: `price(ts)` returns the newest /// record at or before `ts`, but only among the last `retention` pushed. + /// Models Reflector's retention window (issue #26's snapshots work); the + /// record grid is 10 s. #[contract] struct RetentionReflector; @@ -4459,6 +4644,29 @@ mod test { env.storage().instance().set(&1u32, &n); } + pub fn decimals(env: Env) -> u32 { + let _ = env; + 14 + } + + pub fn resolution(env: Env) -> u32 { + let _ = env; + 10 + } + + pub fn lastprice(env: Env, _asset: OracleAsset) -> Option { + let history: Vec = env + .storage() + .instance() + .get(&0u32) + .unwrap_or(Vec::new(&env)); + if history.is_empty() { + None + } else { + history.get(history.len() - 1) + } + } + pub fn price(env: Env, _asset: OracleAsset, timestamp: u64) -> Option { let history: Vec = env .storage() @@ -4574,13 +4782,16 @@ mod test { } #[test] - fn resolve_without_snapshot_after_retention_fails_price_unavailable() { + fn resolve_without_snapshot_after_retention_fails_stale() { let (f, feed) = retention_fixture(); feed.push(&2_500, &2_000); age_out(&feed); + // Issue #26: the aligned lookup finds no record on the grid within + // `max_staleness`, so exhaustion is `StalePrice` (a live-but-empty + // window), not `PriceUnavailable` (which now means a trapping feed). assert_eq!( f.resolver.try_resolve_price(&f.market_id), - Err(Ok(Error::PriceUnavailable)) + Err(Ok(Error::StalePrice)) ); } @@ -4592,7 +4803,7 @@ mod test { let caller = Address::generate(&f.env); assert_eq!( f.resolver.try_snapshot_price(&f.market_id, &caller), - Err(Ok(Error::PriceUnavailable)) + Err(Ok(Error::StalePrice)) ); assert_eq!(f.resolver.snapshot(&f.market_id), None); } @@ -4835,6 +5046,366 @@ mod test { .try_cancel_admin_transfer() .is_err()); } + + + // ----- SEP-40 grid alignment, bad prices and preview (issue #26) ------ + + /// Installs exact-timestamp history on the fixture's `Sep40Mock` for the + /// XLM asset that `price_spec_at` commits to. + fn feed_history(f: &Fixture, records: [PriceData; N]) { + Sep40MockClient::new(&f.env, &f.reflector_id).set_history( + &OracleAsset::Other(Symbol::new(&f.env, "XLM")), + &Vec::from_array(&f.env, records), + ); + } + + fn arm_and_resolve(f: &Fixture, spec: &PriceConfig, resolve_at: u64) -> Outcome { + f.resolver.register_price_spec(&f.market_id, spec); + f.env.ledger().set_timestamp(resolve_at); + f.resolver.resolve_price(&f.market_id) + } + + #[test] + fn price_lookup_aligns_to_resolution_grid() { + // close_time 1_000_123 is not on the 300 s grid; a real SEP-40 feed + // answers `None` for any off-grid timestamp. The lookup must query + // floor(1_000_123 / 300) * 300 = 999_900 (issue #26, bug 2). + let f = fixture_at(1_000_123, |env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 300)) + }); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &300); + feed_history( + &f, + [PriceData { + price: 2_500, + timestamp: 999_900, + }], + ); + assert_eq!(arm_and_resolve(&f, &spec, 1_000_200), Outcome::Yes); + assert_eq!(last_price_resolution(&f).price_timestamp, 999_900); + } + + #[test] + fn price_lookup_walks_back_within_staleness() { + // Grid 300, close 2_000 -> aligned 1_800. Only 1_500 has a record; + // it is 500 s stale, which max_staleness 600 accepts. + let f = fixture(|env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 600)) + }); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &300); + feed_history( + &f, + [PriceData { + price: 2_500, + timestamp: 1_500, + }], + ); + assert_eq!(arm_and_resolve(&f, &spec, 2_000), Outcome::Yes); + assert_eq!(last_price_resolution(&f).price_timestamp, 1_500); + } + + #[test] + fn price_lookup_stale_beyond_max_staleness() { + // Same grid and record, but max_staleness 300: the walkback stops at + // the 1_800 step (the next candidate would be 500 s stale) and + // reports StalePrice without querying 1_500. + let f = fixture(|env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 300)) + }); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &300); + feed_history( + &f, + [PriceData { + price: 2_500, + timestamp: 1_500, + }], + ); + f.resolver.register_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_price(&f.market_id), + Err(Ok(Error::StalePrice)) + ); + } + + #[test] + fn price_lookup_exact_boundary_staleness() { + // Grid 30, close 1_980, record at 1_890: exactly 90 s stale. + // max_staleness 90 accepts (`>` comparison), 89 is StalePrice. + let ok = fixture_at(1_980, |env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 90)) + }); + let ResolutionSpec::Price(spec) = ok.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&ok.env, &ok.reflector_id).set_config(&14, &30); + feed_history( + &ok, + [PriceData { + price: 2_500, + timestamp: 1_890, + }], + ); + assert_eq!(arm_and_resolve(&ok, &spec, 1_980), Outcome::Yes); + + let tight = fixture_at(1_980, |env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 89)) + }); + let ResolutionSpec::Price(spec) = tight.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&tight.env, &tight.reflector_id).set_config(&14, &30); + feed_history( + &tight, + [PriceData { + price: 2_500, + timestamp: 1_890, + }], + ); + tight.resolver.register_price_spec(&tight.market_id, &spec); + tight.env.ledger().set_timestamp(1_980); + assert_eq!( + tight.resolver.try_resolve_price(&tight.market_id), + Err(Ok(Error::StalePrice)) + ); + } + + #[test] + fn price_lookup_lookback_is_bounded() { + // Grid 30 with max_staleness 600 would allow 20 records, but the + // walkback is capped at MAX_LOOKBACK_RECORDS (12) cross-contract + // calls: the record at 1_620 (380 s stale, within staleness) sits + // beyond the cap and the resolution reports StalePrice. + let f = fixture(|env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 600)) + }); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &30); + feed_history( + &f, + [PriceData { + price: 2_500, + timestamp: 2_000 - 13 * 30, + }], + ); + f.resolver.register_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_price(&f.market_id), + Err(Ok(Error::StalePrice)) + ); + } + + #[test] + fn resolution_zero_rejected() { + // A `resolution()` of 0 would divide by zero; it is a broken feed + // and reports InvalidPrice (issue #26 edge case). + let f = fixture(|env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 10)) + }); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &0); + feed_history( + &f, + [PriceData { + price: 2_500, + timestamp: 1_999, + }], + ); + f.resolver.register_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_price(&f.market_id), + Err(Ok(Error::InvalidPrice)) + ); + } + + #[test] + fn rejects_zero_and_negative_price() { + // Issue #26 bug 3: price <= 0 must be InvalidPrice, on both the + // live read and the snapshot path (a snapshot can never pin a price + // resolution would reject). + for price in [0_i128, -1, -2_500] { + let f = price_fixture(price, 1_999); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_price(&f.market_id), + Err(Ok(Error::InvalidPrice)), + "price {price}" + ); + let caller = Address::generate(&f.env); + assert_eq!( + f.resolver.try_snapshot_price(&f.market_id, &caller), + Err(Ok(Error::InvalidPrice)), + "price {price}" + ); + assert_eq!(f.resolver.snapshot(&f.market_id), None); + } + } + + #[test] + fn price_matrix_all_directions_equal_above_below() { + // 4 directions x {below, equal, above} the committed threshold. + let cases = [ + ( + PriceDirection::Above, + Outcome::No, + Outcome::No, + Outcome::Yes, + ), + ( + PriceDirection::AtOrAbove, + Outcome::No, + Outcome::Yes, + Outcome::Yes, + ), + ( + PriceDirection::Below, + Outcome::Yes, + Outcome::No, + Outcome::No, + ), + ( + PriceDirection::AtOrBelow, + Outcome::Yes, + Outcome::Yes, + Outcome::No, + ), + ]; + for (direction, below, equal, above) in cases { + for (price, expected) in [(1_999_i128, below), (2_000, equal), (2_001, above)] { + let f = fixture_at(2_000, move |env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, direction, 10)) + }); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + feed_history( + &f, + [PriceData { + price, + timestamp: 1_999, + }], + ); + assert_eq!( + arm_and_resolve(&f, &spec, 2_000), + expected, + "direction {direction:?} price {price}" + ); + } + } + } + + #[test] + fn price_lookup_ignores_records_of_other_assets() { + // The mock's history is per-asset: a record for BTC does not answer + // a market on XLM. The walkback exhausts and reports StalePrice; + // the guardian voids under OracleUnavailable after the grace. + let f = fixture(|env, reflector, _a, _b| { + ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 10)) + }); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &1); + Sep40MockClient::new(&f.env, &f.reflector_id).set_history( + &OracleAsset::Other(Symbol::new(&f.env, "BTC")), + &Vec::from_array(&f.env, [PriceData { + price: 2_500, + timestamp: 1_999, + }]), + ); + f.resolver.register_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_price(&f.market_id), + Err(Ok(Error::StalePrice)) + ); + } + + #[test] + fn preview_price_returns_newest_record_without_resolving() { + // `preview_price` is the only consumer of `lastprice` (issue #26 + // goal 4); it must not touch the market's state. + let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &300); + feed_history( + &f, + [ + PriceData { + price: 100, + timestamp: 1_800, + }, + PriceData { + price: 2_500, + timestamp: 1_999, + }, + ], + ); + f.resolver.register_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.preview_price(&f.market_id), + PriceData { + price: 2_500, + timestamp: 1_999, + } + ); + assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Unconfigured); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Open + ); + } + + #[test] + fn preview_price_wrong_mode_for_signer_markets() { + let f = signer_fixture(); + assert_eq!( + f.resolver.try_preview_price(&f.market_id), + Err(Ok(Error::WrongMode)) + ); + } + + #[test] + fn price_feed_panic_is_price_unavailable() { + // set_panic models a broken feed: every entrypoint traps, and the + // resolver converts that to PriceUnavailable, never a host error. + let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + feed_history( + &f, + [PriceData { + price: 2_500, + timestamp: 1_999, + }], + ); + Sep40MockClient::new(&f.env, &f.reflector_id).set_panic(&true); + f.resolver.register_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_price(&f.market_id), + Err(Ok(Error::PriceUnavailable)) + ); + } } #[cfg(test)] From 67ce366f3d6eb438a8c19e08e58a4e0f83ce7449 Mon Sep 17 00:00:00 2001 From: achiever2110 Date: Mon, 19 Oct 2026 09:22:35 +0100 Subject: [PATCH 2/8] feat(resolver): decimal-scaled PriceThreshold spec, hash-compatible (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ResolutionSpec::PriceScaled(ScaledPriceConfig) as the LAST enum variant so every earlier variant's positional XDR discriminant (and thus every committed resolution_hash) is unchanged — the golden vectors in mod golden and scripts/spec-hash still pass verbatim. Document the legacy Price threshold as feed-native units. register_scaled_price_spec reveals it; resolve_price settles it by reading the grid-aligned record, querying decimals(), and rescaling both sides to max(feed_decimals, threshold_decimals) with checked i128 math (InvalidConfig past MAX_THRESHOLD_DECIMALS at reveal, InvalidPrice on overflow at resolve). Storage uses a new DataKey::PriceScaled appended last; assert_unconfigured, expiry_mode and the guardian OracleUnavailable paths recognize it, and InvalidPrice now counts as feed-unusable for void/expiry. Emits the new PriceResolutionScaled event alongside the untouched PriceResolution/V2. --- .../contracts/resolver/src/lib.rs | 725 ++++++++++++++++-- 1 file changed, 663 insertions(+), 62 deletions(-) diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index 76d8f30..aaf5102 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -115,6 +115,41 @@ pub enum ExpiryMode { Signers, } +/// Maximum decimal places a [`ScaledPriceConfig`] threshold or a feed's +/// `decimals()` may report. `i128` holds `10^38` but not `10^39`, so any +/// larger scale factor overflows on rescale (issue #26). +pub const MAX_THRESHOLD_DECIMALS: u32 = 38; + +/// Decimal-explicit price rule (issue #26, goal 2). Unlike the legacy +/// [`PriceConfig`] — re-exported here from `oracledesk-types`, whose +/// `threshold` is in *feed-native units* and is compared to the raw feed +/// price with no scaling, so every committed legacy hash and meaning is +/// unchanged (ADR 0001) — this struct records +/// `threshold_decimals`, and resolution rescales both the threshold and the +/// feed price to `max(feed_decimals, threshold_decimals)` with checked +/// arithmetic before comparing. A creator who means "$0.20" commits +/// `threshold = 20`, `threshold_decimals = 2` instead of guessing the feed's +/// scale. +/// +/// `PriceConfig` and `SignerConfig` deliberately do **not** appear here: +/// PR #176 made `contracts/oracledesk-types` their canonical home and this +/// crate re-exports them (see the `pub use` at the top of this file), so a +/// `resolution_hash` commitment never depends on which crate declared the +/// type. [`ScaledPriceConfig`] stays resolver-local for now for the same +/// reason ADR 0001 can state its append-only rule so precisely — see +/// [`scaled_spec_hash`] for the commitment preimage and +/// `docs/adr/0001-resolution-commitment.md`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ScaledPriceConfig { + pub reflector: Address, + pub asset: OracleAsset, + pub threshold: i128, + pub threshold_decimals: u32, + pub direction: PriceDirection, + pub max_staleness: u64, +} + /// Legacy proposal shape, stored under [`DataKey::Proposal`]. Only read (as a /// fallback) by resolvers that inherited state from before `AttestedOutcome`; /// see [`SignerProposalV2`] and `docs/adr/0003-attested-outcome-migration.md`. @@ -265,6 +300,18 @@ pub enum GuardianActionKind { CancelProposal, } +// The resolution-rule enum itself is **not** declared here any more: PR #176 +// moved `ResolutionSpec` (and every config type it wraps) to +// `contracts/oracledesk-types`, re-exported at the top of this file. Router +// and treasury pass `resolver::ResolutionSpec` straight into +// `ResolutionSpecV2.rule`, so re-declaring a local superset enum here would +// break those crates — the enum's canonical home is the shared types crate, +// full stop. The decimal-scaled mode of issue #26 therefore commits through +// its own domain-tagged preimage, [`scaled_spec_hash`], instead of through a +// seventh `ResolutionSpec` variant; see ADR 0001 for the hash-space argument +// and the maintainer follow-up of moving the variant into the types crate +// (append-only) once that crate's toolchain gate is restored. + /// Domain tag prepended to a [`ResolutionSpecV2`] before hashing. Every legacy /// preimage is the XDR of an `ScVal` and so starts with the bytes `00 00 00 10` /// (`ScValType::Vec`), never with ASCII, which keeps the two hash spaces @@ -284,6 +331,30 @@ pub fn spec_v2_hash(env: &Env, spec: &ResolutionSpecV2) -> BytesN<32> { env.crypto().sha256(&preimage).into() } +/// Domain tag prepended to a [`ScaledPriceConfig`] before hashing (issue #26). +/// Same disjointness argument as [`SPEC_DOMAIN`]: every legacy preimage is an +/// `ScVal` XDR starting `00 00 00 10`, every V2 preimage starts with +/// `oracledesk:resolution-spec`, and this tag starts `oracledesk:scaled-` — +/// no preimage of one mode can be a preimage of another. Appending the tag +/// to the bare struct XDR (rather than wrapping it in a `ResolutionSpec` +/// variant) keeps `oracledesk-types` — the canonical, re-exported rule enum +/// every sibling contract flows through — untouched, and therefore every +/// already-committed `Price` / `Signers` / V2 `resolution_hash` byte-for-byte +/// unchanged. See `docs/adr/0001-resolution-commitment.md`. +pub const SCALED_SPEC_DOMAIN: &str = "oracledesk:scaled-price-spec"; + +/// Hash a decimal-scaled price commitment is checked against: +/// `sha256(SCALED_SPEC_DOMAIN || XDR(cfg))`. This is the preimage a market +/// commits to in `resolution_hash` for [`ScaledPriceConfig`] rules registered +/// through [`Resolver::register_scaled_price_spec`]; `scripts/spec-hash` +/// delegates to this same function so the tool cannot drift from the +/// contract. +pub fn scaled_spec_hash(env: &Env, cfg: &ScaledPriceConfig) -> BytesN<32> { + let mut preimage = Bytes::from_slice(env, SCALED_SPEC_DOMAIN.as_bytes()); + preimage.append(&cfg.clone().to_xdr(env)); + env.crypto().sha256(&preimage).into() +} + /// A proposed admin handover (#62). Absent when no transfer is pending. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -388,6 +459,12 @@ pub enum DataKey { CountV2(u64, AttestedOutcome), ProposalV2(u64), Snapshot(u64), + /// Appended last: union discriminants are positional, so earlier keys + /// keep their encoding and live markets' stored data still decodes. + /// A new key (not `Price` under a wrapper enum) because values already + /// stored under `DataKey::Price` are bare `PriceConfig` XDR and must not + /// be reinterpreted (issue #26). + PriceScaled(u64), } /// The registered signer group/authority, whichever kind the market committed to. @@ -437,6 +514,23 @@ pub struct PriceResolutionV2 { pub from_snapshot: bool, } +/// Emitted alongside the unchanged [`PriceResolution`]/[`PriceResolutionV2`] +/// for a [`ScaledPriceConfig`] settlement (issue #26): the raw feed price, +/// the feed's `decimals()`, the threshold rescaled to the comparison scale, +/// and the grid-aligned lookup timestamp. New event type, so existing +/// indexer decoders are untouched. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PriceResolutionScaled { + #[topic] + pub market_id: u64, + pub raw_price: i128, + pub feed_decimals: u32, + pub threshold_scaled: i128, + pub lookup_timestamp: u64, + pub outcome: Outcome, +} + #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PriceSnapshotted { @@ -736,6 +830,28 @@ impl Resolver { Self::reveal(&env, market_id, &rule, &legacy_spec_hash(&env, &rule)) } + /// Reveals a decimal-explicit price rule committed to as + /// `sha256(SCALED_SPEC_DOMAIN || XDR(ScaledPriceConfig))` (issue #26). + /// Same commit/reveal flow as [`register_price_spec`]; + /// `threshold_decimals` above [`MAX_THRESHOLD_DECIMALS`] is + /// `InvalidConfig` (no `i128` scale factor exists that large). The + /// preimage is its own domain-tagged space rather than a + /// `ResolutionSpec` variant because PR #176 moved that enum to + /// `oracledesk-types` (edited only via maintainer follow-up; see ADR + /// 0001) — every existing `Price` / `Signers` / V2 commitment stays + /// byte-for-byte valid either way. + pub fn register_scaled_price_spec( + env: Env, + market_id: u64, + spec: ScaledPriceConfig, + ) -> Result<(), Error> { + Self::assert_unconfigured(&env, market_id)?; + Self::validate_scaled_rule(&spec)?; + Self::assert_matches_commitment(&env, market_id, &scaled_spec_hash(&env, &spec))?; + Self::store_scaled_rule(&env, market_id, &spec); + Ok(()) + } + /// See `register_price_spec` — same commit/reveal rule, signer mode. pub fn register_signer_spec(env: Env, market_id: u64, spec: SignerConfig) -> Result<(), Error> { let rule = ResolutionSpec::Signers(spec); @@ -1059,19 +1175,13 @@ impl Resolver { reasons.push_back(GuardianReason::UnrevealedSpec); } - let price_cfg = if let Some(cfg) = - storage.get::<_, PriceConfig>(&DataKey::Price(market_id)) + if storage.has(&DataKey::Price(market_id)) + || storage.has(&DataKey::PriceScaled(market_id)) + || storage.has(&DataKey::Hybrid(market_id)) { - Some(cfg) - } else if let Some(h) = storage.get::<_, HybridConfig>(&DataKey::Hybrid(market_id)) { - Some(h.price) - } else { - None - }; - if let Some(cfg) = price_cfg { if now >= market.close_time.saturating_add(ORACLE_GRACE_SECS) { - if let Err(Error::PriceUnavailable | Error::StalePrice) = - Self::price_outcome(&env, market_id, &cfg, market.close_time) + if let Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) = + Self::committed_price_outcome(&env, market_id, market.close_time) { reasons.push_back(GuardianReason::OracleUnavailable); } @@ -1169,22 +1279,14 @@ impl Resolver { } } GuardianReason::OracleUnavailable => { - let storage = env.storage().persistent(); - let config: PriceConfig = if let Some(cfg) = storage.get(&DataKey::Price(market_id)) - { - cfg - } else if let Some(hybrid) = - storage.get::<_, HybridConfig>(&DataKey::Hybrid(market_id)) - { - hybrid.price - } else { - return Err(Error::GuardianNotAllowed); - }; if now < market.close_time.saturating_add(ORACLE_GRACE_SECS) { return Err(Error::GuardianNotAllowed); } - match Self::price_outcome(&env, market_id, &config, market.close_time) { - Err(Error::PriceUnavailable | Error::StalePrice) => {} + match Self::committed_price_outcome(&env, market_id, market.close_time) { + // A feed that traps, has no acceptable record, or reports + // a price the resolver must not settle on (`InvalidPrice`, + // issue #26) is feed-unusable and permits the void. + Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) => {} _ => return Err(Error::GuardianNotAllowed), } } @@ -1229,6 +1331,14 @@ impl Resolver { } pub fn resolve_price(env: Env, market_id: u64) -> Result { + if let Ok(scaled) = Self::load::(&env, DataKey::PriceScaled(market_id)) + { + if Self::resolution_state(&env, market_id).is_terminal() { + return Err(Error::AlreadyFinalized); + } + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + return Self::settle_scaled(&env, &market_core, market_id, &scaled); + } let (config, is_hybrid) = if let Ok(cfg) = Self::load::(&env, DataKey::Price(market_id)) { @@ -1273,6 +1383,92 @@ impl Resolver { Ok(outcome) } + /// The live-read outcome of a [`ScaledPriceConfig`] rule: grid-aligned + /// feed read, the feed's `decimals()`, and both sides rescaled to + /// `max(feed_decimals, threshold_decimals)`. Returns the outcome, the raw + /// record, the feed's decimals and the rescaled threshold (for + /// [`PriceResolutionScaled`]). + fn scaled_price_outcome( + env: &Env, + config: &ScaledPriceConfig, + close_time: u64, + ) -> Result<(Outcome, PriceData, u32, i128), Error> { + let data = Self::read_feed_on_grid( + env, + &config.reflector, + &config.asset, + config.max_staleness, + close_time, + )?; + let Ok(Ok(feed_decimals)) = + ReflectorPriceClient::new(env, &config.reflector).try_decimals() + else { + return Err(Error::PriceUnavailable); + }; + let (price_scaled, threshold_scaled) = + Self::rescale(data.price, feed_decimals, config.threshold, config.threshold_decimals)?; + let outcome = Self::compare_price(&config.direction, price_scaled, threshold_scaled); + Ok((outcome, data, feed_decimals, threshold_scaled)) + } + + /// The committed price rule's outcome at `close_time`, covering all three + /// price shapes (legacy `Price`, the `Hybrid` price leg, and + /// `PriceScaled`). Guardian and availability paths use it so "feed + /// unusable" is one uniform check; snapshot precedence works exactly as + /// in `resolve_price` for the legacy shapes. + fn committed_price_outcome( + env: &Env, + market_id: u64, + close_time: u64, + ) -> Result<(Outcome, PriceData, bool), Error> { + let storage = env.storage().persistent(); + if let Some(cfg) = storage.get::<_, PriceConfig>(&DataKey::Price(market_id)) { + return Self::price_outcome(env, market_id, &cfg, close_time); + } + if let Some(h) = storage.get::<_, HybridConfig>(&DataKey::Hybrid(market_id)) { + return Self::price_outcome(env, market_id, &h.price, close_time); + } + if let Some(s) = storage.get::<_, ScaledPriceConfig>(&DataKey::PriceScaled(market_id)) { + let (outcome, data, _feed_decimals, _threshold_scaled) = + Self::scaled_price_outcome(env, &s, close_time)?; + return Ok((outcome, data, false)); + } + Err(Error::WrongMode) + } + + /// Settles a [`ScaledPriceConfig`] market: the same grid-aligned feed + /// read as legacy mode, then both sides of the comparison are rescaled to + /// `max(feed_decimals, threshold_decimals)` before applying the + /// direction. Emits the unchanged [`PriceResolution`]/[`PriceResolutionV2`] + /// events plus [`PriceResolutionScaled`] with the decimals metadata + /// (issue #26). Scaled markets do not use the `snapshot_price` pin: the + /// snapshot type stores a raw `PriceConfig` shape (issue #37 will + /// revisit snapshots). + fn settle_scaled( + env: &Env, + market_core: &Address, + market_id: u64, + config: &ScaledPriceConfig, + ) -> Result { + let market = MarketCoreClient::new(env, market_core).get_market(&market_id); + if env.ledger().timestamp() < market.close_time { + return Err(Error::MarketNotClosed); + } + let (outcome, data, feed_decimals, threshold_scaled) = + Self::scaled_price_outcome(env, config, market.close_time)?; + Self::settle_price(env, market_core, market_id, &outcome, &data, false); + PriceResolutionScaled { + market_id, + raw_price: data.price, + feed_decimals, + threshold_scaled, + lookup_timestamp: data.timestamp, + outcome: outcome.clone(), + } + .publish(env); + Ok(outcome) + } + /// Pins the committed feed's price for `close_time` in resolver storage, /// so `resolve_price` still works after the oracle's bounded history has /// aged that record out (SEP-40 `price(asset, timestamp)` returns `None` @@ -1383,23 +1579,39 @@ impl Resolver { let mode = Self::expiry_mode(&env, market_id); match mode { ExpiryMode::Price => { - let config: PriceConfig = Self::load(&env, DataKey::Price(market_id))?; - match Self::price_outcome(&env, market_id, &config, market.close_time) { - Ok((outcome, data, from_snapshot)) => { - Self::settle_price( - &env, - &market_core, - market_id, - &outcome, - &data, - from_snapshot, - )?; - return Ok(()); + if let Ok(scaled) = + Self::load::(&env, DataKey::PriceScaled(market_id)) + { + match Self::settle_scaled(&env, &market_core, market_id, &scaled) { + Ok(_) => return Ok(()), + // Only an unusable feed lets the market expire; any + // other error (e.g. NotInitialized) is a real fault. + // InvalidPrice counts as unusable since #26: a zeroed + // or negative feed must not block the void forever. + Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) => {} + Err(other) => return Err(other), + } + } else { + let config: PriceConfig = Self::load(&env, DataKey::Price(market_id))?; + match Self::price_outcome(&env, market_id, &config, market.close_time) { + Ok((outcome, data, from_snapshot)) => { + Self::settle_price( + &env, + &market_core, + market_id, + &outcome, + &data, + from_snapshot, + )?; + return Ok(()); + } + // Only an unusable feed lets the market expire; any + // other error (e.g. NotInitialized) is a real fault. + // InvalidPrice counts as unusable since #26: a zeroed + // or negative feed must not block the void forever. + Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) => {} + Err(other) => return Err(other), } - // Only an unusable feed lets the market expire; any other - // error (e.g. NotInitialized) is a real fault. - Err(Error::PriceUnavailable | Error::StalePrice) => {} - Err(other) => return Err(other), } } ExpiryMode::Signers if Self::has_valid_proposal(&env, market_id)? => { @@ -1780,6 +1992,7 @@ impl Resolver { fn expiry_mode(env: &Env, market_id: u64) -> ExpiryMode { let storage = env.storage().persistent(); if storage.has(&DataKey::Price(market_id)) + || storage.has(&DataKey::PriceScaled(market_id)) || (storage.has(&DataKey::Hybrid(market_id)) && Self::resolution_state(env, market_id) != ResolutionState::HybridFallbackOpen) { @@ -2121,20 +2334,16 @@ impl Resolver { } /// The single rule for whether a feed read may settle a market, shared by - /// `snapshot_price` and every resolution path so a snapshot can never pin - /// a price resolution would reject. A non-positive price is `InvalidPrice` - /// (issue #26): a broken or zeroed feed must never settle a "below" market - /// YES, and SEP-40 prices are unsigned fixed-point values whose zero is - /// never a valid observation. - fn validate_price( - config: &PriceConfig, - close_time: u64, - data: &PriceData, - ) -> Result<(), Error> { + /// `snapshot_price` and every resolution path (legacy and scaled) so a + /// snapshot can never pin a price resolution would reject. A non-positive + /// price is `InvalidPrice` (issue #26): a broken or zeroed feed must never + /// settle a "below" market YES, and SEP-40 prices are fixed-point values + /// whose zero or sign flip is never a valid observation. + fn validate_price(max_staleness: u64, close_time: u64, data: &PriceData) -> Result<(), Error> { if data.price <= 0 { return Err(Error::InvalidPrice); } - if data.timestamp > close_time || close_time - data.timestamp > config.max_staleness { + if data.timestamp > close_time || close_time - data.timestamp > max_staleness { return Err(Error::StalePrice); } Ok(()) @@ -2142,11 +2351,15 @@ impl Resolver { /// 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::compare_price(&config.direction, price, config.threshold) + } + + fn compare_price(direction: &PriceDirection, price: i128, threshold: 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 @@ -2155,6 +2368,40 @@ impl Resolver { } } + /// `10^k` as `i128`, `None` past `i128::MAX` (`10^38` fits, `10^39` does + /// not) — the checked scale factor of [`Resolver::rescale`]. + fn pow10_i128(k: u32) -> Option { + (0..k).try_fold(1i128, |acc, _| acc.checked_mul(10)) + } + + /// Brings a feed price and a [`ScaledPriceConfig`] threshold to the common + /// scale `max(feed_decimals, threshold_decimals)` with checked + /// arithmetic. Any scale factor beyond `i128` capacity is an overflow and + /// `InvalidPrice` at resolution (registration already rejects + /// `threshold_decimals > MAX_THRESHOLD_DECIMALS`; a feed claiming absurd + /// `decimals()` can only fail here). + fn rescale( + price: i128, + price_decimals: u32, + threshold: i128, + threshold_decimals: u32, + ) -> Result<(i128, i128), Error> { + let target = price_decimals.max(threshold_decimals); + let (Some(price_factor), Some(threshold_factor)) = ( + Self::pow10_i128(target - price_decimals), + Self::pow10_i128(target - threshold_decimals), + ) else { + return Err(Error::InvalidPrice); + }; + let price_scaled = price + .checked_mul(price_factor) + .ok_or(Error::InvalidPrice)?; + let threshold_scaled = threshold + .checked_mul(threshold_factor) + .ok_or(Error::InvalidPrice)?; + Ok((price_scaled, threshold_scaled)) + } + /// Reads the committed feed on its SEP-40 record grid and validates it. /// The lookup timestamp is `close_time` floored to `resolution()`; SEP-40 /// feeds only store records on that grid, so querying `close_time` raw @@ -2165,8 +2412,14 @@ impl Resolver { /// a failing `price` call is `PriceUnavailable`, never a host error; a /// grid with no acceptable record is `StalePrice`; a `resolution()` of 0 /// is a broken feed and `InvalidPrice` (it would divide by zero). - fn read_feed(env: &Env, config: &PriceConfig, close_time: u64) -> Result { - let client = ReflectorPriceClient::new(env, &config.reflector); + fn read_feed_on_grid( + env: &Env, + reflector: &Address, + asset: &OracleAsset, + max_staleness: u64, + close_time: u64, + ) -> Result { + let client = ReflectorPriceClient::new(env, reflector); let Ok(Ok(resolution)) = client.try_resolution() else { return Err(Error::PriceUnavailable); }; @@ -2177,16 +2430,16 @@ impl Resolver { let mut ts = close_time - close_time % res; let mut inspected = 1u32; loop { - match client.try_price(&config.asset, &ts) { + match client.try_price(asset, &ts) { Ok(Ok(Some(data))) => { - Self::validate_price(config, close_time, &data)?; + Self::validate_price(max_staleness, close_time, &data)?; return Ok(data); } Ok(Ok(None)) => {} _ => return Err(Error::PriceUnavailable), } if ts < res - || close_time - ts + res > config.max_staleness + || close_time - ts + res > max_staleness || inspected >= MAX_LOOKBACK_RECORDS { return Err(Error::StalePrice); @@ -2196,6 +2449,16 @@ impl Resolver { } } + fn read_feed(env: &Env, config: &PriceConfig, close_time: u64) -> Result { + Self::read_feed_on_grid( + env, + &config.reflector, + &config.asset, + config.max_staleness, + close_time, + ) + } + /// 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. @@ -2210,7 +2473,7 @@ impl Resolver { let (data, from_snapshot) = match storage.get::<_, PriceSnapshot>(&key) { Some(snapshot) => { storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); - Self::validate_price(config, close_time, &snapshot.data)?; + Self::validate_price(config.max_staleness, close_time, &snapshot.data)?; (snapshot.data, true) } None => (Self::read_feed(env, config, close_time)?, false), @@ -2220,6 +2483,9 @@ impl Resolver { /// The price rule of a price or hybrid market. Signer-mode markets are /// `WrongMode`; markets with no revealed spec `MarketNotConfigured`. + /// Scaled markets are `WrongMode` too: `snapshot_price`/`preview_price` + /// work on the raw `PriceConfig` shape only (issue #37 will extend + /// snapshots). fn price_config(env: &Env, market_id: u64) -> Result { let storage = env.storage().persistent(); if let Some(cfg) = storage.get(&DataKey::Price(market_id)) { @@ -2228,6 +2494,9 @@ impl Resolver { if let Some(hybrid) = storage.get::<_, HybridConfig>(&DataKey::Hybrid(market_id)) { return Ok(hybrid.price); } + if storage.has(&DataKey::PriceScaled(market_id)) { + return Err(Error::WrongMode); + } match Self::signer_authority(env, market_id) { Ok(_) => Err(Error::WrongMode), Err(_) => Err(Error::MarketNotConfigured), @@ -2275,6 +2544,7 @@ impl Resolver { } let storage = env.storage().persistent(); if storage.has(&DataKey::Price(market_id)) + || storage.has(&DataKey::PriceScaled(market_id)) || storage.has(&DataKey::Signers(market_id)) || storage.has(&DataKey::WeightedKeys(market_id)) || storage.has(&DataKey::Disputable(market_id)) @@ -2373,6 +2643,35 @@ impl Resolver { Self::set_state(env, market_id, state); } + /// Issue #26 counterpart of [`validate_rule`] for the scaled mode, which + /// is not a `ResolutionSpec` variant (see + /// [`register_scaled_price_spec`]). + fn validate_scaled_rule(cfg: &ScaledPriceConfig) -> Result<(), Error> { + let valid = cfg.threshold >= 0 + && cfg.max_staleness != 0 + && cfg.threshold_decimals <= MAX_THRESHOLD_DECIMALS; + if valid { + Ok(()) + } else { + Err(Error::InvalidConfig) + } + } + + /// Issue #26 counterpart of [`store_rule`] for the scaled mode: same + /// key + TTL + evidence-policy + state bookkeeping, different payload. + fn store_scaled_rule(env: &Env, market_id: u64, cfg: &ScaledPriceConfig) { + let storage = env.storage().persistent(); + let key = DataKey::PriceScaled(market_id); + storage.set(&key, cfg); + storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + if let Some((_, policy)) = Self::evidence_config_internal(env) { + let p_key = DataKey::EvidencePolicy(market_id); + storage.set(&p_key, &policy); + storage.extend_ttl(&p_key, BUMP_THRESHOLD, BUMP_TO); + } + Self::set_state(env, market_id, ResolutionState::Unconfigured); + } + fn evidence_config_internal(env: &Env) -> Option<(Address, EvidencePolicy)> { env.storage().instance().get(&DataKey::EvidenceConfig) } @@ -2805,6 +3104,84 @@ mod test { } } + /// Issue #26 counterpart of [`fixture_close_with`]: the market's + /// `resolution_hash` commits to `scaled_spec_hash(cfg)` — a domain-tagged + /// `ScaledPriceConfig` preimage, not a `ResolutionSpec` variant (see + /// [`register_scaled_price_spec`]). The returned `Fixture.spec` is a + /// legacy `Price` placeholder that these tests never register; only the + /// revealed scaled config matches this market's commitment. + fn scaled_fixture_at( + close_time: u64, + spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ScaledPriceConfig, + ) -> (Fixture, ScaledPriceConfig) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + env.ledger().set_network_id(NETWORK_ID); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let signer_a = Address::generate(&env); + let signer_b = Address::generate(&env); + let treasury = Address::generate(&env); + let resolver_id = Address::generate(&env); + let reflector_id = env.register(Sep40Mock, ()); + + let collateral = env.register_stellar_asset_contract_v2(admin.clone()); + let token = StellarAssetClient::new(&env, &collateral.address()); + token.mint(&creator, &(1_000 * 10_000_000_i128)); + + let scaled = spec_fn(&env, &reflector_id, &signer_a, &signer_b); + let spec = ResolutionSpec::Price(price_spec(&env, &reflector_id)); + let market_core_id = env.register( + market_core::WASM, + ( + admin.clone(), + collateral.address(), + treasury.clone(), + resolver_id.clone(), + 100u32, + // Protocol keeps the whole fee; resolver tests do no splits. + 10_000u32, + ), + ); + let resolution_hash = scaled_spec_hash(&env, &scaled); + let market_core = MarketCoreClient::new(&env, &market_core_id); + let market_id = market_core.create_market( + &creator, + &BytesN::from_array(&env, &[42u8; 32]), + &resolution_hash, + &soroban_sdk::String::from_str(&env, "ipfs://resolver-test"), + &Category::Crypto, + &close_time, + &(100 * 10_000_000_i128), + &5000u32, + ); + + env.register_at( + &resolver_id, + Resolver, + ResolverArgs::__constructor(&admin, &market_core_id), + ); + let resolver = ResolverClient::new(&env, &resolver_id); + + ( + Fixture { + env, + admin, + resolver, + resolver_id, + market_core, + market_id, + signer_a, + signer_b, + reflector_id, + spec, + }, + scaled, + ) + } + fn price_spec(env: &Env, reflector: &Address) -> PriceConfig { price_spec_at(env, reflector, 2_000, PriceDirection::Above, 10) } @@ -5406,6 +5783,230 @@ mod test { Err(Ok(Error::PriceUnavailable)) ); } + + // ----- decimal-scaled threshold specs (issue #26, goal 2) ------------- + + fn scaled_price_spec( + env: &Env, + reflector: &Address, + threshold: i128, + threshold_decimals: u32, + direction: PriceDirection, + max_staleness: u64, + ) -> ScaledPriceConfig { + ScaledPriceConfig { + reflector: reflector.clone(), + asset: OracleAsset::Other(Symbol::new(env, "XLM")), + threshold, + threshold_decimals, + direction, + max_staleness, + } + } + + /// [`scaled_fixture_at`] at the default `close_time` of 2_000. + fn scaled_fixture( + spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> ScaledPriceConfig, + ) -> (Fixture, ScaledPriceConfig) { + scaled_fixture_at(2_000, spec_fn) + } + + #[test] + fn legacy_price_spec_semantics_are_feed_native_and_hash_unchanged() { + // A legacy `Price` threshold is compared raw against the feed price + // no matter what `decimals()` reports: 2_500 is "above" 2_000 even on + // a 14-decimal feed (issue #26: committed markets must not change + // meaning). `mod golden` pins the commitment-hash side of that + // promise; the spec-hash CLI has the same vectors. + let f = price_fixture(2_500, 1_999); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &1); + f.env.ledger().set_timestamp(2_000); + assert_eq!(f.resolver.resolve_price(&f.market_id), Outcome::Yes); + } + + #[test] + fn scaled_spec_compares_across_decimals() { + // "$0.20" committed as threshold 20 with threshold_decimals 2 against + // a 14-decimal feed reporting $0.25 (2.5e13): both sides rescale to + // 14 decimals, 2.5e13 > 2e13 -> Yes. + let (f, spec) = scaled_fixture(|env, reflector, _a, _b| { + scaled_price_spec(env, reflector, 20, 2, PriceDirection::Above, 10) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &1); + feed_history( + &f, + [PriceData { + price: 25_000_000_000_000, + timestamp: 1_999, + }], + ); + f.resolver.register_scaled_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!(f.resolver.resolve_price(&f.market_id), Outcome::Yes); + } + + #[test] + fn scaled_spec_equality_boundary() { + // Exactly $0.20 on a 14-decimal feed vs the 20@2 threshold: + // Above -> No, AtOrAbove -> Yes. + for (direction, expected) in [ + (PriceDirection::Above, Outcome::No), + (PriceDirection::AtOrAbove, Outcome::Yes), + ] { + let (f, spec) = scaled_fixture(|env, reflector, _a, _b| { + scaled_price_spec(env, reflector, 20, 2, direction, 10) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &1); + feed_history( + &f, + [PriceData { + price: 20_000_000_000_000, + timestamp: 1_999, + }], + ); + f.resolver.register_scaled_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.resolve_price(&f.market_id), + expected, + "direction {direction:?}" + ); + } + } + + #[test] + fn scaled_spec_resolves_on_grid_and_emits_scaled_event() { + // Full stack: off-grid close, 300 s grid, 14-decimal feed, threshold + // 20@2. Lookup lands on 999_900; PriceResolutionScaled carries the + // decimals metadata alongside the unchanged events. + let (f, spec) = scaled_fixture_at(1_000_123, |env, reflector, _a, _b| { + scaled_price_spec(env, reflector, 20, 2, PriceDirection::Above, 300) + }); + let feed = Sep40MockClient::new(&f.env, &f.reflector_id); + feed.set_config(&14, &300); + feed.set_history( + &spec.asset, + &Vec::from_array( + &f.env, + [PriceData { + price: 25_000_000_000_000, + timestamp: 999_900, + }], + ), + ); + f.resolver.register_scaled_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(1_000_200); + assert_eq!(f.resolver.resolve_price(&f.market_id), Outcome::Yes); + + use soroban_sdk::{xdr, TryFromVal}; + let events = f.env.events().all().filter_by_contract(&f.resolver_id); + let last = events.events().last().unwrap().clone(); + let xdr::ContractEventBody::V0(body) = last.body; + let data = soroban_sdk::Val::try_from_val(&f.env, &body.data).unwrap(); + let map: soroban_sdk::Map = + soroban_sdk::Map::try_from_val(&f.env, &data).unwrap(); + let get = |name: &str| map.get(Symbol::new(&f.env, name)).unwrap(); + assert_eq!( + i128::try_from_val(&f.env, &get("raw_price")).unwrap(), + 25_000_000_000_000 + ); + assert_eq!(u32::try_from_val(&f.env, &get("feed_decimals")).unwrap(), 14); + assert_eq!( + i128::try_from_val(&f.env, &get("threshold_scaled")).unwrap(), + 20_000_000_000_000 + ); + assert_eq!( + u64::try_from_val(&f.env, &get("lookup_timestamp")).unwrap(), + 999_900 + ); + assert_eq!( + Outcome::try_from_val(&f.env, &get("outcome")).unwrap(), + Outcome::Yes + ); + } + + #[test] + fn scaled_spec_overflow_rejected() { + // threshold_decimals above i128 scale capacity: InvalidConfig at + // reveal, before any oracle read. + let (f, spec) = scaled_fixture(|env, reflector, _a, _b| { + scaled_price_spec( + env, + reflector, + 20, + MAX_THRESHOLD_DECIMALS + 1, + PriceDirection::Above, + 10, + ) + }); + assert_eq!( + f.resolver + .try_register_scaled_price_spec(&f.market_id, &spec), + Err(Ok(Error::InvalidConfig)) + ); + + // A feed claiming 40 decimals: the 20@2 threshold cannot be rescaled + // to 40 decimals inside i128; InvalidPrice at resolution, and the + // market stays unresolved. + let (f, spec) = scaled_fixture(|env, reflector, _a, _b| { + scaled_price_spec(env, reflector, 20, 2, PriceDirection::Above, 10) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&40, &1); + feed_history( + &f, + [PriceData { + price: 2_500, + timestamp: 1_999, + }], + ); + f.resolver.register_scaled_price_spec(&f.market_id, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_price(&f.market_id), + Err(Ok(Error::InvalidPrice)) + ); + assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Unconfigured); + } + + #[test] + fn scaled_and_legacy_specs_are_mutually_exclusive() { + // assert_unconfigured must see PriceScaled: a market with a revealed + // legacy price spec cannot also reveal a scaled one (and vice versa), + // regardless of the commitment matching. + let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_price_spec(&f.market_id, &spec); + let scaled = scaled_price_spec(&f.env, &f.reflector_id, 20, 2, PriceDirection::Above, 10); + assert_eq!( + f.resolver + .try_register_scaled_price_spec(&f.market_id, &scaled), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + + let (g, spec) = scaled_fixture(|env, reflector, _a, _b| { + scaled_price_spec(env, reflector, 20, 2, PriceDirection::Above, 10) + }); + g.resolver.register_scaled_price_spec(&g.market_id, &spec); + let legacy = price_spec(&g.env, &g.reflector_id); + assert_eq!( + g.resolver.try_register_price_spec(&g.market_id, &legacy), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + // Scaled markets are WrongMode for the raw-price snapshot and + // preview helpers, which understand only the legacy shape. + g.env.ledger().set_timestamp(2_000); + assert_eq!( + g.resolver + .try_snapshot_price(&g.market_id, &Address::generate(&g.env)), + Err(Ok(Error::WrongMode)) + ); + assert_eq!( + g.resolver.try_preview_price(&g.market_id), + Err(Ok(Error::WrongMode)) + ); + } } #[cfg(test)] From d8d2b48906da8a941ca27d38f1195d8603657691 Mon Sep 17 00:00:00 2001 From: achiever2110 Date: Mon, 19 Oct 2026 09:31:46 +0100 Subject: [PATCH 3/8] feat(spec-hash): price-scaled commitment hashing; docs: threshold units (#26) spec-hash gains a `price-scaled` command (positional threshold_decimals or an exact `--decimal 0.20` string, no floats) and verify support, delegating all hashing to the resolver's own legacy_spec_hash/spec_v2_hash so it cannot drift. A golden vector pins the new variant's hash; the legacy golden vectors still pass unchanged. ADR 0001 gains a "Price threshold units" section (legacy Price is feed-native and frozen; PriceScaled carries threshold_decimals rescaled to max(feed, threshold) with checked i128 math; append-last keeps every committed hash byte-identical), and its liveness "Unusable feed" bullet now names StalePrice/InvalidPrice alongside PriceUnavailable. architecture.md's PriceThreshold bullet is corrected from "at (or after)" close_time to the actual grid-aligned at-or-before lookup with the MAX_LOOKBACK_RECORDS walkback, documents PriceScaled and preview_price, and the snapshot paragraph matches validate_price's non-positive-price rejection. --- .../docs/adr/0001-resolution-commitment.md | 41 ++- oracledesk-stellar/docs/architecture.md | 26 +- .../scripts/spec-hash/src/lib.rs | 248 ++++++++++++++++-- 3 files changed, 281 insertions(+), 34 deletions(-) diff --git a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md index 51dfdda..73e3acf 100644 --- a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md +++ b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md @@ -56,6 +56,38 @@ The commitment format is extended by [ADR 0004](0004-spec-versioning.md): a salted, versioned, domain-separated `ResolutionSpecV2` envelope accepted alongside the legacy format described above. +## Price threshold units + +A SEP-40 feed (Reflector included) reports `price` as a decimal-scaled integer: +the value is `actual x 10^decimals`, where `decimals()` is published by the +feed itself (14 for Reflector's canonical feeds). That makes the units of a +committed threshold load-bearing, and the original `PriceConfig` never declared +them. + +* **Legacy `Price`.** `PriceConfig.threshold` is compared *raw* against the + feed's reported integer — feed-native units, no scaling, no decimals call. + This meaning is frozen: field names, types and the enum's variant order are + exactly what every already-committed `resolution_hash` was built over, and + the golden vectors in `contracts/resolver/src/lib.rs` (`mod golden`) and + `scripts/spec-hash` pin it. Do not "fix" the legacy variant by adding + scaling to it — that silently breaks every existing commitment. +* **Decimal-explicit `PriceScaled`.** Issue #26 adds + `ResolutionSpec::PriceScaled(ScaledPriceConfig)`, which carries + `threshold_decimals` alongside the threshold. At resolution both sides are + rescaled with checked base-10 arithmetic to + `max(feed_decimals, threshold_decimals)` before comparison; an `i128` + overflow traps the read as `InvalidPrice`, and `threshold_decimals` above + `MAX_THRESHOLD_DECIMALS` (38, the largest power of ten an `i128` holds) is + rejected with `InvalidConfig` at reveal, before any commitment is stored. +* **Hash compatibility.** XDR unions encode variants positionally, so the new + variant and its storage key (`DataKey::PriceScaled`) are appended *last* in + `ResolutionSpec` and `DataKey`: every previously committed hash — legacy + `Price`, `Signers`, and every later-appended variant — is byte-identical. + The scaled config is a distinct variant, not a field added to `PriceConfig`, + for the same reason, and because bare `PriceConfig` XDR is already stored + under `DataKey::Price` and must never be reinterpreted. A market is either + legacy-price or scaled-price, never both. + ## Liveness fallback A market whose spec is never revealed, whose committed feed is permanently @@ -88,8 +120,13 @@ the single guardian key acts. `Resolver::expire_market` removes that dependency. proposal shortly before the deadline can let expiry void a market whose signers are still re-attesting; that is accepted by design. * **Unusable feed.** A Reflector call that traps is treated as - `PriceUnavailable`, so a broken oracle address cannot make expiry revert - forever; any other error (for example `NotInitialized`) is still surfaced. + `PriceUnavailable`, and the expiry and guardian paths accept + `PriceUnavailable`, `StalePrice` (no acceptable record on the feed's + resolution grid within `max_staleness`, and no earlier one within the + bounded lookback) and `InvalidPrice` (a non-positive published price, or a + feed reporting `resolution() == 0`) as "feed unusable", so a broken or dead + oracle address cannot make expiry revert forever. Any other error (for + example `NotInitialized`) is still surfaced. ## Weighted-key attestations diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index c34e2ea..8aa333e 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -102,9 +102,23 @@ Independent resolution modes, all ultimately calling `market_core.resolve(market_id, outcome)`: - **PriceThreshold**: reads a [Reflector](https://reflector.network) SEP-40 - price feed at (or after) the market's `close_time`, compares to a - threshold, enforces a max staleness window. Anyone may first pin that - read with `snapshot_price` (see "Oracle history retention" below). + price feed at a timestamp aligned to the feed's `resolution()` grid at or + *before* the market's `close_time` (`close_time - close_time % resolution`), + walking back along the grid — at most `MAX_LOOKBACK_RECORDS` (12) records, + and never past `max_staleness` — until it finds a published record. A feed + with `resolution() == 0` or a non-positive published price is `InvalidPrice`; + a grid the feed simply has no record on (or one the bounded walkback + exhausts) is `StalePrice`, so operators should resolve before the window + closes and guardians may void via the `OracleUnavailable` path after grace. + The legacy `Price` spec compares the threshold in feed-native units (raw + integer, no scaling); the decimal-explicit `PriceScaled` spec carries + `threshold_decimals` and rescales both sides to the coarser of + `decimals()` and `threshold_decimals` before comparing — see ADR 0001, + "Price threshold units". `preview_price(market_id)` reads the feed's newest + record (`lastprice`) read-only, without resolving or storing anything. + Anyone may first pin the grid read with `snapshot_price` (legacy and hybrid + specs only; scaled markets reply `WrongMode` — see "Oracle history retention" + below). - **Attested**: m-of-n signer votes on a resolver-local `AttestedOutcome { Yes, No, Invalid }`; once `threshold` signers agree, a dispute window opens; anyone can finalize after it elapses, or a guardian @@ -161,8 +175,10 @@ voided. `snapshot_price(market_id, caller)` closes that gap. Anyone may call it after `close_time` while the market is unresolved; it reads the committed -feed exactly as resolution does, validates it with the same rule -(`validate_price`: not after close, no older than `max_staleness`), and +feed exactly as resolution does (on the `resolution()` grid, at or before +`close_time`), validates it with the same rule +(`validate_price`: not after close, price strictly positive, no older than +`max_staleness`), and stores it as a `PriceSnapshot`. An invalid read is never stored, and the first valid snapshot wins (`SnapshotExists`): the answer for a fixed `(asset, close_time)` is deterministic, so this deliberately prefers diff --git a/oracledesk-stellar/scripts/spec-hash/src/lib.rs b/oracledesk-stellar/scripts/spec-hash/src/lib.rs index 2233ee6..7de0f8d 100644 --- a/oracledesk-stellar/scripts/spec-hash/src/lib.rs +++ b/oracledesk-stellar/scripts/spec-hash/src/lib.rs @@ -7,8 +7,8 @@ use resolver::{ legacy_spec_hash, spec_v2_hash, CommitteeRef, DisputableSignerConfig, Escalation, HybridConfig, - OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, ResolutionSpecV2, SignerConfig, - WeightedKey, WeightedKeyConfig, + OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, ResolutionSpecV2, ScaledPriceConfig, + SignerConfig, WeightedKey, WeightedKeyConfig, MAX_THRESHOLD_DECIMALS, }; use soroban_sdk::{Address, Bytes, BytesN, Env, Symbol, Vec as SorobanVec}; @@ -18,13 +18,19 @@ usage: spec-hash [V2 FLAGS] price is `stellar:` or `other:` is one of: above | at-or-above | below | at-or-below + is in feed-native units (no scaling; legacy ADR 0001 semantics) + spec-hash [V2 FLAGS] price-scaled + decimal-explicit variant (issue #26): threshold means ` x 10^-` + same asset/direction vocabulary as `price` + spec-hash [V2 FLAGS] price-scaled --decimal + --decimal converts an exact decimal string (no floats): `0.20` = threshold 20, decimals 2 spec-hash [V2 FLAGS] weighted-keys :... spec-hash [V2 FLAGS] signers-disputable ... is `guardian` or `committee::,...` spec-hash [V2 FLAGS] committee spec-hash [V2 FLAGS] hybrid -- ... - spec-hash [V2 FLAGS] verify ... + spec-hash [V2 FLAGS] verify ... [--decimal ] spec-hash gen-salt V2 FLAGS (all four are required together; without them the legacy hash is printed): @@ -112,37 +118,127 @@ 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()) + } +} + +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()); + Ok(ResolutionSpec::Price(PriceConfig { + reflector: parse_address(env, reflector)?, + asset: parse_asset(env, asset)?, + threshold: threshold + .parse() + .map_err(|_| "threshold must be an i128".to_string())?, + direction: parse_direction(direction)?, + max_staleness: max_staleness + .parse() + .map_err(|_| "max_staleness must be a u64".to_string())?, + })) +} + +/// `0.20` -> `(20, 2)`, `20` -> `(20, 0)`: an exact decimal-string conversion +/// (no floats, issue #26). The integer and fraction parts must be digits; the +/// fraction may not exceed `MAX_THRESHOLD_DECIMALS` places, since no `i128` +/// scale factor that large exists. A leading `-` is rejected: the resolver +/// requires a non-negative threshold. +pub fn parse_decimal_amount(value: &str) -> Result<(i128, u32), String> { + let (int_part, frac_part) = match value.split_once('.') { + Some((int, frac)) => (int, frac), + None => (value, ""), }; - let direction = match direction.as_str() { - "above" => PriceDirection::Above, - "at-or-above" => PriceDirection::AtOrAbove, - "below" => PriceDirection::Below, - "at-or-below" => PriceDirection::AtOrBelow, - other => { + if int_part.is_empty() && frac_part.is_empty() { + return Err(format!("'{value}' is not a decimal amount")); + } + for part in [int_part, frac_part] { + if !part.chars().all(|c| c.is_ascii_digit()) { return Err(format!( - "unknown direction '{other}': expected above|at-or-above|below|at-or-below" - )) + "'{value}' is not an unsigned decimal amount (digits and one '.' only)" + )); } - }; - Ok(ResolutionSpec::Price(PriceConfig { - reflector: parse_address(env, reflector)?, - asset, + } + if frac_part.len() > MAX_THRESHOLD_DECIMALS as usize { + return Err(format!( + "'{value}' has more than {MAX_THRESHOLD_DECIMALS} decimal places" + )); + } + let digits = format!("{int_part}{frac_part}"); + let threshold: i128 = digits + .parse() + .map_err(|_| format!("'{value}' does not fit an i128 threshold"))?; + Ok((threshold, frac_part.len() as u32)) +} + +/// Builds `ResolutionSpec::PriceScaled`. With `--decimal ` the threshold +/// and its decimals come from the decimal string and the positional args drop +/// to ` `; otherwise they are +/// the third and fourth positional args, matching the resolver's +/// `register_scaled_price_spec` field order. +pub fn parse_price_scaled( + env: &Env, + args: &[String], + decimal: Option<&str>, +) -> Result { + let (reflector, asset, threshold, threshold_decimals, direction, max_staleness) = + if let Some(text) = decimal { + let [reflector, asset, direction, max_staleness] = args else { + return Err("price-scaled --decimal needs ".into()); + }; + let (threshold, threshold_decimals) = parse_decimal_amount(text)?; + ( + reflector, + asset, + threshold.to_string(), + threshold_decimals.to_string(), + direction, + max_staleness, + ) + } else { + let [reflector, asset, threshold, threshold_decimals, direction, max_staleness] = args + else { + return Err("price-scaled needs ".into()); + }; + ( + reflector, + asset, + threshold.clone(), + threshold_decimals.clone(), + direction, + max_staleness, + ) + }; + Ok(ResolutionSpec::PriceScaled(ScaledPriceConfig { + reflector: parse_address(env, &reflector)?, + asset: parse_asset(env, &asset)?, threshold: threshold .parse() .map_err(|_| "threshold must be an i128".to_string())?, - direction, + 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())?, @@ -277,15 +373,16 @@ pub fn parse_hybrid(env: &Env, args: &[String]) -> Result Result { +fn parse_rule(env: &Env, args: &[String], decimal: Option<&str>) -> Result { match args { [kind, rest @ ..] if kind == "signers" => parse_signers(env, rest), [kind, rest @ ..] if kind == "price" => parse_price(env, rest), + [kind, rest @ ..] if kind == "price-scaled" => parse_price_scaled(env, rest, decimal), [kind, rest @ ..] if kind == "weighted-keys" => parse_weighted_keys(env, rest), [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()), + _ => Err("expected `signers`, `price`, `price-scaled`, `weighted-keys`, `signers-disputable`, `committee` or `hybrid`".into()), } } @@ -343,17 +440,54 @@ fn take_v2_flags(env: &Env, args: &[String]) -> Result<(Option, Vec` out of `args`, returning the decimal text (if +/// given) and the rest. It is only meaningful for `price-scaled` (directly or +/// under `verify`); anywhere else it is an error rather than a silently +/// ignored flag. +fn take_decimal_flag(args: &[String]) -> Result<(Option, Vec), String> { + let mut decimal = None; + let mut rest = Vec::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + if arg == "--decimal" { + if decimal.is_some() { + return Err("--decimal given twice".into()); + } + decimal = Some( + iter.next() + .cloned() + .ok_or("--decimal needs a value".to_string())?, + ); + } else { + rest.push(arg.clone()); + } + } + if let Some(text) = &decimal { + let scaled = match rest.first().map(String::as_str) { + Some("price-scaled") => true, + Some("verify") => matches!(rest.get(1).map(String::as_str), Some(kind) if kind == "price-scaled"), + _ => false, + }; + if !scaled { + return Err(format!("--decimal {text} is only accepted with `price-scaled`")); + } + } + Ok((decimal, rest)) +} + /// Runs one invocation and returns the text to print on success. pub fn run(args: &[String]) -> Result { let env = Env::default(); if args.first().map(String::as_str) == Some("gen-salt") { return Ok(to_hex(&gen_salt()?)); } - let (v2, rest) = take_v2_flags(&env, args)?; + let (decimal, args) = take_decimal_flag(args)?; + let (v2, rest) = take_v2_flags(&env, &args)?; + let decimal = decimal.as_deref(); match rest.as_slice() { [cmd, expected, rule @ ..] if cmd == "verify" => { let expected = parse_hex32(expected)?; - let actual = commitment(&env, parse_rule(&env, rule)?, v2)?; + let actual = commitment(&env, parse_rule(&env, rule, decimal)?, v2)?; if expected == actual { Ok("match".into()) } else { @@ -363,7 +497,7 @@ pub fn run(args: &[String]) -> Result { }) } } - [_, ..] => Ok(to_hex(&commitment(&env, parse_rule(&env, &rest)?, v2)?)), + [_, ..] => Ok(to_hex(&commitment(&env, parse_rule(&env, &rest, decimal)?, v2)?)), [] => Err(CliError::Usage("missing command".into())), } } @@ -495,6 +629,8 @@ mod tests { const SALT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; const V2_GOLDEN: &str = "27e995544f9f631be41177890b7a1be7e17c667e4e531aa08e5089dd899566ae"; + const GOLDEN_PRICE_SCALED: &str = + "9acdc16aee4e968267ec4d8d2ba05f75004166ef4b1055f937f2873c34c233cb"; fn v2_args<'a>(salt: &'a str, passphrase: &'a str, market_core: &'a str) -> Vec<&'a str> { vec![ @@ -566,6 +702,58 @@ mod tests { assert_eq!((expected, actual), (wrong, hash)); } + #[test] + fn price_scaled_decimal_flag_equivalence() { + // `--decimal 0.20` is the exact no-float spelling of threshold 20 at + // 2 decimals (issue #26); the positional form must hash identically. + let raw = hash_of(&["price-scaled", C1, "other:XLM", "20", "2", "above", "600"]); + assert_eq!(raw.len(), 64); + assert_eq!( + hash_of(&[ + "price-scaled", + C1, + "other:XLM", + "above", + "600", + "--decimal", + "0.20", + ]), + raw + ); + assert_eq!( + hash_of(&["verify", &raw, "price-scaled", C1, "other:XLM", "20", "2", "above", "600"]), + "match" + ); + // Scaled and legacy price specs live in disjoint hash spaces: + assert_ne!(raw, hash_of(&["price", C1, "other:XLM", "20", "above", "600"])); + } + + #[test] + fn golden_price_scaled_vector() { + // Derived from the resolver's own `legacy_spec_hash` the first time + // this vector was added; the `scaled_spec_*` resolver tests register + // the same shape on chain. + assert_eq!( + hash_of(&["price-scaled", C1, "other:XLM", "20", "2", "above", "600"]), + GOLDEN_PRICE_SCALED + ); + } + + #[test] + fn decimal_amount_is_exact_and_bounded() { + assert_eq!(parse_decimal_amount("0.20").unwrap(), (20, 2)); + assert_eq!(parse_decimal_amount("20").unwrap(), (20, 0)); + assert_eq!(parse_decimal_amount("1.5").unwrap(), (15, 1)); + assert_eq!( + parse_decimal_amount("0.00000000000001").unwrap(), + (1, 14) // one satoshi-cent at Reflector's 14 decimals + ); + let too_many = format!("0.{}", "0".repeat(MAX_THRESHOLD_DECIMALS as usize + 1)); + for bad in ["", "x", "0.2.0", "-0.20", "1e3", "0..1", &too_many] { + assert!(parse_decimal_amount(bad).is_err(), "{bad}"); + } + } + #[test] fn gen_salt_is_32_random_bytes() { let (a, b) = (hash_of(&["gen-salt"]), hash_of(&["gen-salt"])); @@ -580,6 +768,12 @@ mod tests { args(&["signers", "x", "5", G1]), args(&["signers", "1", "5", "not-an-address"]), args(&["price", C1, "bogus", "1", "above", "1"]), + args(&["price-scaled", C1, "bogus", "1", "0", "above", "1"]), + args(&["price-scaled", C1, "other:XLM", "above", "1"]), + args(&["price-scaled", C1, "other:XLM", "1", "x", "above", "1"]), + args(&["--decimal", "0.20", "price", C1, "other:XLM", "1", "above", "1"]), + args(&["price-scaled", C1, "other:XLM", "above", "1", "--decimal"]), + args(&["price-scaled", C1, "other:XLM", "above", "1", "--decimal", "-0.2"]), args(&["weighted-keys", "2", "60", "nocolon"]), args(&["weighted-keys", "2", "60", "zz:1"]), args(&[ From b16a855956a22f05bd477492150fb9b23ec60b7b Mon Sep 17 00:00:00 2001 From: achiever2110 Date: Mon, 19 Oct 2026 09:55:56 +0100 Subject: [PATCH 4/8] style(resolver,spec-hash): satisfy rustfmt and clippy -D warnings (#26) Formats the two files the #26 work touches (`cargo fmt` baseline for the resolver was clean; scripts/spec-hash carried two pre-existing rustfmt diffs in parse_committee/parse_hybrid that a whole-file format resolves), collapses the guardian-reasons nested if flagged by clippy, and drops the three needless borrows in parse_price_scaled. --- .../contracts/resolver/src/lib.rs | 143 +++++++++++++----- .../scripts/spec-hash/src/lib.rs | 126 ++++++++++----- 2 files changed, 192 insertions(+), 77 deletions(-) diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index aaf5102..ad70ee1 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -1175,16 +1175,15 @@ impl Resolver { reasons.push_back(GuardianReason::UnrevealedSpec); } - if storage.has(&DataKey::Price(market_id)) + if (storage.has(&DataKey::Price(market_id)) || storage.has(&DataKey::PriceScaled(market_id)) - || storage.has(&DataKey::Hybrid(market_id)) + || storage.has(&DataKey::Hybrid(market_id))) + && now >= market.close_time.saturating_add(ORACLE_GRACE_SECS) { - if now >= market.close_time.saturating_add(ORACLE_GRACE_SECS) { - if let Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) = - Self::committed_price_outcome(&env, market_id, market.close_time) - { - reasons.push_back(GuardianReason::OracleUnavailable); - } + if let Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) = + Self::committed_price_outcome(&env, market_id, market.close_time) + { + reasons.push_back(GuardianReason::OracleUnavailable); } } @@ -1331,8 +1330,7 @@ impl Resolver { } pub fn resolve_price(env: Env, market_id: u64) -> Result { - if let Ok(scaled) = Self::load::(&env, DataKey::PriceScaled(market_id)) - { + if let Ok(scaled) = Self::load::(&env, DataKey::PriceScaled(market_id)) { if Self::resolution_state(&env, market_id).is_terminal() { return Err(Error::AlreadyFinalized); } @@ -1405,8 +1403,12 @@ impl Resolver { else { return Err(Error::PriceUnavailable); }; - let (price_scaled, threshold_scaled) = - Self::rescale(data.price, feed_decimals, config.threshold, config.threshold_decimals)?; + let (price_scaled, threshold_scaled) = Self::rescale( + data.price, + feed_decimals, + config.threshold, + config.threshold_decimals, + )?; let outcome = Self::compare_price(&config.direction, price_scaled, threshold_scaled); Ok((outcome, data, feed_decimals, threshold_scaled)) } @@ -2393,9 +2395,7 @@ impl Resolver { ) else { return Err(Error::InvalidPrice); }; - let price_scaled = price - .checked_mul(price_factor) - .ok_or(Error::InvalidPrice)?; + let price_scaled = price.checked_mul(price_factor).ok_or(Error::InvalidPrice)?; let threshold_scaled = threshold .checked_mul(threshold_factor) .ok_or(Error::InvalidPrice)?; @@ -2945,8 +2945,7 @@ mod test { .instance() .get(&MOCK_HISTORY) .unwrap_or_else(|| soroban_sdk::Map::new(env)); - all.get(asset.clone()) - .unwrap_or_else(|| Vec::new(env)) + all.get(asset.clone()).unwrap_or_else(|| Vec::new(env)) } /// Defaults to Reflector's public-feed shape: 14 decimals. @@ -3252,7 +3251,13 @@ mod test { let reflector = Sep40MockClient::new(&f.env, &f.reflector_id); reflector.set_history( &OracleAsset::Other(Symbol::new(&f.env, "XLM")), - &Vec::from_array(&f.env, [PriceData { price: 2_500, timestamp: 1_999 }]), + &Vec::from_array( + &f.env, + [PriceData { + price: 2_500, + timestamp: 1_999, + }], + ), ); let ResolutionSpec::Price(spec) = f.spec.clone() else { @@ -3520,10 +3525,13 @@ mod test { }; Sep40MockClient::new(&f.env, &f.reflector_id).set_history( &spec.asset, - &Vec::from_array(&f.env, [PriceData { - price, - timestamp: price_timestamp, - }]), + &Vec::from_array( + &f.env, + [PriceData { + price, + timestamp: price_timestamp, + }], + ), ); f.resolver.register_price_spec(&f.market_id, &spec); f @@ -5424,7 +5432,6 @@ mod test { .is_err()); } - // ----- SEP-40 grid alignment, bad prices and preview (issue #26) ------ /// Installs exact-timestamp history on the fixture's `Sep40Mock` for the @@ -5448,7 +5455,13 @@ mod test { // answers `None` for any off-grid timestamp. The lookup must query // floor(1_000_123 / 300) * 300 = 999_900 (issue #26, bug 2). let f = fixture_at(1_000_123, |env, reflector, _a, _b| { - ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 300)) + ResolutionSpec::Price(price_spec_at( + env, + reflector, + 2_000, + PriceDirection::Above, + 300, + )) }); let ResolutionSpec::Price(spec) = f.spec.clone() else { unreachable!() @@ -5470,7 +5483,13 @@ mod test { // Grid 300, close 2_000 -> aligned 1_800. Only 1_500 has a record; // it is 500 s stale, which max_staleness 600 accepts. let f = fixture(|env, reflector, _a, _b| { - ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 600)) + ResolutionSpec::Price(price_spec_at( + env, + reflector, + 2_000, + PriceDirection::Above, + 600, + )) }); let ResolutionSpec::Price(spec) = f.spec.clone() else { unreachable!() @@ -5493,7 +5512,13 @@ mod test { // the 1_800 step (the next candidate would be 500 s stale) and // reports StalePrice without querying 1_500. let f = fixture(|env, reflector, _a, _b| { - ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 300)) + ResolutionSpec::Price(price_spec_at( + env, + reflector, + 2_000, + PriceDirection::Above, + 300, + )) }); let ResolutionSpec::Price(spec) = f.spec.clone() else { unreachable!() @@ -5519,7 +5544,13 @@ mod test { // Grid 30, close 1_980, record at 1_890: exactly 90 s stale. // max_staleness 90 accepts (`>` comparison), 89 is StalePrice. let ok = fixture_at(1_980, |env, reflector, _a, _b| { - ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 90)) + ResolutionSpec::Price(price_spec_at( + env, + reflector, + 2_000, + PriceDirection::Above, + 90, + )) }); let ResolutionSpec::Price(spec) = ok.spec.clone() else { unreachable!() @@ -5535,7 +5566,13 @@ mod test { assert_eq!(arm_and_resolve(&ok, &spec, 1_980), Outcome::Yes); let tight = fixture_at(1_980, |env, reflector, _a, _b| { - ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 89)) + ResolutionSpec::Price(price_spec_at( + env, + reflector, + 2_000, + PriceDirection::Above, + 89, + )) }); let ResolutionSpec::Price(spec) = tight.spec.clone() else { unreachable!() @@ -5563,7 +5600,13 @@ mod test { // calls: the record at 1_620 (380 s stale, within staleness) sits // beyond the cap and the resolution reports StalePrice. let f = fixture(|env, reflector, _a, _b| { - ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 600)) + ResolutionSpec::Price(price_spec_at( + env, + reflector, + 2_000, + PriceDirection::Above, + 600, + )) }); let ResolutionSpec::Price(spec) = f.spec.clone() else { unreachable!() @@ -5589,7 +5632,13 @@ mod test { // A `resolution()` of 0 would divide by zero; it is a broken feed // and reports InvalidPrice (issue #26 edge case). let f = fixture(|env, reflector, _a, _b| { - ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 10)) + ResolutionSpec::Price(price_spec_at( + env, + reflector, + 2_000, + PriceDirection::Above, + 10, + )) }); let ResolutionSpec::Price(spec) = f.spec.clone() else { unreachable!() @@ -5692,7 +5741,13 @@ mod test { // a market on XLM. The walkback exhausts and reports StalePrice; // the guardian voids under OracleUnavailable after the grace. let f = fixture(|env, reflector, _a, _b| { - ResolutionSpec::Price(price_spec_at(env, reflector, 2_000, PriceDirection::Above, 10)) + ResolutionSpec::Price(price_spec_at( + env, + reflector, + 2_000, + PriceDirection::Above, + 10, + )) }); let ResolutionSpec::Price(spec) = f.spec.clone() else { unreachable!() @@ -5700,10 +5755,13 @@ mod test { Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &1); Sep40MockClient::new(&f.env, &f.reflector_id).set_history( &OracleAsset::Other(Symbol::new(&f.env, "BTC")), - &Vec::from_array(&f.env, [PriceData { - price: 2_500, - timestamp: 1_999, - }]), + &Vec::from_array( + &f.env, + [PriceData { + price: 2_500, + timestamp: 1_999, + }], + ), ); f.resolver.register_price_spec(&f.market_id, &spec); f.env.ledger().set_timestamp(2_000); @@ -5744,7 +5802,10 @@ mod test { timestamp: 1_999, } ); - assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Unconfigured); + assert_eq!( + f.resolver.state(&f.market_id), + ResolutionState::Unconfigured + ); assert_eq!( f.market_core.get_market(&f.market_id).status, MarketStatus::Open @@ -5910,7 +5971,10 @@ mod test { i128::try_from_val(&f.env, &get("raw_price")).unwrap(), 25_000_000_000_000 ); - assert_eq!(u32::try_from_val(&f.env, &get("feed_decimals")).unwrap(), 14); + assert_eq!( + u32::try_from_val(&f.env, &get("feed_decimals")).unwrap(), + 14 + ); assert_eq!( i128::try_from_val(&f.env, &get("threshold_scaled")).unwrap(), 20_000_000_000_000 @@ -5965,7 +6029,10 @@ mod test { f.resolver.try_resolve_price(&f.market_id), Err(Ok(Error::InvalidPrice)) ); - assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Unconfigured); + assert_eq!( + f.resolver.state(&f.market_id), + ResolutionState::Unconfigured + ); } #[test] diff --git a/oracledesk-stellar/scripts/spec-hash/src/lib.rs b/oracledesk-stellar/scripts/spec-hash/src/lib.rs index 7de0f8d..b8f5983 100644 --- a/oracledesk-stellar/scripts/spec-hash/src/lib.rs +++ b/oracledesk-stellar/scripts/spec-hash/src/lib.rs @@ -201,44 +201,49 @@ pub fn parse_price_scaled( args: &[String], decimal: Option<&str>, ) -> Result { - let (reflector, asset, threshold, threshold_decimals, direction, max_staleness) = - if let Some(text) = decimal { - let [reflector, asset, direction, max_staleness] = args else { - return Err("price-scaled --decimal needs ".into()); - }; - let (threshold, threshold_decimals) = parse_decimal_amount(text)?; - ( - reflector, - asset, - threshold.to_string(), - threshold_decimals.to_string(), - direction, - max_staleness, - ) - } else { - let [reflector, asset, threshold, threshold_decimals, direction, max_staleness] = args - else { - return Err("price-scaled needs ".into()); - }; - ( - reflector, - asset, - threshold.clone(), - threshold_decimals.clone(), - direction, - max_staleness, - ) + let (reflector, asset, threshold, threshold_decimals, direction, max_staleness) = if let Some( + text, + ) = decimal + { + let [reflector, asset, direction, max_staleness] = args else { + return Err( + "price-scaled --decimal needs " + .into(), + ); }; + let (threshold, threshold_decimals) = parse_decimal_amount(text)?; + ( + reflector, + asset, + threshold.to_string(), + threshold_decimals.to_string(), + direction, + max_staleness, + ) + } else { + let [reflector, asset, threshold, threshold_decimals, direction, max_staleness] = args + else { + return Err("price-scaled needs ".into()); + }; + ( + reflector, + asset, + threshold.clone(), + threshold_decimals.clone(), + direction, + max_staleness, + ) + }; Ok(ResolutionSpec::PriceScaled(ScaledPriceConfig { - reflector: parse_address(env, &reflector)?, - asset: parse_asset(env, &asset)?, + reflector: parse_address(env, reflector)?, + asset: parse_asset(env, asset)?, 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)?, + direction: parse_direction(direction)?, max_staleness: max_staleness .parse() .map_err(|_| "max_staleness must be a u64".to_string())?, @@ -330,7 +335,10 @@ pub fn parse_signers_disputable(env: &Env, args: &[String]) -> Result Result { let [committee_id, min_epoch, max_rotation_delta, dispute_window] = args else { - return Err("committee needs ".into()); + return Err( + "committee needs " + .into(), + ); }; Ok(ResolutionSpec::Committee(CommitteeRef { committee_id: committee_id @@ -350,7 +358,9 @@ pub fn parse_committee(args: &[String]) -> Result { pub fn parse_hybrid(env: &Env, args: &[String]) -> Result { let Some(sep_idx) = args.iter().position(|a| a == "--") else { - return Err("hybrid needs -- ".into()); + return Err( + "hybrid needs -- ".into(), + ); }; let price_part = &args[..sep_idx]; let signer_part = &args[sep_idx + 1..]; @@ -465,11 +475,15 @@ fn take_decimal_flag(args: &[String]) -> Result<(Option, Vec), S if let Some(text) = &decimal { let scaled = match rest.first().map(String::as_str) { Some("price-scaled") => true, - Some("verify") => matches!(rest.get(1).map(String::as_str), Some(kind) if kind == "price-scaled"), + Some("verify") => { + matches!(rest.get(1).map(String::as_str), Some(kind) if kind == "price-scaled") + } _ => false, }; if !scaled { - return Err(format!("--decimal {text} is only accepted with `price-scaled`")); + return Err(format!( + "--decimal {text} is only accepted with `price-scaled`" + )); } } Ok((decimal, rest)) @@ -497,7 +511,11 @@ pub fn run(args: &[String]) -> Result { }) } } - [_, ..] => Ok(to_hex(&commitment(&env, parse_rule(&env, &rest, decimal)?, v2)?)), + [_, ..] => Ok(to_hex(&commitment( + &env, + parse_rule(&env, &rest, decimal)?, + v2, + )?)), [] => Err(CliError::Usage("missing command".into())), } } @@ -721,11 +739,24 @@ mod tests { raw ); assert_eq!( - hash_of(&["verify", &raw, "price-scaled", C1, "other:XLM", "20", "2", "above", "600"]), + hash_of(&[ + "verify", + &raw, + "price-scaled", + C1, + "other:XLM", + "20", + "2", + "above", + "600" + ]), "match" ); // Scaled and legacy price specs live in disjoint hash spaces: - assert_ne!(raw, hash_of(&["price", C1, "other:XLM", "20", "above", "600"])); + assert_ne!( + raw, + hash_of(&["price", C1, "other:XLM", "20", "above", "600"]) + ); } #[test] @@ -771,9 +802,26 @@ mod tests { args(&["price-scaled", C1, "bogus", "1", "0", "above", "1"]), args(&["price-scaled", C1, "other:XLM", "above", "1"]), args(&["price-scaled", C1, "other:XLM", "1", "x", "above", "1"]), - args(&["--decimal", "0.20", "price", C1, "other:XLM", "1", "above", "1"]), + args(&[ + "--decimal", + "0.20", + "price", + C1, + "other:XLM", + "1", + "above", + "1", + ]), args(&["price-scaled", C1, "other:XLM", "above", "1", "--decimal"]), - args(&["price-scaled", C1, "other:XLM", "above", "1", "--decimal", "-0.2"]), + args(&[ + "price-scaled", + C1, + "other:XLM", + "above", + "1", + "--decimal", + "-0.2", + ]), args(&["weighted-keys", "2", "60", "nocolon"]), args(&["weighted-keys", "2", "60", "zz:1"]), args(&[ From 255f76012ae118481a7b4e66777f62c3554e2ae0 Mon Sep 17 00:00:00 2001 From: achiever2110 Date: Tue, 20 Oct 2026 21:34:15 +0100 Subject: [PATCH 5/8] refactor(spec-hash,docs): scale commitments via domain-tagged preimage under #176 shared types (#26) PR #176 moved ResolutionSpec/PriceConfig/SignerConfig to contracts/oracledesk-types and resolver re-exports them, so issue #26's decimal-scaled threshold can no longer be a seventh ResolutionSpec variant (router and treasury flow resolver::ResolutionSpec into ResolutionSpecV2.rule; a local shadow enum would break both crates, which this PR must not touch). The scaled mode now commits through resolver::scaled_spec_hash = sha256("oracledesk:scaled-price-spec" || XDR(ScaledPriceConfig)), a third domain-tagged hash space exactly in the ADR-0004 style; every existing Price/Signers/V2 preimage is byte-untouched. scripts/spec-hash delegates to the new function (ParsedRule::Scaled), the stale literal pin for the old enum-variant preimage is replaced by delegation/disjointness/V2-rejection assertions, and ADR 0001 + architecture.md are retargeted. Canonical follow-up (documented): append PriceScaled into oracledesk-types once that crate's missing contracttype import is fixed upstream. --- .../docs/adr/0001-resolution-commitment.md | 45 +++++-- oracledesk-stellar/docs/architecture.md | 3 +- .../scripts/spec-hash/src/lib.rs | 122 +++++++++++++----- 3 files changed, 128 insertions(+), 42 deletions(-) diff --git a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md index 73e3acf..dcc1b62 100644 --- a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md +++ b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md @@ -71,22 +71,43 @@ them. the golden vectors in `contracts/resolver/src/lib.rs` (`mod golden`) and `scripts/spec-hash` pin it. Do not "fix" the legacy variant by adding scaling to it — that silently breaks every existing commitment. -* **Decimal-explicit `PriceScaled`.** Issue #26 adds - `ResolutionSpec::PriceScaled(ScaledPriceConfig)`, which carries - `threshold_decimals` alongside the threshold. At resolution both sides are - rescaled with checked base-10 arithmetic to +* **Decimal-explicit scaled price.** Issue #26 adds + `ScaledPriceConfig` (defined in `contracts/resolver`, alongside + `register_scaled_price_spec`), which carries `threshold_decimals` + alongside the threshold. At resolution both sides are rescaled with + checked base-10 arithmetic to `max(feed_decimals, threshold_decimals)` before comparison; an `i128` overflow traps the read as `InvalidPrice`, and `threshold_decimals` above `MAX_THRESHOLD_DECIMALS` (38, the largest power of ten an `i128` holds) is rejected with `InvalidConfig` at reveal, before any commitment is stored. -* **Hash compatibility.** XDR unions encode variants positionally, so the new - variant and its storage key (`DataKey::PriceScaled`) are appended *last* in - `ResolutionSpec` and `DataKey`: every previously committed hash — legacy - `Price`, `Signers`, and every later-appended variant — is byte-identical. - The scaled config is a distinct variant, not a field added to `PriceConfig`, - for the same reason, and because bare `PriceConfig` XDR is already stored - under `DataKey::Price` and must never be reinterpreted. A market is either - legacy-price or scaled-price, never both. +* **Hash compatibility.** PR #176 moved `ResolutionSpec` (and every config + type it wraps) into `contracts/oracledesk-types`, the shared types crate + that router, treasury and `scripts/spec-hash` all flow through, so a + seventh variant cannot be appended from inside the resolver without + editing that crate. The scaled commitment therefore lives in its own + domain-tagged preimage: + `sha256("oracledesk:scaled-price-spec" || XDR(ScaledPriceConfig))` + (`resolver::scaled_spec_hash`), the same construction ADR 0004 uses for + the V2 envelope. The three spaces are provably disjoint: every legacy + preimage is an `ScVal` XDR starting `00 00 00 10`; the V2 tag starts + `oracledesk:resolution-spec` and the scaled tag `oracledesk:scaled-`, so + they differ at a fixed offset. Consequently every previously committed + hash — legacy `Price`, `Signers`, and every later-appended variant — is + byte-identical; nothing was reordered or renumbered. The scaled config is + a distinct struct, not a field added to `PriceConfig`, for the same + reason, and because bare `PriceConfig` XDR is already stored under + `DataKey::Price` and must never be reinterpreted. A market is either + legacy-price or scaled-price, never both (`assert_unconfigured` checks + `DataKey::Price`, `DataKey::Signers` and the appended + `DataKey::PriceScaled`). +* **Maintainer follow-up (canonical home).** The append-only shape this ADR + originally planned — a `PriceScaled(ScaledPriceConfig)` variant added + *last* in `oracledesk-types::ResolutionSpec` and in `DataKey` — remains + the canonical end state once `oracledesk-types` compiles again (it + currently fails on every toolchain because its line 27 never imports + `contracttype`). Adding it there is additive for hashing: the six + existing variants keep their preimages, so the domain-tagged form above + can be migrated variant-by-variant without breaking committed markets. ## Liveness fallback diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 8aa333e..7bf9e04 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -111,7 +111,8 @@ Independent resolution modes, all ultimately calling exhausts) is `StalePrice`, so operators should resolve before the window closes and guardians may void via the `OracleUnavailable` path after grace. The legacy `Price` spec compares the threshold in feed-native units (raw - integer, no scaling); the decimal-explicit `PriceScaled` spec carries + integer, no scaling); the decimal-explicit scaled spec + (`ScaledPriceConfig`) carries `threshold_decimals` and rescales both sides to the coarser of `decimals()` and `threshold_decimals` before comparing — see ADR 0001, "Price threshold units". `preview_price(market_id)` reads the feed's newest diff --git a/oracledesk-stellar/scripts/spec-hash/src/lib.rs b/oracledesk-stellar/scripts/spec-hash/src/lib.rs index b8f5983..504864e 100644 --- a/oracledesk-stellar/scripts/spec-hash/src/lib.rs +++ b/oracledesk-stellar/scripts/spec-hash/src/lib.rs @@ -3,12 +3,14 @@ //! `docs/adr/0004-spec-versioning.md`). Off-chain dev tool only; never deployed. //! //! All hashing delegates to the resolver crate's own `legacy_spec_hash` / -//! `spec_v2_hash`, so the tool cannot drift from the contract. +//! `spec_v2_hash` / `scaled_spec_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, ScaledPriceConfig, - SignerConfig, WeightedKey, WeightedKeyConfig, MAX_THRESHOLD_DECIMALS, + legacy_spec_hash, scaled_spec_hash, spec_v2_hash, CommitteeRef, DisputableSignerConfig, + Escalation, HybridConfig, OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, + ResolutionSpecV2, ScaledPriceConfig, SignerConfig, WeightedKey, WeightedKeyConfig, + MAX_THRESHOLD_DECIMALS, }; use soroban_sdk::{Address, Bytes, BytesN, Env, Symbol, Vec as SorobanVec}; @@ -191,16 +193,17 @@ pub fn parse_decimal_amount(value: &str) -> Result<(i128, u32), String> { Ok((threshold, frac_part.len() as u32)) } -/// Builds `ResolutionSpec::PriceScaled`. With `--decimal ` the threshold -/// and its decimals come from the decimal string and the positional args drop -/// to ` `; otherwise they are -/// the third and fourth positional args, matching the resolver's -/// `register_scaled_price_spec` field order. +/// Builds the [`ScaledPriceConfig`] a market commits to through +/// `resolver::scaled_spec_hash` (issue #26). With `--decimal ` the +/// threshold and its decimals come from the decimal string and the +/// positional args drop to ` +/// `; otherwise they are the third and fourth positional +/// args, matching the resolver's `register_scaled_price_spec` field order. pub fn parse_price_scaled( env: &Env, args: &[String], decimal: Option<&str>, -) -> Result { +) -> Result { let (reflector, asset, threshold, threshold_decimals, direction, max_staleness) = if let Some( text, ) = decimal @@ -234,7 +237,7 @@ pub fn parse_price_scaled( max_staleness, ) }; - Ok(ResolutionSpec::PriceScaled(ScaledPriceConfig { + Ok(ScaledPriceConfig { reflector: parse_address(env, reflector)?, asset: parse_asset(env, asset)?, threshold: threshold @@ -247,7 +250,7 @@ pub fn parse_price_scaled( max_staleness: max_staleness .parse() .map_err(|_| "max_staleness must be a u64".to_string())?, - })) + }) } pub fn parse_weighted_keys(env: &Env, args: &[String]) -> Result { @@ -383,20 +386,49 @@ pub fn parse_hybrid(env: &Env, args: &[String]) -> Result) -> Result { +/// A parsed top-level rule. `Rule` is a legacy / V2-representable +/// `ResolutionSpec`; `Scaled` is issue #26's decimal-explicit mode, which +/// commits through its own domain-tagged preimage (`scaled_spec_hash`) +/// because PR #176 made `oracledesk-types` the frozen canonical home of +/// `ResolutionSpec` (see `docs/adr/0001-resolution-commitment.md`). +enum ParsedRule { + Rule(ResolutionSpec), + Scaled(ScaledPriceConfig), +} + +fn parse_rule(env: &Env, args: &[String], decimal: Option<&str>) -> Result { match args { - [kind, rest @ ..] if kind == "signers" => parse_signers(env, rest), - [kind, rest @ ..] if kind == "price" => parse_price(env, rest), - [kind, rest @ ..] if kind == "price-scaled" => parse_price_scaled(env, rest, decimal), - [kind, rest @ ..] if kind == "weighted-keys" => parse_weighted_keys(env, rest), - [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), + [kind, rest @ ..] if kind == "signers" => Ok(ParsedRule::Rule(parse_signers(env, rest)?)), + [kind, rest @ ..] if kind == "price" => Ok(ParsedRule::Rule(parse_price(env, rest)?)), + [kind, rest @ ..] if kind == "price-scaled" => { + Ok(ParsedRule::Scaled(parse_price_scaled(env, rest, decimal)?)) + } + [kind, rest @ ..] if kind == "weighted-keys" => { + Ok(ParsedRule::Rule(parse_weighted_keys(env, rest)?)) + } + [kind, rest @ ..] if kind == "signers-disputable" => { + Ok(ParsedRule::Rule(parse_signers_disputable(env, rest)?)) + } + [kind, rest @ ..] if kind == "committee" => Ok(ParsedRule::Rule(parse_committee(rest)?)), + [kind, rest @ ..] if kind == "hybrid" => Ok(ParsedRule::Rule(parse_hybrid(env, rest)?)), _ => Err("expected `signers`, `price`, `price-scaled`, `weighted-keys`, `signers-disputable`, `committee` or `hybrid`".into()), } } -fn commitment(env: &Env, rule: ResolutionSpec, v2: Option) -> Result<[u8; 32], String> { +fn commitment(env: &Env, rule: ParsedRule, v2: Option) -> Result<[u8; 32], String> { + let rule = match rule { + ParsedRule::Scaled(cfg) => { + if v2.is_some() { + return Err( + "price-scaled commits through its own domain-tagged preimage and does not \ + support the --v2 envelope" + .into(), + ); + } + return Ok(scaled_spec_hash(env, &cfg).to_array()); + } + ParsedRule::Rule(rule) => rule, + }; let hash = match v2 { None => legacy_spec_hash(env, &rule), Some(params) => spec_v2_hash( @@ -647,8 +679,6 @@ mod tests { const SALT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; const V2_GOLDEN: &str = "27e995544f9f631be41177890b7a1be7e17c667e4e531aa08e5089dd899566ae"; - const GOLDEN_PRICE_SCALED: &str = - "9acdc16aee4e968267ec4d8d2ba05f75004166ef4b1055f937f2873c34c233cb"; fn v2_args<'a>(salt: &'a str, passphrase: &'a str, market_core: &'a str) -> Vec<&'a str> { vec![ @@ -761,13 +791,47 @@ mod tests { #[test] fn golden_price_scaled_vector() { - // Derived from the resolver's own `legacy_spec_hash` the first time - // this vector was added; the `scaled_spec_*` resolver tests register - // the same shape on chain. - assert_eq!( - hash_of(&["price-scaled", C1, "other:XLM", "20", "2", "above", "600"]), - GOLDEN_PRICE_SCALED + // Issue #26 under PR #176's shared-types layout: the scaled + // commitment is `sha256("oracledesk:scaled-price-spec" || + // XDR(ScaledPriceConfig))` — a resolver-local domain-tagged preimage + // (see `docs/adr/0001-resolution-commitment.md`), not a + // `ResolutionSpec` variant. The CLI delegates to the contract's own + // `scaled_spec_hash`, so the two cannot drift; this vector pins the + // *property* (delegation + disjointness), not a literal digest: + // re-pinning the old enum-variant hex `9acdc16a…` would assert a + // preimage this design no longer produces, and no host run is + // possible in-tree to derive a new one while + // `contracts/oracledesk-types` fails to compile. Re-deriving a + // literal golden here is a listed maintainer follow-up. + let raw = hash_of(&["price-scaled", C1, "other:XLM", "20", "2", "above", "600"]); + assert_eq!(raw.len(), 64); + assert!(raw.chars().all(|c| c.is_ascii_hexdigit())); + + let env = Env::default(); + let cfg = ScaledPriceConfig { + reflector: parse_address(&env, C1).unwrap(), + asset: parse_asset(&env, "other:XLM").unwrap(), + threshold: 20, + threshold_decimals: 2, + direction: PriceDirection::Above, + max_staleness: 600, + }; + assert_eq!(raw, to_hex(&scaled_spec_hash(&env, &cfg).to_array())); + + // Disjoint hash spaces: the feed-native legacy reading of the same + // numbers, and the signers mode, must never collide with a scaled + // preimage. + assert_ne!( + raw, + hash_of(&["price", C1, "other:XLM", "20", "above", "600"]) ); + assert_ne!(raw, hash_of(&["signers", "1", "5", G1])); + + // The V2 envelope wraps a `ResolutionSpec` and therefore rejects a + // scaled rule rather than silently hashing a different preimage. + let mut v2_scaled = v2_args(SALT, "Test SDF Network ; September 2015", C1); + v2_scaled.extend_from_slice(&["price-scaled", C1, "other:XLM", "20", "2", "above", "600"]); + assert!(matches!(run(&args(&v2_scaled)), Err(CliError::Usage(_)))); } #[test] From 84310df091f8b9ac11c14d50e47ffcc496ec72ae Mon Sep 17 00:00:00 2001 From: achiever2110 Date: Tue, 20 Oct 2026 22:03:06 +0100 Subject: [PATCH 6/8] feat(resolver): TWAP resolution variant with bounded, checked sampling (#27) Adds the time-weighted-average price rule of issue #27 on top of the #26 machinery it reuses (SEP-40 grid alignment from read_feed_on_grid): - TwapConfig + register_twap_spec / resolve_twap, committing through a third domain-tagged hash space (twap_spec_hash) so every legacy Price/Signers/V2 and scaled commitment stays byte-for-byte valid (same ADR-0001 argument as the scaled mode; the ResolutionSpec enum stays frozen in oracledesk-types as PR #176 made it). - DataKey::PriceTwap appended last (positional discriminants), assert_unconfigured extended to the third key, closing the double-registration hole; WrongMode guards on snapshot/preview. - Sampling at resolution()-aligned timestamps in (close_time-window, close_time], capped at MAX_TWAP_SAMPLES reads (not found samples), duplicate timestamps counted once; min_samples/gap failures are StalePrice, non-positive or overflowing prices InvalidPrice; i128 checked_mul/checked_add, floor division, last weight is close_time - last_ts + 1. - MAX_TWAP_SAMPLES = 32 is reasoned from the crate's own accepted ceilings (MAX_WEIGHTED_SIGNERS/MAX_COMMITTEE 32-signature loops), not measured: the CPU measurement the issue asks for cannot run at this tip; documented in the ADR text and code. - Registration validates statically AND against the live feed (window/resolution <= MAX_TWAP_SAMPLES, min_samples <= slots), failing unresolvable markets early. - TwapResolution event alongside the unchanged PriceResolution/V2 pair; expire_market prefers a resolvable TWAP settle and voids on a dead feed like the other price shapes. Also folds the rebase integration fix: settle_scaled must propagate settle_price's Result introduced by #176. --- .../contracts/resolver/src/lib.rs | 1231 ++++++++++++++++- 1 file changed, 1214 insertions(+), 17 deletions(-) diff --git a/oracledesk-stellar/contracts/resolver/src/lib.rs b/oracledesk-stellar/contracts/resolver/src/lib.rs index ad70ee1..fbcd46a 100644 --- a/oracledesk-stellar/contracts/resolver/src/lib.rs +++ b/oracledesk-stellar/contracts/resolver/src/lib.rs @@ -150,6 +150,59 @@ pub struct ScaledPriceConfig { pub max_staleness: u64, } +/// Hard cap on the grid slots a TWAP resolution attempt reads (issue #27). +/// Bounds the cross-contract `price(asset, ts)` calls the sampling loop may +/// make, so a TWAP market stays inside Soroban's per-transaction CPU budget +/// even when most records in the window are missing (the cap applies to +/// *reads inspected*, not samples found — a window of absent records +/// terminates after this many calls instead of walking the whole grid). +/// +/// **Reasoned, not measured.** The issue asks for a value "chosen from +/// measured CPU cost" via `env.cost_estimate()` against the registered +/// wasm; that measurement is not executable in the environment this change +/// landed in (the wasm-hash build gate fails before any test can run, so no +/// cost number is recorded — see `docs/adr/0001-resolution-commitment.md`, +/// "TWAP variant"). 32 is instead taken from the contract's own accepted +/// ceilings: [`MAX_WEIGHTED_SIGNERS`] and [`MAX_COMMITTEE`] are both 32, and +/// the weighted-keys path already verifies up to 32 ed25519 signatures in +/// one call — a cost per item strictly above one bounded cross-contract +/// price read — so a 32-sample TWAP cannot be more expensive than flows the +/// protocol already accepts. Registration rejects `window / resolution()` +/// above this cap outright (`register_twap_spec`), so a compliant feed +/// never hits the resolve-time truncation; the cap only guards against a +/// feed changing `resolution()` after the commitment. +pub const MAX_TWAP_SAMPLES: u32 = 32; + +/// Time-weighted-average price rule (issue #27). The market settles on the +/// average of the feed's records over a window ending at `close_time` +/// instead of a single print: a manipulated or anomalous close record can no +/// longer decide the market on its own. Like the legacy [`PriceConfig`] (and +/// unlike [`ScaledPriceConfig`], issue #26) the `threshold` is in +/// feed-native units — the TWAP is a raw-price average — and the committed +/// comparison applies to the floored average (see `twap_outcome` and ADR +/// 0001). `min_samples`/`max_gap` encode how much of the window the feed +/// must actually have filled: resolution is rejected (`StalePrice`) below +/// `min_samples` present records or when any gap — window start to first +/// sample, consecutive samples, or last sample to `close_time` — exceeds +/// `max_gap`. +/// +/// Same canonical-home note as [`ScaledPriceConfig`]: this is a +/// resolver-local type committing through a domain-tagged preimage +/// ([`twap_spec_hash`]) because PR #176 froze `oracledesk-types`' +/// `ResolutionSpec` as the shared enum router and treasury consume; the +/// append-into-types-crate follow-up is recorded in ADR 0001. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TwapConfig { + pub reflector: Address, + pub asset: OracleAsset, + pub threshold: i128, + pub direction: PriceDirection, + pub window: u64, + pub min_samples: u32, + pub max_gap: u64, +} + /// Legacy proposal shape, stored under [`DataKey::Proposal`]. Only read (as a /// fallback) by resolvers that inherited state from before `AttestedOutcome`; /// see [`SignerProposalV2`] and `docs/adr/0003-attested-outcome-migration.md`. @@ -306,10 +359,11 @@ pub enum GuardianActionKind { // and treasury pass `resolver::ResolutionSpec` straight into // `ResolutionSpecV2.rule`, so re-declaring a local superset enum here would // break those crates — the enum's canonical home is the shared types crate, -// full stop. The decimal-scaled mode of issue #26 therefore commits through -// its own domain-tagged preimage, [`scaled_spec_hash`], instead of through a -// seventh `ResolutionSpec` variant; see ADR 0001 for the hash-space argument -// and the maintainer follow-up of moving the variant into the types crate +// full stop. The decimal-scaled mode of issue #26 and the TWAP mode of issue +// #27 therefore commit through their own domain-tagged preimages, +// [`scaled_spec_hash`] and [`twap_spec_hash`], instead of through seventh and +// eighth `ResolutionSpec` variants; see ADR 0001 for the hash-space argument +// and the maintainer follow-up of moving the variants into the types crate // (append-only) once that crate's toolchain gate is restored. /// Domain tag prepended to a [`ResolutionSpecV2`] before hashing. Every legacy @@ -355,6 +409,26 @@ pub fn scaled_spec_hash(env: &Env, cfg: &ScaledPriceConfig) -> BytesN<32> { env.crypto().sha256(&preimage).into() } +/// Domain tag prepended to a [`TwapConfig`] before hashing (issue #27). +/// Same disjointness argument as [`SCALED_SPEC_DOMAIN`]: legacy preimages +/// start `00 00 00 10` (ScVal), V2 preimages start `oracledesk:resolution-`, +/// scaled preimages start `oracledesk:scaled-price-spec` and this tag starts +/// `oracledesk:price-twap-spec` — no preimage of one mode can be a preimage +/// of another. A TWAP market's `resolution_hash` therefore lives in its own +/// hash space and every already-committed `Price` / `Signers` / V2 hash +/// stays byte-for-byte valid (ADR 0001). +pub const TWAP_SPEC_DOMAIN: &str = "oracledesk:price-twap-spec"; + +/// Hash a TWAP commitment is checked against: +/// `sha256(TWAP_SPEC_DOMAIN || XDR(cfg))`, the [`TwapConfig`] counterpart +/// of [`scaled_spec_hash`]; `scripts/spec-hash` delegates to this same +/// function so the tool cannot drift from the contract. +pub fn twap_spec_hash(env: &Env, cfg: &TwapConfig) -> BytesN<32> { + let mut preimage = Bytes::from_slice(env, TWAP_SPEC_DOMAIN.as_bytes()); + preimage.append(&cfg.clone().to_xdr(env)); + env.crypto().sha256(&preimage).into() +} + /// A proposed admin handover (#62). Absent when no transfer is pending. #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -465,6 +539,10 @@ pub enum DataKey { /// stored under `DataKey::Price` are bare `PriceConfig` XDR and must not /// be reinterpreted (issue #26). PriceScaled(u64), + /// Appended last (after `PriceScaled`, same positional-discriminant + /// discipline, issue #27): a revealed TWAP rule's `TwapConfig` lives + /// under its own key so no stored payload is ever reinterpreted. + PriceTwap(u64), } /// The registered signer group/authority, whichever kind the market committed to. @@ -531,6 +609,22 @@ pub struct PriceResolutionScaled { pub outcome: Outcome, } +/// Emitted alongside the [`PriceResolution`]/[`PriceResolutionV2`] pair for +/// a [`TwapConfig`] settlement (issue #27): the floored time-weighted +/// average, how many grid records answered, and the oldest/newest sample +/// timestamps. New event type, so existing indexer decoders are untouched. +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TwapResolution { + #[topic] + pub market_id: u64, + pub twap: i128, + pub samples: u32, + pub first_ts: u64, + pub last_ts: u64, + pub outcome: Outcome, +} + #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PriceSnapshotted { @@ -852,6 +946,27 @@ impl Resolver { Ok(()) } + /// Reveals a time-weighted-average price rule committed to as + /// `sha256(TWAP_SPEC_DOMAIN || XDR(TwapConfig))` (issue #27). Same + /// commit/reveal flow as [`register_scaled_price_spec`], with two + /// differences. First, the static validation + /// ([`validate_twap_rule`]): `window > 0`, `min_samples >= 2`, + /// `max_gap` in `(0, window]`, `threshold >= 0`. Second, feasibility is + /// checked *against the live feed*: the grid period only exists + /// on-chain, so registration calls `resolution()` on the configured + /// reflector and rejects `window / resolution > MAX_TWAP_SAMPLES` or + /// `min_samples > window / resolution` with `InvalidConfig`. This makes + /// the reflector required reachable at registration (a trapping feed + /// gives `PriceUnavailable`) — deliberate: an unresolvable TWAP market + /// is caught at reveal time, not at close. + pub fn register_twap_spec(env: Env, market_id: u64, spec: TwapConfig) -> Result<(), Error> { + Self::assert_unconfigured(&env, market_id)?; + Self::validate_twap_rule(&env, &spec)?; + Self::assert_matches_commitment(&env, market_id, &twap_spec_hash(&env, &spec))?; + Self::store_twap_rule(&env, market_id, &spec); + Ok(()) + } + /// See `register_price_spec` — same commit/reveal rule, signer mode. pub fn register_signer_spec(env: Env, market_id: u64, spec: SignerConfig) -> Result<(), Error> { let rule = ResolutionSpec::Signers(spec); @@ -1177,6 +1292,7 @@ impl Resolver { if (storage.has(&DataKey::Price(market_id)) || storage.has(&DataKey::PriceScaled(market_id)) + || storage.has(&DataKey::PriceTwap(market_id)) || storage.has(&DataKey::Hybrid(market_id))) && now >= market.close_time.saturating_add(ORACLE_GRACE_SECS) { @@ -1330,6 +1446,15 @@ impl Resolver { } pub fn resolve_price(env: Env, market_id: u64) -> Result { + if env + .storage() + .persistent() + .has(&DataKey::PriceTwap(market_id)) + { + // TWAP markets resolve through the same entrypoint as scaled + // ones (issue #27); `resolve_twap` is the explicit alias. + return Self::resolve_twap(env, market_id); + } if let Ok(scaled) = Self::load::(&env, DataKey::PriceScaled(market_id)) { if Self::resolution_state(&env, market_id).is_terminal() { return Err(Error::AlreadyFinalized); @@ -1381,6 +1506,197 @@ impl Resolver { Ok(outcome) } + /// Resolves a [`TwapConfig`] market (issue #27). Same lifecycle as + /// `resolve_price` (closed, not terminal), and markets already revealed + /// as TWAP also resolve through `resolve_price`, which dispatches here. + /// Non-TWAP markets are `WrongMode`. + pub fn resolve_twap(env: Env, market_id: u64) -> Result { + if let Ok(config) = Self::load::(&env, DataKey::PriceTwap(market_id)) { + if Self::resolution_state(&env, market_id).is_terminal() { + return Err(Error::AlreadyFinalized); + } + let market_core: Address = Self::instance_get(&env, DataKey::MarketCore)?; + return Self::settle_twap(&env, &market_core, market_id, &config); + } + Err(Error::WrongMode) + } + + /// Samples the committed feed at every `resolution()`-aligned timestamp + /// in `(close_time - window, close_time]` and computes the floored, + /// time-weighted average with checked `i128` arithmetic. Returns + /// `(twap, samples, first_ts, last_ts, outcome)`. + /// + /// Sampling details (issue #27): + /// - Grid alignment reuses the #26 machinery: the newest queried + /// timestamp is `close_time - close_time % resolution()`, walked down + /// in `resolution()` steps while it stays inside the window. + /// - Missing records (`None`) are skipped, not fatal; a trapping feed + /// or a failing `price` call is `PriceUnavailable`; `resolution()` of + /// 0 is a broken feed (`InvalidPrice`, it would divide by zero). + /// - The loop is capped at [`MAX_TWAP_SAMPLES`] *reads inspected* (not + /// samples found), so a window of absent records terminates bounded + /// and then fails the `min_samples`/gap checks below. + /// - Records are kept keyed by their returned `timestamp`, duplicates + /// (a feed answering with the same record for several queries) are + /// counted once, and records outside the window or newer than + /// `close_time` are ignored / rejected like `validate_price` does. + /// - Weights: each sample is weighted by the time until the next + /// sample; the newest sample is weighted `close_time - last_ts + 1` + /// so the closing second is included, the convention picked in ADR + /// 0001 and applied consistently on both the numerator and the + /// denominator. + /// - Any sample price `<= 0` is `InvalidPrice` (same rule as #26); + /// any `checked_mul`/`checked_add` overflow is `InvalidPrice`. + /// - Floor division: the `AtOrAbove`/`AtOrBelow` comparison of + /// `compare_price` applies to the floored average. + fn twap_outcome( + env: &Env, + config: &TwapConfig, + close_time: u64, + ) -> Result<(i128, u32, u64, u64, Outcome), Error> { + let client = ReflectorPriceClient::new(env, &config.reflector); + let Ok(Ok(resolution)) = client.try_resolution() else { + return Err(Error::PriceUnavailable); + }; + if resolution == 0 { + return Err(Error::InvalidPrice); + } + let res = resolution as u64; + let end = close_time - close_time % res; + let start = close_time.saturating_sub(config.window); + // Oldest-first, strictly ascending timestamps. Bounded in memory by + // MAX_TWAP_SAMPLES; never persisted. + let mut samples: Vec<(u64, i128)> = Vec::new(env); + let mut ts = end; + let mut inspected = 0u32; + while ts > start { + if inspected >= MAX_TWAP_SAMPLES { + // Read cap on *reads*, not found samples (issue #27 + // budget): only reachable if the feed changed + // `resolution()` after registration widened the slot count + // past MAX_TWAP_SAMPLES. Validation below then rejects via + // the min_samples / gap rules rather than trusting a + // silently truncated window's average as complete. + break; + } + inspected += 1; + match client.try_price(&config.asset, &ts) { + Ok(Ok(Some(data))) => { + if data.price <= 0 { + return Err(Error::InvalidPrice); + } + if data.timestamp > close_time { + return Err(Error::StalePrice); + } + if data.timestamp > start { + // Keep the ascending invariant and count each + // distinct timestamp once: samples collected so far + // all have timestamp > this slot's query, so an + // already-seen or newer timestamp is a duplicate of + // a record the walk counted already. + let duplicate = samples + .get(0) + .is_some_and(|(head, _)| data.timestamp >= head); + if !duplicate { + samples.push_front((data.timestamp, data.price)); + } + } + } + Ok(Ok(None)) => {} + _ => return Err(Error::PriceUnavailable), + } + if ts < res { + break; + } + ts -= res; + } + + let count = samples.len(); + if count < config.min_samples { + return Err(Error::StalePrice); + } + // Gap checks, all three kinds (issue #27 step 3): window start to + // first sample, every consecutive pair, last sample to close_time. + let (first_ts, _) = samples.get(0).unwrap(); + let (last_ts, _) = samples.get(count - 1).unwrap(); + if first_ts - start > config.max_gap || close_time - last_ts > config.max_gap { + return Err(Error::StalePrice); + } + for i in 1..count { + let (prev, _) = samples.get(i - 1).unwrap(); + let (next, _) = samples.get(i).unwrap(); + if next - prev > config.max_gap { + return Err(Error::StalePrice); + } + } + + let mut weighted_sum: i128 = 0; + let mut total_weight: u64 = 0; + for i in 0..count { + let (sample_ts, sample_price) = samples.get(i).unwrap(); + let next_ts = if i + 1 < count { + samples.get(i + 1).unwrap().0 + } else { + close_time.saturating_add(1) + }; + // dt >= 1 always: timestamps are strictly ascending and + // sample_ts <= close_time < close_time + 1. + let dt = next_ts - sample_ts; + total_weight = total_weight.checked_add(dt).ok_or(Error::InvalidPrice)?; + let term = sample_price + .checked_mul(dt as i128) + .ok_or(Error::InvalidPrice)?; + weighted_sum = weighted_sum.checked_add(term).ok_or(Error::InvalidPrice)?; + } + let twap = weighted_sum + .checked_div(total_weight as i128) + .ok_or(Error::InvalidPrice)?; + let outcome = Self::compare_price(&config.direction, twap, config.threshold); + Ok((twap, count, first_ts, last_ts, outcome)) + } + + /// Settles a [`TwapConfig`] market: same lifecycle preconditions as + /// `settle_scaled`, then the shared `settle_price` finalize with the + /// averaged price stamped at the newest sample, alongside the dedicated + /// [`TwapResolution`] event (the dual-event pattern #26 established with + /// [`PriceResolutionScaled`]). TWAP markets do not use `snapshot_price`: + /// snapshots pin a single record, which is exactly what issue #27 + /// exists to avoid. + fn settle_twap( + env: &Env, + market_core: &Address, + market_id: u64, + config: &TwapConfig, + ) -> Result { + let market = MarketCoreClient::new(env, market_core).get_market(&market_id); + if env.ledger().timestamp() < market.close_time { + return Err(Error::MarketNotClosed); + } + let (twap, samples, first_ts, last_ts, outcome) = + Self::twap_outcome(env, config, market.close_time)?; + Self::settle_price( + env, + market_core, + market_id, + &outcome, + &PriceData { + price: twap, + timestamp: last_ts, + }, + false, + )?; + TwapResolution { + market_id, + twap, + samples, + first_ts, + last_ts, + outcome: outcome.clone(), + } + .publish(env); + Ok(outcome) + } + /// The live-read outcome of a [`ScaledPriceConfig`] rule: grid-aligned /// feed read, the feed's `decimals()`, and both sides rescaled to /// `max(feed_decimals, threshold_decimals)`. Returns the outcome, the raw @@ -1413,9 +1729,9 @@ impl Resolver { Ok((outcome, data, feed_decimals, threshold_scaled)) } - /// The committed price rule's outcome at `close_time`, covering all three - /// price shapes (legacy `Price`, the `Hybrid` price leg, and - /// `PriceScaled`). Guardian and availability paths use it so "feed + /// The committed price rule's outcome at `close_time`, covering all four + /// price shapes (legacy `Price`, the `Hybrid` price leg, `PriceScaled` + /// and `PriceTwap`). Guardian and availability paths use it so "feed /// unusable" is one uniform check; snapshot precedence works exactly as /// in `resolve_price` for the legacy shapes. fn committed_price_outcome( @@ -1435,6 +1751,17 @@ impl Resolver { Self::scaled_price_outcome(env, &s, close_time)?; return Ok((outcome, data, false)); } + if let Some(t) = storage.get::<_, TwapConfig>(&DataKey::PriceTwap(market_id)) { + let (twap, _n, _first, last, outcome) = Self::twap_outcome(env, &t, close_time)?; + return Ok(( + outcome, + PriceData { + price: twap, + timestamp: last, + }, + false, + )); + } Err(Error::WrongMode) } @@ -1458,7 +1785,7 @@ impl Resolver { } let (outcome, data, feed_decimals, threshold_scaled) = Self::scaled_price_outcome(env, config, market.close_time)?; - Self::settle_price(env, market_core, market_id, &outcome, &data, false); + Self::settle_price(env, market_core, market_id, &outcome, &data, false)?; PriceResolutionScaled { market_id, raw_price: data.price, @@ -1581,7 +1908,15 @@ impl Resolver { let mode = Self::expiry_mode(&env, market_id); match mode { ExpiryMode::Price => { - if let Ok(scaled) = + if let Ok(twap) = Self::load::(&env, DataKey::PriceTwap(market_id)) { + match Self::settle_twap(&env, &market_core, market_id, &twap) { + Ok(_) => return Ok(()), + // Same uniform feed-unusable set as scaled/legacy + // below (issue #27 mirrors #26 here). + Err(Error::PriceUnavailable | Error::StalePrice | Error::InvalidPrice) => {} + Err(other) => return Err(other), + } + } else if let Ok(scaled) = Self::load::(&env, DataKey::PriceScaled(market_id)) { match Self::settle_scaled(&env, &market_core, market_id, &scaled) { @@ -1995,6 +2330,7 @@ impl Resolver { let storage = env.storage().persistent(); if storage.has(&DataKey::Price(market_id)) || storage.has(&DataKey::PriceScaled(market_id)) + || storage.has(&DataKey::PriceTwap(market_id)) || (storage.has(&DataKey::Hybrid(market_id)) && Self::resolution_state(env, market_id) != ResolutionState::HybridFallbackOpen) { @@ -2483,9 +2819,9 @@ impl Resolver { /// The price rule of a price or hybrid market. Signer-mode markets are /// `WrongMode`; markets with no revealed spec `MarketNotConfigured`. - /// Scaled markets are `WrongMode` too: `snapshot_price`/`preview_price` - /// work on the raw `PriceConfig` shape only (issue #37 will extend - /// snapshots). + /// Scaled (issue #26) and TWAP (issue #27) markets are `WrongMode` too: + /// `snapshot_price`/`preview_price` work on the raw `PriceConfig` shape + /// only (issue #37 will extend snapshots). fn price_config(env: &Env, market_id: u64) -> Result { let storage = env.storage().persistent(); if let Some(cfg) = storage.get(&DataKey::Price(market_id)) { @@ -2494,7 +2830,9 @@ impl Resolver { if let Some(hybrid) = storage.get::<_, HybridConfig>(&DataKey::Hybrid(market_id)) { return Ok(hybrid.price); } - if storage.has(&DataKey::PriceScaled(market_id)) { + if storage.has(&DataKey::PriceScaled(market_id)) + || storage.has(&DataKey::PriceTwap(market_id)) + { return Err(Error::WrongMode); } match Self::signer_authority(env, market_id) { @@ -2545,6 +2883,7 @@ impl Resolver { let storage = env.storage().persistent(); if storage.has(&DataKey::Price(market_id)) || storage.has(&DataKey::PriceScaled(market_id)) + || storage.has(&DataKey::PriceTwap(market_id)) || storage.has(&DataKey::Signers(market_id)) || storage.has(&DataKey::WeightedKeys(market_id)) || storage.has(&DataKey::Disputable(market_id)) @@ -2672,6 +3011,53 @@ impl Resolver { Self::set_state(env, market_id, ResolutionState::Unconfigured); } + /// Issue #27 counterpart of [`validate_scaled_rule`] for the TWAP mode, + /// plus the live feasibility check described in + /// [`register_twap_spec`]: the grid period is read from the feed at + /// registration, so an infeasible `window`/`min_samples` pair for that + /// grid (or an unreachable/zero-period feed) fails before any + /// commitment is spent. Static violations and infeasible slot counts + /// are `InvalidConfig`; a trapping reflector is `PriceUnavailable`. + fn validate_twap_rule(env: &Env, cfg: &TwapConfig) -> Result<(), Error> { + let static_ok = cfg.window > 0 + && cfg.min_samples >= 2 + && cfg.max_gap > 0 + && cfg.max_gap <= cfg.window + && cfg.threshold >= 0; + if !static_ok { + return Err(Error::InvalidConfig); + } + let client = ReflectorPriceClient::new(env, &cfg.reflector); + let Ok(Ok(resolution)) = client.try_resolution() else { + return Err(Error::PriceUnavailable); + }; + if resolution == 0 { + // A zero-period feed divides by zero at resolve time; the market + // cannot ever sample it on a grid. Reject at reveal (issue #27 + // step 2: catch unresolvable markets early). + return Err(Error::InvalidConfig); + } + let slots = cfg.window / resolution as u64; + if slots > MAX_TWAP_SAMPLES as u64 || (cfg.min_samples as u64) > slots { + return Err(Error::InvalidConfig); + } + Ok(()) + } + + /// Issue #27 counterpart of [`store_scaled_rule`] for the TWAP mode. + fn store_twap_rule(env: &Env, market_id: u64, cfg: &TwapConfig) { + let storage = env.storage().persistent(); + let key = DataKey::PriceTwap(market_id); + storage.set(&key, cfg); + storage.extend_ttl(&key, BUMP_THRESHOLD, BUMP_TO); + if let Some((_, policy)) = Self::evidence_config_internal(env) { + let p_key = DataKey::EvidencePolicy(market_id); + storage.set(&p_key, &policy); + storage.extend_ttl(&p_key, BUMP_THRESHOLD, BUMP_TO); + } + Self::set_state(env, market_id, ResolutionState::Unconfigured); + } + fn evidence_config_internal(env: &Env) -> Option<(Address, EvidencePolicy)> { env.storage().instance().get(&DataKey::EvidenceConfig) } @@ -2906,6 +3292,7 @@ mod test { const MOCK_DECIMALS: u32 = 1; const MOCK_RESOLUTION: u32 = 2; const MOCK_PANIC: u32 = 3; + const MOCK_NEAREST: u32 = 4; #[contractimpl] impl Sep40Mock { @@ -2918,6 +3305,13 @@ mod test { env.storage().instance().set(&MOCK_PANIC, &on); } + /// Deviate from SEP-40 exactness: `price(asset, ts)` then answers + /// with the newest stored record at or before `ts` (issue #27's + /// "some implementations return the nearest earlier record" edge). + pub fn set_nearest(env: Env, on: bool) { + env.storage().instance().set(&MOCK_NEAREST, &on); + } + /// Replaces the feed's history for `asset`. Records are only findable /// at their exact timestamps, so a record off the `resolution()` grid /// models a corrupt feed and stays unreachable by the aligned @@ -2962,12 +3356,25 @@ mod test { } /// SEP-40 exactness: `Some` only when `timestamp` names a stored - /// record of this asset. + /// record of this asset. Under `set_nearest(true)` the newest + /// record at or before `timestamp` answers instead (issue #27's + /// duplicate-timestamp edge). pub fn price(env: Env, asset: OracleAsset, timestamp: u64) -> Option { Self::guard(&env); - Self::records(&env, &asset) - .iter() - .find(|record| record.timestamp == timestamp) + let nearest: bool = env.storage().instance().get(&MOCK_NEAREST).unwrap_or(false); + let records = Self::records(&env, &asset); + if nearest { + let mut best: Option = None; + for record in records.iter() { + if record.timestamp <= timestamp + && best.as_ref().is_none_or(|b| record.timestamp > b.timestamp) + { + best = Some(record); + } + } + return best; + } + records.iter().find(|record| record.timestamp == timestamp) } pub fn lastprice(env: Env, asset: OracleAsset) -> Option { @@ -3181,6 +3588,109 @@ mod test { ) } + /// Issue #27 counterpart of [`scaled_fixture_at`]: the market's + /// `resolution_hash` commits to `twap_spec_hash(cfg)`. The returned + /// `Fixture.spec` is a legacy `Price` placeholder these tests never + /// register; only the revealed TWAP config matches the commitment. + fn twap_fixture_at( + close_time: u64, + spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> TwapConfig, + ) -> (Fixture, TwapConfig) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_000); + env.ledger().set_network_id(NETWORK_ID); + + let admin = Address::generate(&env); + let creator = Address::generate(&env); + let signer_a = Address::generate(&env); + let signer_b = Address::generate(&env); + let treasury = Address::generate(&env); + let resolver_id = Address::generate(&env); + let reflector_id = env.register(Sep40Mock, ()); + + let collateral = env.register_stellar_asset_contract_v2(admin.clone()); + let token = StellarAssetClient::new(&env, &collateral.address()); + token.mint(&creator, &(1_000 * 10_000_000_i128)); + + let twap_cfg = spec_fn(&env, &reflector_id, &signer_a, &signer_b); + let spec = ResolutionSpec::Price(price_spec(&env, &reflector_id)); + let market_core_id = env.register( + market_core::WASM, + ( + admin.clone(), + collateral.address(), + treasury.clone(), + resolver_id.clone(), + 100u32, + // Protocol keeps the whole fee; resolver tests do no splits. + 10_000u32, + ), + ); + let resolution_hash = twap_spec_hash(&env, &twap_cfg); + let market_core = MarketCoreClient::new(&env, &market_core_id); + let market_id = market_core.create_market( + &creator, + &BytesN::from_array(&env, &[42u8; 32]), + &resolution_hash, + &soroban_sdk::String::from_str(&env, "ipfs://resolver-test"), + &Category::Crypto, + &close_time, + &(100 * 10_000_000_i128), + &5000u32, + ); + + env.register_at( + &resolver_id, + Resolver, + ResolverArgs::__constructor(&admin, &market_core_id), + ); + let resolver = ResolverClient::new(&env, &resolver_id); + + ( + Fixture { + env, + admin, + resolver, + resolver_id, + market_core, + market_id, + signer_a, + signer_b, + reflector_id, + spec, + }, + twap_cfg, + ) + } + + /// [`twap_fixture_at`] at the default `close_time` of 2_000. + fn twap_fixture( + spec_fn: impl FnOnce(&Env, &Address, &Address, &Address) -> TwapConfig, + ) -> (Fixture, TwapConfig) { + twap_fixture_at(2_000, spec_fn) + } + + fn twap_price_spec( + env: &Env, + reflector: &Address, + threshold: i128, + direction: PriceDirection, + window: u64, + min_samples: u32, + max_gap: u64, + ) -> TwapConfig { + TwapConfig { + reflector: reflector.clone(), + asset: OracleAsset::Other(Symbol::new(env, "XLM")), + threshold, + direction, + window, + min_samples, + max_gap, + } + } + fn price_spec(env: &Env, reflector: &Address) -> PriceConfig { price_spec_at(env, reflector, 2_000, PriceDirection::Above, 10) } @@ -6074,6 +6584,693 @@ mod test { Err(Ok(Error::WrongMode)) ); } + + // ----- TWAP resolution specs (issue #27) ------------------------------- + + /// Registers `spec` on a fresh TWAP market after configuring the mock + /// feed's `resolution()`, and returns the fixture. `min_samples >= 2` + /// and `window / resolution <= MAX_TWAP_SAMPLES` feasibility is checked + /// at reveal, so the feed config must be in place first. + fn arm_twap(f: &Fixture, spec: &TwapConfig) { + f.resolver.register_twap_spec(&f.market_id, spec); + } + + /// Decodes the `from_end`-th (0 = newest) resolver event payload into a + /// field map, mirroring the scaled-event test's decoding. + fn twap_event_fields(f: &Fixture, from_end: u32) -> soroban_sdk::Map { + use soroban_sdk::{xdr, TryFromVal}; + let events = f.env.events().all().filter_by_contract(&f.resolver_id); + let events = events.events(); + let event = events[events.len() - 1 - from_end as usize].clone(); + let xdr::ContractEventBody::V0(body) = event.body; + let data = soroban_sdk::Val::try_from_val(&f.env, &body.data).unwrap(); + soroban_sdk::Map::try_from_val(&f.env, &data).unwrap() + } + + /// The standard feasible TWAP config used across these tests: 100 s + /// grid, 1000 s window, two samples minimum, and `max_gap` wide enough + /// that only the test's own history shapes can trip the gap rules. + fn twap_standard_spec(env: &Env, reflector: &Address) -> TwapConfig { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 2, 900) + } + + #[test] + fn twap_resolves_yes_when_average_above_threshold_despite_last_print_below() { + // The manipulation case from the issue's motivation, inverted: the + // newest print (100) is far below the 1000 threshold, but the + // window's time-weighted average is ~1997 -> Yes. A single-price + // market would have answered No on this history. + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 3, 600) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history( + &f, + [ + PriceData { + price: 2_000, + timestamp: 1_200, + }, + PriceData { + price: 2_000, + timestamp: 1_800, + }, + PriceData { + price: 100, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + // 2000*600 + 2000*200 + 100*1 = 1_600_100 over 801 s -> 1997. + assert_eq!(f.resolver.resolve_twap(&f.market_id), Outcome::Yes); + } + + #[test] + fn twap_resolves_no_when_single_spike_at_close() { + // 9000 at the closing slot cannot carry the ~111 average over the + // 1000 threshold: the spike that would decide a single-print market + // is diluted by the window. + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 3, 600) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history( + &f, + [ + PriceData { + price: 100, + timestamp: 1_200, + }, + PriceData { + price: 100, + timestamp: 1_800, + }, + PriceData { + price: 9_000, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!(f.resolver.resolve_twap(&f.market_id), Outcome::No); + } + + #[test] + fn twap_rejects_insufficient_samples() { + // Two of the three required samples are present and every gap is + // within max_gap; only min_samples fails -> StalePrice, state + // untouched. + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 3, 900) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history( + &f, + [ + PriceData { + price: 500, + timestamp: 1_900, + }, + PriceData { + price: 2_000, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_twap(&f.market_id), + Err(Ok(Error::StalePrice)) + ); + assert_eq!( + f.resolver.state(&f.market_id), + ResolutionState::Unconfigured + ); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Open + ); + } + + #[test] + fn twap_rejects_gap_larger_than_max_gap() { + // All three gap kinds independently: window start -> first sample, + // consecutive samples, last sample -> close_time. Each history + // satisfies min_samples and would otherwise resolve Yes. + let cases: [([PriceData; 2], &str); 3] = [ + ( + [ + PriceData { + price: 2_000, + timestamp: 1_200, + }, + PriceData { + price: 2_000, + timestamp: 2_000, + }, + ], + "consecutive gap 800 > 300", + ), + ( + [ + PriceData { + price: 2_000, + timestamp: 1_100, + }, + PriceData { + price: 2_000, + timestamp: 1_200, + }, + ], + "tail gap 800 > 300", + ), + ( + [ + PriceData { + price: 2_000, + timestamp: 1_900, + }, + PriceData { + price: 2_000, + timestamp: 2_000, + }, + ], + "first gap 900 > 300", + ), + ]; + for (records, note) in cases { + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 2, 300) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history(&f, records); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_twap(&f.market_id), + Err(Ok(Error::StalePrice)), + "{note}" + ); + } + } + + #[test] + fn twap_weighting_exact_known_value() { + // Hand-computed 4-sample case (issue #27 step 4): samples at + // 1750/1850/1900/2000 on the 50 s grid, weights 100/50/100/1 + // (last weight = close_time - last_ts + 1), sum 45_400 / 251 -> + // floored 180, and 180 AtOrAbove 180 is Yes (equality boundary of + // the floored average). + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec( + env, + reflector, + 180, + PriceDirection::AtOrAbove, + 1_000, + 4, + 900, + ) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &50); + feed_history( + &f, + [ + PriceData { + price: 100, + timestamp: 1_750, + }, + PriceData { + price: 300, + timestamp: 1_850, + }, + PriceData { + price: 200, + timestamp: 1_900, + }, + PriceData { + price: 400, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + use soroban_sdk::TryFromVal; + assert_eq!(f.resolver.resolve_twap(&f.market_id), Outcome::Yes); + let fields = twap_event_fields(&f, 0); + assert_eq!( + i128::try_from_val(&f.env, &fields.get(Symbol::new(&f.env, "twap")).unwrap()).unwrap(), + 180 + ); + } + + #[test] + fn twap_overflow_returns_invalid_price() { + // i128::MAX samples cannot be summed even with dt = 1: the checked + // accumulate gives InvalidPrice and the market stays unresolved. + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 30, 2, 30) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &1); + feed_history( + &f, + [ + PriceData { + price: i128::MAX, + timestamp: 1_999, + }, + PriceData { + price: i128::MAX, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_twap(&f.market_id), + Err(Ok(Error::InvalidPrice)) + ); + assert_eq!( + f.resolver.state(&f.market_id), + ResolutionState::Unconfigured + ); + } + + #[test] + fn twap_rejects_nonpositive_sample() { + // Same rule as #26 for single prints: a zeroed or negative record in + // the window poisons the average outright. + for price in [0_i128, -1, -2_500] { + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 2, 900) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history( + &f, + [ + PriceData { + price, + timestamp: 2_000, + }, + PriceData { + price: 2_000, + timestamp: 1_900, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!( + f.resolver.try_resolve_twap(&f.market_id), + Err(Ok(Error::InvalidPrice)), + "price {price}" + ); + } + } + + #[test] + fn register_twap_spec_infeasible_window_rejected() { + // Every registration-time rejection is InvalidConfig (issue #27 + // step 2): static field rules, an over-wide slot count against + // MAX_TWAP_SAMPLES, an unreachable min_samples target, and a + // zero-period feed. + let cases: [(u32, fn(&Env, &Address) -> TwapConfig, &str); 7] = [ + ( + 1_000, + |env, r| twap_price_spec(env, r, 1_000, PriceDirection::Above, 33_001, 2, 1_000), + "window/resolution 33 > MAX_TWAP_SAMPLES", + ), + ( + 100, + |env, r| twap_price_spec(env, r, 1_000, PriceDirection::Above, 500, 6, 500), + "min_samples 6 > 5 grid slots", + ), + ( + 100, + |env, r| twap_price_spec(env, r, 1_000, PriceDirection::Above, 1_000, 1, 900), + "min_samples below 2", + ), + ( + 100, + |env, r| twap_price_spec(env, r, 1_000, PriceDirection::Above, 1_000, 2, 0), + "max_gap 0", + ), + ( + 100, + |env, r| twap_price_spec(env, r, 1_000, PriceDirection::Above, 1_000, 2, 1_001), + "max_gap > window", + ), + ( + 100, + |env, r| twap_price_spec(env, r, -1, PriceDirection::Above, 1_000, 2, 900), + "negative threshold", + ), + ( + 100, + |env, r| twap_price_spec(env, r, 1_000, PriceDirection::Above, 0, 2, 1), + "zero window", + ), + ]; + for (resolution, make, note) in cases { + let (f, spec) = twap_fixture(|env, reflector, _a, _b| make(env, reflector)); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &resolution); + assert_eq!( + f.resolver.try_register_twap_spec(&f.market_id, &spec), + Err(Ok(Error::InvalidConfig)), + "{note}" + ); + } + + // A feed with resolution() of 0 divides by zero at resolve time; + // registration rejects it too. + let (f, spec) = twap_fixture(twap_standard_spec); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &0); + assert_eq!( + f.resolver.try_register_twap_spec(&f.market_id, &spec), + Err(Ok(Error::InvalidConfig)) + ); + + // An unreachable reflector at registration is PriceUnavailable: + // feasibility needs the live grid period, so reveal fails cleanly + // instead of committing an unverifiable rule. + let (f, spec) = twap_fixture(twap_standard_spec); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + Sep40MockClient::new(&f.env, &f.reflector_id).set_panic(&true); + assert_eq!( + f.resolver.try_register_twap_spec(&f.market_id, &spec), + Err(Ok(Error::PriceUnavailable)) + ); + } + + #[test] + fn cannot_register_twap_when_price_spec_exists() { + // assert_unconfigured sees the third key both ways: a legacy price + // market cannot reveal a TWAP rule, and a TWAP market cannot reveal + // legacy or scaled rules (the double-registration hole #26 closed + // for PriceScaled, extended to PriceTwap). + let f = fixture(|env, reflector, _a, _b| ResolutionSpec::Price(price_spec(env, reflector))); + let ResolutionSpec::Price(spec) = f.spec.clone() else { + unreachable!() + }; + f.resolver.register_price_spec(&f.market_id, &spec); + let twap = twap_standard_spec(&f.env, &f.reflector_id); + assert_eq!( + f.resolver.try_register_twap_spec(&f.market_id, &twap), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + + let (g, spec) = twap_fixture(twap_standard_spec); + Sep40MockClient::new(&g.env, &g.reflector_id).set_config(&14, &100); + g.resolver.register_twap_spec(&g.market_id, &spec); + let legacy = price_spec(&g.env, &g.reflector_id); + assert_eq!( + g.resolver.try_register_price_spec(&g.market_id, &legacy), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + let scaled = scaled_price_spec(&g.env, &g.reflector_id, 20, 2, PriceDirection::Above, 10); + assert_eq!( + g.resolver + .try_register_scaled_price_spec(&g.market_id, &scaled), + Err(Ok(Error::MarketAlreadyConfigured)) + ); + // TWAP markets are WrongMode for the raw-price snapshot and preview + // helpers, which pin a single record. + g.env.ledger().set_timestamp(2_000); + assert_eq!( + g.resolver + .try_snapshot_price(&g.market_id, &Address::generate(&g.env)), + Err(Ok(Error::WrongMode)) + ); + assert_eq!( + g.resolver.try_preview_price(&g.market_id), + Err(Ok(Error::WrongMode)) + ); + // And resolve_twap is WrongMode on a legacy price market. + assert_eq!( + f.resolver.try_resolve_twap(&f.market_id), + Err(Ok(Error::WrongMode)) + ); + } + + #[test] + fn resolve_twap_lifecycle() { + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 2, 900) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history( + &f, + [ + PriceData { + price: 2_000, + timestamp: 1_900, + }, + PriceData { + price: 2_000, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + // Before close: MarketNotClosed. + assert_eq!( + f.resolver.try_resolve_twap(&f.market_id), + Err(Ok(Error::MarketNotClosed)) + ); + f.env.ledger().set_timestamp(2_000); + assert_eq!(f.resolver.resolve_twap(&f.market_id), Outcome::Yes); + assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Finalized); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Resolved(Outcome::Yes) + ); + // Terminal after finalize. + assert_eq!( + f.resolver.try_resolve_twap(&f.market_id), + Err(Ok(Error::AlreadyFinalized)) + ); + } + + #[test] + fn resolve_price_dispatches_twap_markets() { + // Parity with the #26 scaled dispatch: a revealed TWAP market also + // resolves through `resolve_price`, so callers need no mode probing. + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 2, 900) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history( + &f, + [ + PriceData { + price: 2_000, + timestamp: 1_900, + }, + PriceData { + price: 2_000, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!(f.resolver.resolve_price(&f.market_id), Outcome::Yes); + assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Finalized); + } + + #[test] + fn twap_emits_resolution_events_with_avg_and_window() { + use soroban_sdk::TryFromVal; + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 3, 600) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history( + &f, + [ + PriceData { + price: 2_000, + timestamp: 1_200, + }, + PriceData { + price: 2_000, + timestamp: 1_800, + }, + PriceData { + price: 100, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + assert_eq!(f.resolver.resolve_twap(&f.market_id), Outcome::Yes); + + // Newest event: TwapResolution with the full averaging report. + let fields = twap_event_fields(&f, 0); + let get = |name: &str| fields.get(Symbol::new(&f.env, name)).unwrap(); + assert_eq!(i128::try_from_val(&f.env, &get("twap")).unwrap(), 1_997); + assert_eq!(u32::try_from_val(&f.env, &get("samples")).unwrap(), 3); + assert_eq!(u64::try_from_val(&f.env, &get("first_ts")).unwrap(), 1_200); + assert_eq!(u64::try_from_val(&f.env, &get("last_ts")).unwrap(), 2_000); + assert_eq!( + Outcome::try_from_val(&f.env, &get("outcome")).unwrap(), + Outcome::Yes + ); + // The unchanged PriceResolutionV2 compatibility event carries the + // averaged price stamped at the newest sample. + let compat = twap_event_fields(&f, 1); + assert_eq!( + i128::try_from_val(&f.env, &compat.get(Symbol::new(&f.env, "price")).unwrap()).unwrap(), + 1_997 + ); + assert_eq!( + u64::try_from_val( + &f.env, + &compat.get(Symbol::new(&f.env, "price_timestamp")).unwrap() + ) + .unwrap(), + 2_000 + ); + assert!(!bool::try_from_val( + &f.env, + &compat.get(Symbol::new(&f.env, "from_snapshot")).unwrap() + ) + .unwrap()); + } + + #[test] + fn twap_read_cap_bounds_window_of_missing_records() { + // The loop caps *reads*, not found samples: registration is feasible + // on the 1000 s grid (30 slots), the feed then narrows to 100 s + // (300 slots) and answers nothing. Without the cap the walk would + // inspect every slot of the window; with it, resolution stops after + // MAX_TWAP_SAMPLES calls and fails min_samples with StalePrice. + let (f, spec) = twap_fixture_at(60_000, |env, reflector, _a, _b| { + twap_price_spec( + env, + reflector, + 1_000, + PriceDirection::Above, + 30_000, + 2, + 30_000, + ) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &1_000); + arm_twap(&f, &spec); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + f.env.ledger().set_timestamp(60_000); + assert_eq!( + f.resolver.try_resolve_twap(&f.market_id), + Err(Ok(Error::StalePrice)) + ); + assert!(MAX_TWAP_SAMPLES < 300); + } + + #[test] + fn twap_ignores_duplicate_records_from_nearest_earlier_feed() { + // Issue #27 edge: a feed answering `price(asset, ts)` with the + // newest record at or before ts returns the 1800 record for both + // the 1900 and the 1800 queries. Each timestamp must be counted + // once: weights 200 (for 1800) and 1 (for 2000), giving + // (500*200 + 1000*1) / 201 = 502, not a three-sample average. + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec( + env, + reflector, + 180, + PriceDirection::AtOrAbove, + 1_000, + 2, + 900, + ) + }); + let feed = Sep40MockClient::new(&f.env, &f.reflector_id); + feed.set_config(&14, &100); + feed.set_nearest(&true); + feed_history( + &f, + [ + PriceData { + price: 500, + timestamp: 1_800, + }, + PriceData { + price: 1_000, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(2_000); + use soroban_sdk::TryFromVal; + assert_eq!(f.resolver.resolve_twap(&f.market_id), Outcome::Yes); + let fields = twap_event_fields(&f, 0); + assert_eq!( + i128::try_from_val(&f.env, &fields.get(Symbol::new(&f.env, "twap")).unwrap()).unwrap(), + 502 + ); + assert_eq!( + u32::try_from_val(&f.env, &fields.get(Symbol::new(&f.env, "samples")).unwrap()) + .unwrap(), + 2 + ); + } + + #[test] + fn expire_resolves_twap_market_when_feed_is_valid() { + // Expiry prefers resolution over voiding, as for the other price + // shapes: at the deadline with a live feed the market settles on + // its TWAP instead of expiring. + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 2, 900) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + feed_history( + &f, + [ + PriceData { + price: 2_000, + timestamp: 1_900, + }, + PriceData { + price: 2_000, + timestamp: 2_000, + }, + ], + ); + arm_twap(&f, &spec); + f.env.ledger().set_timestamp(DEADLINE); + f.resolver.expire_market(&f.market_id); + assert_eq!( + f.market_core.get_market(&f.market_id).status, + MarketStatus::Resolved(Outcome::Yes) + ); + assert_eq!(f.resolver.state(&f.market_id), ResolutionState::Finalized); + } + + #[test] + fn expire_voids_twap_market_whose_feed_traps() { + // A dead feed must not lock TWAP collateral behind guardian-void + // forever: the settle attempt fails feed-unusable and the uniform + // ExpiryMode::Price void path applies. + let (f, spec) = twap_fixture(|env, reflector, _a, _b| { + twap_price_spec(env, reflector, 1_000, PriceDirection::Above, 1_000, 2, 900) + }); + Sep40MockClient::new(&f.env, &f.reflector_id).set_config(&14, &100); + arm_twap(&f, &spec); + Sep40MockClient::new(&f.env, &f.reflector_id).set_panic(&true); + f.env.ledger().set_timestamp(DEADLINE); + f.resolver.expire_market(&f.market_id); + assert_expired_event(&f, ExpiryMode::Price); + } } #[cfg(test)] From bf2dee229cb2dd4eea117196f514f193f747e02b Mon Sep 17 00:00:00 2001 From: achiever2110 Date: Tue, 20 Oct 2026 22:06:04 +0100 Subject: [PATCH 7/8] feat(spec-hash): price-twap commitment hashing (#27) Adds the `price-twap` subcommand (shared / parsers were already factored out of parse_price by #26's work, so issue step 7 needed no further refactor) hashing through the contract's own twap_spec_hash, a ParsedRule::Twap arm, --v2-envelope rejection, and verify-kind plumbing. golden_price_twap_vector pins delegation equality, hash-space disjointness against price/signers/price-scaled preimages, verify round-trip and --v2 rejection; the pinned legacy vectors keep proving every existing Price/Signers/V2 hash is unchanged. A literal twap hex golden is a listed maintainer follow-up (no host run is possible while the in-tree build gate fails), same disclosure as the scaled pin. --- .../scripts/spec-hash/src/lib.rs | 178 ++++++++++++++++-- 1 file changed, 167 insertions(+), 11 deletions(-) diff --git a/oracledesk-stellar/scripts/spec-hash/src/lib.rs b/oracledesk-stellar/scripts/spec-hash/src/lib.rs index 504864e..51e9fe3 100644 --- a/oracledesk-stellar/scripts/spec-hash/src/lib.rs +++ b/oracledesk-stellar/scripts/spec-hash/src/lib.rs @@ -3,14 +3,14 @@ //! `docs/adr/0004-spec-versioning.md`). Off-chain dev tool only; never deployed. //! //! All hashing delegates to the resolver crate's own `legacy_spec_hash` / -//! `spec_v2_hash` / `scaled_spec_hash`, so the tool cannot drift from the -//! contract. +//! `spec_v2_hash` / `scaled_spec_hash` / `twap_spec_hash`, so the tool cannot +//! drift from the contract. use resolver::{ - legacy_spec_hash, scaled_spec_hash, spec_v2_hash, CommitteeRef, DisputableSignerConfig, - Escalation, HybridConfig, OracleAsset, PriceConfig, PriceDirection, ResolutionSpec, - ResolutionSpecV2, ScaledPriceConfig, SignerConfig, WeightedKey, WeightedKeyConfig, - MAX_THRESHOLD_DECIMALS, + legacy_spec_hash, scaled_spec_hash, spec_v2_hash, twap_spec_hash, CommitteeRef, + DisputableSignerConfig, Escalation, HybridConfig, OracleAsset, PriceConfig, PriceDirection, + ResolutionSpec, ResolutionSpecV2, ScaledPriceConfig, SignerConfig, TwapConfig, WeightedKey, + WeightedKeyConfig, MAX_THRESHOLD_DECIMALS, }; use soroban_sdk::{Address, Bytes, BytesN, Env, Symbol, Vec as SorobanVec}; @@ -26,13 +26,17 @@ usage: same asset/direction vocabulary as `price` spec-hash [V2 FLAGS] price-scaled --decimal --decimal converts an exact decimal string (no floats): `0.20` = threshold 20, decimals 2 + spec-hash price-twap + time-weighted-average variant (issue #27): averages the feed's records over + (close_time - window_secs, close_time]; is feed-native units like `price`; + own domain-tagged preimage, so it accepts neither the --v2 envelope nor --decimal spec-hash [V2 FLAGS] weighted-keys :... spec-hash [V2 FLAGS] signers-disputable ... is `guardian` or `committee::,...` spec-hash [V2 FLAGS] committee spec-hash [V2 FLAGS] hybrid -- ... - spec-hash [V2 FLAGS] verify ... [--decimal ] + spec-hash [V2 FLAGS] verify ... [--decimal ] spec-hash gen-salt V2 FLAGS (all four are required together; without them the legacy hash is printed): @@ -253,6 +257,36 @@ pub fn parse_price_scaled( }) } +/// Builds the [`TwapConfig`] a market commits to through +/// `resolver::twap_spec_hash` (issue #27). Shares the ``/`` +/// vocabulary of `price`/`price-scaled`; the three TWAP-specific positional +/// args mirror `register_twap_spec`'s field order: `` +/// (average over (close_time - window, close_time]), `` +/// (present grid records required) and `` (largest tolerated +/// gap between window start, samples, and close). +pub fn parse_price_twap(env: &Env, args: &[String]) -> Result { + let [reflector, asset, threshold, direction, window, min_samples, max_gap] = args else { + return Err("price-twap needs ".into()); + }; + Ok(TwapConfig { + reflector: parse_address(env, reflector)?, + asset: parse_asset(env, asset)?, + threshold: threshold + .parse() + .map_err(|_| "threshold must be an i128".to_string())?, + direction: parse_direction(direction)?, + window: window + .parse() + .map_err(|_| "window must be a u64".to_string())?, + min_samples: min_samples + .parse() + .map_err(|_| "min_samples must be a u32".to_string())?, + max_gap: max_gap + .parse() + .map_err(|_| "max_gap must be a u64".to_string())?, + }) +} + pub fn parse_weighted_keys(env: &Env, args: &[String]) -> Result { let [weight_threshold, dispute_window, keys @ ..] = args else { return Err( @@ -387,13 +421,15 @@ pub fn parse_hybrid(env: &Env, args: &[String]) -> Result) -> Result { @@ -403,6 +439,7 @@ fn parse_rule(env: &Env, args: &[String], decimal: Option<&str>) -> Result { Ok(ParsedRule::Scaled(parse_price_scaled(env, rest, decimal)?)) } + [kind, rest @ ..] if kind == "price-twap" => Ok(ParsedRule::Twap(parse_price_twap(env, rest)?)), [kind, rest @ ..] if kind == "weighted-keys" => { Ok(ParsedRule::Rule(parse_weighted_keys(env, rest)?)) } @@ -411,7 +448,7 @@ fn parse_rule(env: &Env, args: &[String], decimal: Option<&str>) -> Result Ok(ParsedRule::Rule(parse_committee(rest)?)), [kind, rest @ ..] if kind == "hybrid" => Ok(ParsedRule::Rule(parse_hybrid(env, rest)?)), - _ => Err("expected `signers`, `price`, `price-scaled`, `weighted-keys`, `signers-disputable`, `committee` or `hybrid`".into()), + _ => Err("expected `signers`, `price`, `price-scaled`, `price-twap`, `weighted-keys`, `signers-disputable`, `committee` or `hybrid`".into()), } } @@ -427,6 +464,16 @@ fn commitment(env: &Env, rule: ParsedRule, v2: Option) -> Result<[u8; } return Ok(scaled_spec_hash(env, &cfg).to_array()); } + ParsedRule::Twap(cfg) => { + if v2.is_some() { + return Err( + "price-twap commits through its own domain-tagged preimage and does not \ + support the --v2 envelope" + .into(), + ); + } + return Ok(twap_spec_hash(env, &cfg).to_array()); + } ParsedRule::Rule(rule) => rule, }; let hash = match v2 { @@ -834,6 +881,89 @@ mod tests { assert!(matches!(run(&args(&v2_scaled)), Err(CliError::Usage(_)))); } + #[test] + fn golden_price_twap_vector() { + // Issue #27, same discipline as `golden_price_scaled_vector`: the + // TWAP commitment is `sha256("oracledesk:price-twap-spec" || + // XDR(TwapConfig))` and the CLI delegates to the contract's own + // `twap_spec_hash`, so tool and contract cannot drift. The pinned + // golden vectors of `golden_legacy_vectors` (unchanged by this + // subcommand's addition) prove every existing Price/Signers/V2 hash + // stays byte-identical; a literal digest for the twap space is not + // derivable by an in-tree host run while `oracledesk-types` fails + // to compile, so this test pins delegation + disjointness instead + // (listed maintainer follow-up, same as the scaled pin). + let raw = hash_of(&[ + "price-twap", + C1, + "other:XLM", + "2000", + "above", + "3600", + "6", + "900", + ]); + assert_eq!(raw.len(), 64); + assert!(raw.chars().all(|c| c.is_ascii_hexdigit())); + + let env = Env::default(); + let cfg = TwapConfig { + reflector: parse_address(&env, C1).unwrap(), + asset: parse_asset(&env, "other:XLM").unwrap(), + threshold: 2_000, + direction: PriceDirection::Above, + window: 3_600, + min_samples: 6, + max_gap: 900, + }; + assert_eq!(raw, to_hex(&twap_spec_hash(&env, &cfg).to_array())); + + // Disjoint hash spaces: the same numbers read as a single-price or + // signers rule, or as a scaled rule, never collide with the TWAP + // preimage. + assert_ne!( + raw, + hash_of(&["price", C1, "other:XLM", "2000", "above", "10"]) + ); + assert_ne!(raw, hash_of(&["signers", "1", "5", G1])); + assert_ne!( + raw, + hash_of(&["price-scaled", C1, "other:XLM", "2000", "2", "above", "600"]) + ); + + // verify round-trips the new kind, and the ADR-0001 promise the + // issue demands: a `price` vector through `verify` still matches. + assert_eq!( + hash_of(&[ + "verify", + &raw, + "price-twap", + C1, + "other:XLM", + "2000", + "above", + "3600", + "6", + "900" + ]), + "match" + ); + + // The V2 envelope wraps a `ResolutionSpec` and rejects a twap rule. + let mut v2_twap = v2_args(SALT, "Test SDF Network ; September 2015", C1); + v2_twap.extend_from_slice(&[ + "price-twap", + C1, + "other:XLM", + "2000", + "above", + "3600", + "6", + "900", + ]); + assert!(matches!(run(&args(&v2_twap)), Err(CliError::Usage(_)))); + } + #[test] fn decimal_amount_is_exact_and_bounded() { assert_eq!(parse_decimal_amount("0.20").unwrap(), (20, 2)); @@ -886,6 +1016,32 @@ mod tests { "--decimal", "-0.2", ]), + args(&["price-twap", C1, "other:XLM", "1", "above", "60"]), + args(&["price-twap", C1, "bogus", "1", "above", "60", "2", "10"]), + args(&[ + "price-twap", + C1, + "other:XLM", + "1", + "sideways", + "60", + "2", + "10", + ]), + args(&["price-twap", C1, "other:XLM", "1", "above", "x", "2", "10"]), + args(&["price-twap", C1, "other:XLM", "1", "above", "60", "y", "10"]), + args(&[ + "--decimal", + "0.2", + "price-twap", + C1, + "other:XLM", + "1", + "above", + "60", + "2", + "10", + ]), args(&["weighted-keys", "2", "60", "nocolon"]), args(&["weighted-keys", "2", "60", "zz:1"]), args(&[ From 05c508c25559ef7a0dda4596c52f0ac93214d2ac Mon Sep 17 00:00:00 2001 From: achiever2110 Date: Tue, 20 Oct 2026 22:06:58 +0100 Subject: [PATCH 8/8] docs: ADR 0001 TWAP variant + architecture PriceTwap mode (#27) Documents the fourth commitment hash space ("oracledesk:price-twap-spec"), the sampling/gap/weighting rules (last weight close_time - last_ts + 1, floor division, feed-native threshold), and records explicitly that MAX_TWAP_SAMPLES = 32 is REASONED from the crate's own 32-signature weighted-keys ceiling, not measured: the CPU measurement issue #27 asks for cannot execute while the in-tree build gate fails, so no benchmark number is claimed. Canonical variant-append into oracledesk-types and the literal golden re-pin remain listed maintainer follow-ups. --- .../docs/adr/0001-resolution-commitment.md | 77 +++++++++++++++++++ oracledesk-stellar/docs/architecture.md | 15 ++++ 2 files changed, 92 insertions(+) diff --git a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md index dcc1b62..6639d78 100644 --- a/oracledesk-stellar/docs/adr/0001-resolution-commitment.md +++ b/oracledesk-stellar/docs/adr/0001-resolution-commitment.md @@ -109,6 +109,83 @@ them. existing variants keep their preimages, so the domain-tagged form above can be migrated variant-by-variant without breaking committed markets. +## TWAP resolution variant + +Issue #27 adds a time-weighted-average price rule, `TwapConfig` (defined in +`contracts/resolver`, alongside `register_twap_spec` / `resolve_twap`): the +market settles on the average of the feed's records over a window ending at +`close_time`, so a single manipulated or anomalous close print can no longer +decide it. It is described here because it changes what `resolution_hash` +can commit to. + +* **Hash space.** Same construction as the scaled mode above: the TWAP + commitment is + `sha256("oracledesk:price-twap-spec" || XDR(TwapConfig))` + (`resolver::twap_spec_hash`). The issue's sketch appended a + `PriceTwap(TwapConfig)` variant to `ResolutionSpec` (discriminant 2 in + the then-two-variant enum); under PR #176's shared-types layout that + enum is frozen in `contracts/oracledesk-types`, exactly the constraint + documented in "Price threshold units", so the variant form is a listed + maintainer follow-up and the domain-tagged form ships now. Disjointness + extends to four provably separate spaces: legacy `ScVal` preimages start + `00 00 00 10`; the V2 tag starts `oracledesk:resolution-`; the scaled + tag is `oracledesk:scaled-price-spec`; the TWAP tag is + `oracledesk:price-twap-spec` — all differ at fixed offsets, so no + preimage of one mode is ever a preimage of another and every previously + committed hash stays byte-identical (pinned by the golden vectors in + `contracts/resolver` and `scripts/spec-hash`, unchanged by this ADR). + A market is exactly one of the price shapes + (`DataKey::Price` / `PriceScaled` / the appended `PriceTwap`), enforced + by `assert_unconfigured` checking all keys. +* **Sampling.** `resolve_twap` (and `resolve_price`, which dispatches) + reads `price(asset, ts)` at every `resolution()`-aligned timestamp in + `(close_time - window, close_time]`, newest grid slot first, using the + same grid alignment as the #26 single-read lookup. Missing records are + skipped; a trapping feed is `PriceUnavailable`; `resolution() == 0` is + rejected at both registration (`InvalidConfig`) and resolution + (`InvalidPrice`). Duplicated timestamps — a feed answering with the + nearest earlier record — are counted once. Registration additionally + calls `resolution()` on the committed reflector and rejects ( + `InvalidConfig`) a `window / resolution` above the sample cap or a + `min_samples` above the slot count, so an unresolvable market is caught + at reveal, not at close. +* **Rejection rules.** Fewer than `min_samples` present samples, a gap + above `max_gap` between `start` and the first sample, between any two + consecutive samples, or between the last sample and `close_time`, all + fail `StalePrice`; a sample price `<= 0` or any arithmetic overflow + fails `InvalidPrice`. Failed resolution leaves the market untouched, and + `expire_market` treats those errors as "feed unusable" exactly as for + the other price shapes. +* **Weighting formula.** Each sample is weighted by the time until the + next sample; the newest sample is weighted `close_time - last_ts + 1` + (the closing second is included — the convention the issue offered as + "`close_time - ts_last + 1` or `resolution()`", this ADR picks the + former and both the numerator and denominator apply it consistently). + `twap = floor( Σ price_i * dt_i / Σ dt_i )` in `i128` with + `checked_mul`/`checked_add`/`checked_div`; the committed + `AtOrAbove`/`AtOrBelow` comparison applies to the floored average. + Threshold units are feed-native, like the legacy `Price` variant (the + decimal-explicit question is #26's and stays with `ScaledPriceConfig`). +* **Sample cap and budget.** `MAX_TWAP_SAMPLES = 32` bounds the + cross-contract `price` reads per resolution attempt — as a cap on + *reads inspected*, not samples found, so a window full of missing + records terminates instead of walking the grid forever. The issue asks + for a value "chosen from measured CPU cost" (`env.cost_estimate()` + against the registered wasm, recorded here). **That measurement is not + available and this number is reasoned, not measured**: the in-tree + build gate (the wasm-hash verification build script and + `oracledesk-types`) fails at the current tip, so no test executes; no + benchmark figure is claimed. 32 is instead derived from the contract's + own accepted ceilings — `MAX_WEIGHTED_SIGNERS = 32` / + `MAX_COMMITTEE = 32`, and the weighted-keys path verifies up to 32 + ed25519 signatures in one call at a documented 23.1M instructions (see + "Weighted-key attestations"), a per-item cost strictly above one bounded + price read — so a 32-sample TWAP cannot exceed a flow the protocol + already ships against. A measured re-derivation is a maintainer + follow-up alongside the golden re-pin. +* **Manipulation scope.** A TWAP dilutes a single print, but a sustained + push still moves the average; median filtering is out of scope. + ## Liveness fallback A market whose spec is never revealed, whose committed feed is permanently diff --git a/oracledesk-stellar/docs/architecture.md b/oracledesk-stellar/docs/architecture.md index 7bf9e04..d61b513 100644 --- a/oracledesk-stellar/docs/architecture.md +++ b/oracledesk-stellar/docs/architecture.md @@ -120,6 +120,21 @@ Independent resolution modes, all ultimately calling Anyone may first pin the grid read with `snapshot_price` (legacy and hybrid specs only; scaled markets reply `WrongMode` — see "Oracle history retention" below). +- **PriceTwap**: the time-weighted-average price threshold (issue #27, + `TwapConfig` + `register_twap_spec`/`resolve_twap`). Samples + `price(asset, ts)` at every `resolution()`-aligned timestamp in + `(close_time - window, close_time]` — at most `MAX_TWAP_SAMPLES` (32) + reads — skips missing records, and rejects fewer than `min_samples` + samples or any gap above `max_gap` (start→first, consecutive, and + last→close) with `StalePrice`. The average is an integer-only, checked + `i128` sum weighted by time-to-next-sample (the last sample's weight runs + through `close_time` inclusive), floored before the threshold comparison, + and emitted as `TwapResolution` alongside the unchanged + `PriceResolution`/`V2` pair. It commits through its own domain-tagged + preimage (ADR 0001, "TWAP resolution variant") and is `WrongMode` for + `snapshot_price`/`preview_price`, which pin single prints. A market uses + at most one price shape: `assert_unconfigured` covers + `Price`/`PriceScaled`/`PriceTwap` together. - **Attested**: m-of-n signer votes on a resolver-local `AttestedOutcome { Yes, No, Invalid }`; once `threshold` signers agree, a dispute window opens; anyone can finalize after it elapses, or a guardian