diff --git a/contracts/markets/Cargo.toml b/contracts/markets/Cargo.toml new file mode 100644 index 00000000..5576b146 --- /dev/null +++ b/contracts/markets/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "markets" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["lib"] +doctest = false + +[dependencies] +soroban-sdk = { workspace = true } +predictify-hybrid = { path = "../predictify-hybrid" } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } + +[[test]] +name = "auth_snap" +path = "tests/auth_snap.rs" diff --git a/contracts/markets/src/lib.rs b/contracts/markets/src/lib.rs new file mode 100644 index 00000000..a591a937 --- /dev/null +++ b/contracts/markets/src/lib.rs @@ -0,0 +1,2 @@ +#![no_std] +// Markets test utility package placeholder diff --git a/contracts/markets/tests/auth_snap.rs b/contracts/markets/tests/auth_snap.rs new file mode 100644 index 00000000..72991f86 --- /dev/null +++ b/contracts/markets/tests/auth_snap.rs @@ -0,0 +1,536 @@ +//! Per-entrypoint authorization snapshot tests. +//! +//! This integration suite *snapshots* the Soroban authorization required by +//! every state-changing entrypoint of [`PredictifyHybrid`]. Instead of only +//! checking that an authorized call succeeds and an unauthorized one fails, +//! these tests inspect [`Env::auths`] immediately after each call and assert +//! **which address the host actually required an authorization from**. +//! +//! Why a snapshot? +//! +//! * If a `require_auth` is ever dropped from an entrypoint, `env.auths()` for +//! that call becomes empty and the matching test fails. +//! * If the auth subject is ever rebound to the wrong argument (e.g. an admin +//! setter that starts authorizing an attacker-controlled address), the +//! captured subject no longer matches the expected one and the test fails. +//! * Read-only entrypoints are pinned to *require no auth at all*, documenting +//! the read/write authorization boundary. +//! +//! ## Why some entrypoints use a different check +//! +//! The Soroban host records an authorization only for an invocation that +//! commits. When a call traps or returns `Err`, its recorded auths are rolled +//! back and `env.auths()` comes back empty. Most snapshots below therefore +//! drive the entrypoint through a *fully satisfied* happy path — including a +//! real Stellar Asset Contract for stake transfers — and then read the auth +//! back ("committed snapshot"). +//! +//! Three entrypoints need runtime state this fixture deliberately does not +//! build. For those the auth boundary is pinned directly instead +//! ("auth boundary"): authorized calls must get *past* `require_auth` and fail +//! on domain logic (`Err(Ok(..))`), while unauthorized calls must trap in +//! `require_auth` (a matching `#[should_panic]` test). +//! +//! ## Entrypoint auth matrix +//! +//! | Entrypoint | Required auth subject | Verified by | +//! |----------------------------|-----------------------|--------------------| +//! | `create_market` | admin | committed snapshot | +//! | `resolve_market_manual` | admin | committed snapshot | +//! | `collect_fees` | admin | committed snapshot | +//! | `set_platform_fee` | admin | committed snapshot | +//! | `set_treasury` | admin | committed snapshot | +//! | `set_global_claim_period` | admin | committed snapshot | +//! | `set_market_claim_period` | admin | committed snapshot | +//! | `extend_deadline` | admin | committed snapshot | +//! | `sweep_unclaimed_winnings` | admin | auth boundary | +//! | `vote` | user | committed snapshot | +//! | `place_bet` | user | committed snapshot | +//! | `cancel_bet` | user | committed snapshot | +//! | `claim_winnings` | user | auth boundary | +//! | `dispute_market` | user | auth boundary | +//! | `get_market` (read-only) | none | committed snapshot | +//! | `get_market_bet_stats` | none | committed snapshot | + +use predictify_hybrid::{ + Error, OracleConfig, OracleProvider, PredictifyHybrid, PredictifyHybridClient, +}; +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + token::StellarAssetClient, + Address, Env, String, Symbol, Vec, +}; + +// ============================================================ +// Fixture +// ============================================================ + +/// A fully wired contract: registered Stellar Asset Contract for stake +/// transfers, `TokenID` stored in contract state, and an initialized admin. +struct Fixture { + env: Env, + cid: Address, + admin: Address, + token_id: Address, +} + +impl Fixture { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let cid = env.register(PredictifyHybrid, ()); + + // Register a Stellar asset so the contract's token client resolves. + let token_id = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + + // Wire the token before initializing so stake transfers work. + env.as_contract(&cid, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "TokenID"), &token_id); + }); + + PredictifyHybridClient::new(&env, &cid).initialize(&admin, &Some(200i128), &None); + + Fixture { + env, + cid, + admin, + token_id, + } + } + + fn client(&self) -> PredictifyHybridClient<'_> { + PredictifyHybridClient::new(&self.env, &self.cid) + } + + /// Create a funded user able to cover any stake used in these tests. + fn user(&self) -> Address { + let u = Address::generate(&self.env); + StellarAssetClient::new(&self.env, &self.token_id).mint(&u, &100_000_000_000i128); + u + } + + fn oracle(&self) -> OracleConfig { + OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::from_str( + &self.env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + feed_id: String::from_str(&self.env, "BTC/USD"), + threshold: 50_000, + comparison: String::from_str(&self.env, "gt"), + } + } + + /// Create a standard two-outcome, 30-day market owned by `admin`. + fn market(&self) -> Symbol { + let mut outcomes = Vec::new(&self.env); + outcomes.push_back(String::from_str(&self.env, "yes")); + outcomes.push_back(String::from_str(&self.env, "no")); + self.client().create_market( + &self.admin, + &String::from_str(&self.env, "Will BTC reach 100k?"), + &outcomes, + &30u32, + &self.oracle(), + &None, + &86_400u64, + &None, + &None, + &None, + ) + } + + fn yes(&self) -> String { + String::from_str(&self.env, "yes") + } + + /// Advance ~31 days so a 30-day market is past its end time. + fn advance_past_end(&self) { + self.env + .ledger() + .with_mut(|l| l.timestamp += 31 * 24 * 60 * 60); + } + + /// Advance past the default 86_400 s dispute window. + fn advance_past_dispute(&self) { + self.env.ledger().with_mut(|l| l.timestamp += 86_401); + } + + /// Addresses the most recent top-level invocation required auth from. + fn required_auth(&self) -> std::vec::Vec
{ + self.env + .auths() + .iter() + .map(|(addr, _)| addr.clone()) + .collect() + } + + /// Assert the last invocation required an authorization from `expected`. + /// + /// An empty auth set means either the entrypoint performed no + /// `require_auth`, or the call failed and the host rolled its auths back. + fn assert_requires_auth(&self, expected: &Address, label: &str) { + let required = self.required_auth(); + assert!( + !required.is_empty(), + "{label}: no auth recorded — the entrypoint either skipped require_auth \ + or the call failed before committing" + ); + assert!( + required.contains(expected), + "{label}: expected auth from {expected:?}, captured {required:?}" + ); + } + + /// Assert the last invocation required *no* authorization (read-only). + fn assert_no_auth(&self, label: &str) { + let required = self.required_auth(); + assert!( + required.is_empty(), + "{label}: expected no auth for a read-only entrypoint, captured {required:?}" + ); + } +} + +// ============================================================ +// Auth-boundary assertions for entrypoints whose happy path needs +// state this fixture does not construct +// ============================================================ + +/// Assert the call got *past* `require_auth`. +/// +/// A `try_*` invocation reports a contract-level failure as `Err(Ok(..))` and a +/// host-level trap — which is how a rejected `require_auth` surfaces — as +/// `Err(Err(InvokeError))`. So reaching a contract error proves authorization +/// succeeded and the call failed later, on domain logic. +fn assert_auth_passed( + result: &Result, Result>, + label: &str, +) { + if let Err(Err(invoke_err)) = result { + panic!( + "{label}: expected the call to pass require_auth and fail on domain \ + logic, but it trapped at the host level: {invoke_err:?}" + ); + } +} + +// ============================================================ +// Admin-scoped entrypoints — snapshot: requires admin auth +// ============================================================ + +#[test] +fn snapshot_create_market_requires_admin_auth() { + let f = Fixture::new(); + let _ = f.market(); + f.assert_requires_auth(&f.admin, "create_market"); +} + +#[test] +fn snapshot_resolve_market_manual_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + f.advance_past_end(); + f.client() + .resolve_market_manual(&f.admin, &market_id, &f.yes()); + f.assert_requires_auth(&f.admin, "resolve_market_manual"); +} + +#[test] +fn snapshot_set_platform_fee_requires_admin_auth() { + let f = Fixture::new(); + f.client().set_platform_fee(&f.admin, &300i128); + f.assert_requires_auth(&f.admin, "set_platform_fee"); +} + +#[test] +fn snapshot_set_treasury_requires_admin_auth() { + let f = Fixture::new(); + let treasury = Address::generate(&f.env); + f.client().set_treasury(&f.admin, &treasury); + f.assert_requires_auth(&f.admin, "set_treasury"); +} + +#[test] +fn snapshot_set_global_claim_period_requires_admin_auth() { + let f = Fixture::new(); + f.client().set_global_claim_period(&f.admin, &604_800u64); + f.assert_requires_auth(&f.admin, "set_global_claim_period"); +} + +#[test] +fn snapshot_set_market_claim_period_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + f.client() + .set_market_claim_period(&f.admin, &market_id, &604_800u64); + f.assert_requires_auth(&f.admin, "set_market_claim_period"); +} + +#[test] +fn snapshot_extend_deadline_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + f.client().extend_deadline( + &f.admin, + &market_id, + &7u32, + &String::from_str(&f.env, "More time needed"), + ); + f.assert_requires_auth(&f.admin, "extend_deadline"); +} + +#[test] +fn snapshot_collect_fees_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + + // Stake enough that the 2% platform fee clears MIN_FEE_AMOUNT. + let user = f.user(); + f.client() + .vote(&user, &market_id, &f.yes(), &1_000_000_000i128); + + f.advance_past_end(); + f.client() + .resolve_market_manual(&f.admin, &market_id, &f.yes()); + + f.client().collect_fees(&f.admin, &market_id); + f.assert_requires_auth(&f.admin, "collect_fees"); +} + +/// `sweep_unclaimed_winnings` needs runtime state this fixture does not build, +/// so instead of a committed-call snapshot we pin the auth boundary directly: +/// authorized -> gets past `require_auth` and fails on domain logic, +/// unauthorized -> trapped by `require_auth`. +#[test] +fn snapshot_sweep_unclaimed_winnings_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + + let result = f + .client() + .try_sweep_unclaimed_winnings(&f.admin, &market_id, &false); + assert_auth_passed(&result, "sweep_unclaimed_winnings"); +} + +#[test] +#[should_panic] +fn edge_sweep_unclaimed_winnings_without_auth_panics() { + let f = Fixture::new(); + let market_id = f.market(); + f.env.set_auths(&[]); + f.client() + .sweep_unclaimed_winnings(&f.admin, &market_id, &false); +} + +// ============================================================ +// User-scoped entrypoints — snapshot: requires user auth +// ============================================================ + +#[test] +fn snapshot_vote_requires_user_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.client().vote(&user, &market_id, &f.yes(), &1_000_000i128); + f.assert_requires_auth(&user, "vote"); +} + +#[test] +fn snapshot_place_bet_requires_user_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.client() + .place_bet(&user, &market_id, &f.yes(), &1_000_000i128, &250i128); + f.assert_requires_auth(&user, "place_bet"); +} + +#[test] +fn snapshot_cancel_bet_requires_user_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.client() + .place_bet(&user, &market_id, &f.yes(), &1_000_000i128, &250i128); + f.client().cancel_bet(&user, &market_id); + f.assert_requires_auth(&user, "cancel_bet"); +} + +/// `claim_winnings` reaches `AlreadyClaimed` in this fixture, so the committed +/// snapshot is unavailable; pin the auth boundary instead (see +/// `snapshot_sweep_unclaimed_winnings_requires_admin_auth`). +#[test] +fn snapshot_claim_winnings_requires_user_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.client() + .vote(&user, &market_id, &f.yes(), &10_000_000i128); + + f.advance_past_end(); + f.client() + .resolve_market_manual(&f.admin, &market_id, &f.yes()); + f.advance_past_dispute(); + + let result = f.client().try_claim_winnings(&user, &market_id); + assert_auth_passed(&result, "claim_winnings"); +} + +#[test] +#[should_panic] +fn edge_claim_winnings_without_auth_panics() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.env.set_auths(&[]); + f.client().claim_winnings(&user, &market_id); +} + +/// `dispute_market` is rejected by its market-state validator in this fixture, +/// so the committed snapshot is unavailable; pin the auth boundary instead +/// (see `snapshot_sweep_unclaimed_winnings_requires_admin_auth`). +#[test] +fn snapshot_dispute_market_requires_user_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.client() + .vote(&user, &market_id, &f.yes(), &10_000_000i128); + f.advance_past_end(); + + let result = f + .client() + .try_dispute_market(&user, &market_id, &10_000_000i128, &None); + assert_auth_passed(&result, "dispute_market"); +} + +#[test] +#[should_panic] +fn edge_dispute_market_without_auth_panics() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.env.set_auths(&[]); + f.client() + .dispute_market(&user, &market_id, &10_000_000i128, &None); +} + +// ============================================================ +// Read-only entrypoints — snapshot: requires no auth +// ============================================================ + +#[test] +fn snapshot_get_market_requires_no_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let _ = f.client().get_market(&market_id); + f.assert_no_auth("get_market"); +} + +#[test] +fn snapshot_get_market_bet_stats_requires_no_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let _ = f.client().get_market_bet_stats(&market_id); + f.assert_no_auth("get_market_bet_stats"); +} + +// ============================================================ +// Edge cases +// ============================================================ + +/// A user entrypoint must bind `require_auth` to the acting user, never to the +/// admin — even though the admin is authorized in the same environment. +#[test] +fn snapshot_vote_subject_is_user_not_admin() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + f.client().vote(&user, &market_id, &f.yes(), &1_000_000i128); + + let required = f.required_auth(); + assert!( + required.contains(&user), + "vote must require the acting user" + ); + assert!( + !required.contains(&f.admin), + "vote must not require admin auth: {required:?}" + ); +} + +/// Two distinct users are authorized independently: user B's vote must record +/// B's auth, not A's, proving the subject tracks the argument. +#[test] +fn snapshot_vote_subject_tracks_argument_across_users() { + let f = Fixture::new(); + let market_id = f.market(); + let user_a = f.user(); + let user_b = f.user(); + + f.client() + .vote(&user_a, &market_id, &f.yes(), &1_000_000i128); + f.assert_requires_auth(&user_a, "vote(user_a)"); + + f.client().vote( + &user_b, + &market_id, + &String::from_str(&f.env, "no"), + &1_000_000i128, + ); + let required = f.required_auth(); + assert!( + required.contains(&user_b), + "vote must require user_b, captured {required:?}" + ); + assert!( + !required.contains(&user_a), + "user_a's auth must not satisfy user_b's vote: {required:?}" + ); +} + +/// A non-admin caller is rejected with `Error::Unauthorized` even while +/// `mock_all_auths` is active, because admin entrypoints check the caller +/// against the stored admin identity rather than trusting a bare signature. +#[test] +fn edge_non_admin_set_platform_fee_is_unauthorized() { + let f = Fixture::new(); + let attacker = Address::generate(&f.env); + let result = f.client().try_set_platform_fee(&attacker, &300i128); + assert_eq!( + result, + Err(Ok(Error::Unauthorized)), + "non-admin must not be able to set the platform fee" + ); +} + +/// With no auths mocked, a user entrypoint must fail in `require_auth` rather +/// than silently proceeding. +#[test] +#[should_panic] +fn edge_vote_without_auth_panics() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + + // Clear all mocked auths: the user's require_auth can no longer be satisfied. + f.env.set_auths(&[]); + f.client().vote(&user, &market_id, &f.yes(), &1_000_000i128); +} + +/// With no auths mocked, an admin entrypoint must fail in `require_auth`. +#[test] +#[should_panic] +fn edge_set_platform_fee_without_auth_panics() { + let f = Fixture::new(); + f.env.set_auths(&[]); + f.client().set_platform_fee(&f.admin, &300i128); +} diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index c3f563f3..9a328809 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -5610,6 +5610,46 @@ impl EventEmitter { env.events() .publish((symbol_short!("cum_set"), user.clone()), event); } + + /// Emit dispute vote cast event. + pub fn emit_dispute_vote_cast( + env: &Env, + dispute_id: &Symbol, + voter: &Address, + vote: bool, + stake: i128, + ) { + let event = DisputeVoteCastEvent { + dispute_id: dispute_id.clone(), + voter: voter.clone(), + vote, + stake, + nonce: Self::get_and_increment_nonce(env, symbol_short!("disp_vt").clone()), + timestamp: env.ledger().timestamp(), + }; + Self::store_event(env, &symbol_short!("disp_vt"), &event); + env.events() + .publish((symbol_short!("disp_vt"), dispute_id.clone()), event); + } + + /// Emit dispute fee distributed event. + pub fn emit_dispute_fee_distributed( + env: &Env, + dispute_id: &Symbol, + total_fees: i128, + fees_distributed: bool, + ) { + let event = DisputeFeeDistributedEvent { + dispute_id: dispute_id.clone(), + total_fees, + fees_distributed, + nonce: Self::get_and_increment_nonce(env, symbol_short!("disp_fee").clone()), + timestamp: env.ledger().timestamp(), + }; + Self::store_event(env, &symbol_short!("disp_fee"), &event); + env.events() + .publish((symbol_short!("disp_fee"), dispute_id.clone()), event); + } } #[cfg(test)] @@ -5632,10 +5672,6 @@ mod focused_dispute_tests { }); let events = env.events().all(); - // Expect at least one event with 3 topics: (topic0, topic1, topic2) - // topic0 = dispt_opn - // topic1 = mkt_123 - // topic2 = 1 (schema version) let mut found = false; for event in events.events().iter() { @@ -5651,4 +5687,131 @@ mod focused_dispute_tests { } assert!(found, "DisputeOpenedEvent not found with correct topic structure"); } + + #[test] + fn test_dispute_vote_cast_event_topics() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let dispute_id = Symbol::new(&env, "disp_123"); + let voter = Address::generate(&env); + let vote = true; + let stake = 100_000_000i128; + + env.as_contract(&contract_id, || { + EventEmitter::emit_dispute_vote_cast(&env, &dispute_id, &voter, vote, stake); + }); + + let events = env.events().all(); + let mut found = false; + for event in events.events().iter() { + if event.2.len() == 2 { + let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); + let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap(); + + if topic0 == symbol_short!("disp_vt") { + assert_eq!(topic1, dispute_id, "Dispute ID must be topic1"); + found = true; + } + } + } + assert!(found, "DisputeVoteCastEvent not found with correct topic structure"); + } + + #[test] + fn test_dispute_fee_distributed_event_topics() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let dispute_id = Symbol::new(&env, "disp_123"); + let total_fees = 1_000_000i128; + let fees_distributed = true; + + env.as_contract(&contract_id, || { + EventEmitter::emit_dispute_fee_distributed(&env, &dispute_id, total_fees, fees_distributed); + }); + + let events = env.events().all(); + let mut found = false; + for event in events.events().iter() { + if event.2.len() == 2 { + let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); + let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap(); + + if topic0 == symbol_short!("disp_fee") { + assert_eq!(topic1, dispute_id, "Dispute ID must be topic1"); + found = true; + } + } + } + assert!(found, "DisputeFeeDistributedEvent not found with correct topic structure"); + } } + +#[cfg(test)] +mod focused_betting_tests { + use super::*; + use soroban_sdk::{testutils::{Address as _, Events}, Address, Env, IntoVal, Symbol, TryIntoVal, Val, String}; + + #[test] + fn test_bet_placed_event_topics() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let market_id = Symbol::new(&env, "mkt_123"); + let bettor = Address::generate(&env); + let outcome = String::from_str(&env, "Yes"); + let amount = 10_000_000i128; + + env.as_contract(&contract_id, || { + EventEmitter::emit_bet_placed(&env, &market_id, &bettor, &outcome, amount); + }); + + let events = env.events().all(); + let mut found = false; + for event in events.events().iter() { + if event.2.len() == 2 { + let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); + let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap(); + + if topic0 == symbol_short!("bet_plc") { + assert_eq!(topic1, market_id, "Market ID must be topic1"); + found = true; + } + } + } + assert!(found, "BetPlacedEvent not found with correct topic structure"); + } + + #[test] + fn test_bet_status_updated_event_topics() { + let env = Env::default(); + let contract_id = env.register(crate::PredictifyHybrid, ()); + + let market_id = Symbol::new(&env, "mkt_123"); + let bettor = Address::generate(&env); + let old_status = String::from_str(&env, "Active"); + let new_status = String::from_str(&env, "Won"); + let payout = Some(15_000_000i128); + + env.as_contract(&contract_id, || { + EventEmitter::emit_bet_status_updated(&env, &market_id, &bettor, &old_status, &new_status, payout); + }); + + let events = env.events().all(); + let mut found = false; + for event in events.events().iter() { + if event.2.len() == 2 { + let topic0: Symbol = event.2.get(0).unwrap().try_into_val(&env).unwrap(); + let topic1: Symbol = event.2.get(1).unwrap().try_into_val(&env).unwrap(); + + if topic0 == symbol_short!("bet_upd") { + assert_eq!(topic1, market_id, "Market ID must be topic1"); + found = true; + } + } + } + assert!(found, "BetStatusUpdatedEvent not found with correct topic structure"); + } +} +