diff --git a/contracts/predictify-hybrid/Cargo.toml b/contracts/predictify-hybrid/Cargo.toml index f7b29a73..420985d9 100644 --- a/contracts/predictify-hybrid/Cargo.toml +++ b/contracts/predictify-hybrid/Cargo.toml @@ -27,6 +27,10 @@ soroban-sdk = { workspace = true } name = "err_stability" path = "tests/err_stability.rs" +[[test]] +name = "auth_snapshot_betting" +path = "tests/auth_snapshot_betting.rs" + [[test]] name = "stateful" path = "tests/stateful.rs" diff --git a/contracts/predictify-hybrid/tests/auth_snapshot_betting.rs b/contracts/predictify-hybrid/tests/auth_snapshot_betting.rs new file mode 100644 index 00000000..8c3fd7da --- /dev/null +++ b/contracts/predictify-hybrid/tests/auth_snapshot_betting.rs @@ -0,0 +1,499 @@ +//! Per-entrypoint authorization snapshot tests for betting operations. +//! +//! This integration suite extends `auth_snapshot.rs` by covering the +//! **betting-specific** state-changing entrypoints that were not included in the +//! core suite: batch bets, bet-limit administration, fee-config commit/reveal, +//! fee withdrawal, event cancellation, oracle-failure refunds, and auto +//! payout distribution. +//! +//! Read-only entrypoints (`get_bet`, `has_user_bet`, `get_implied_probability`, +//! `get_payout_multiplier`, `get_effective_bet_limits`, `get_market_max_bet_cap`, +//! `calculate_bet_payout`, `query_user_bets_paged`) are documented as +//! requiring **no** auth and are pinned accordingly. +//! +//! ## Entrypoint auth matrix (this file) +//! +//! | Entrypoint | Required auth subject | Verified by | +//! |--------------------------------|-----------------------|--------------------| +//! | `place_bets` | user | committed snapshot | +//! | `cancel_event` | admin | auth boundary | +//! | `refund_on_oracle_failure` | admin | auth boundary | +//! | `distribute_payouts` | none (public) | committed snapshot | +//! | `set_global_bet_limits` | admin | committed snapshot | +//! | `set_event_bet_limits` | admin | committed snapshot | +//! | `set_market_max_bet_cap` | admin | committed snapshot | +//! | `remove_market_max_bet_cap` | admin | committed snapshot | +//! | `set_governance_min_bet_bps` | admin | committed snapshot | +//! | `commit_fee_config` | admin | auth boundary | +//! | `withdraw_collected_fees` | admin | auth boundary | +//! | `get_bet` (read-only) | none | committed snapshot | +//! | `has_user_bet` (read-only) | none | committed snapshot | +//! | `get_implied_probability` | none | committed snapshot | +//! | `get_payout_multiplier` | none | committed snapshot | +//! | `get_effective_bet_limits` | none | committed snapshot | +//! | `get_market_max_bet_cap` | none | committed snapshot | +//! | `calculate_bet_payout` | none | committed snapshot | +//! | `query_user_bets_paged` | none | committed snapshot | + +use predictify_hybrid::{ + Error, OracleConfig, OracleProvider, PredictifyHybrid, PredictifyHybridClient, +}; +use soroban_sdk::{ + testutils::Address as _, token::StellarAssetClient, Address, Env, String, Symbol, Vec, +}; + +// ============================================================ +// Fixture (duplicated from auth_snapshot.rs to keep suites independent) +// ============================================================ + +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, ()); + + let token_id = env + .register_stellar_asset_contract_v2(Address::generate(&env)) + .address(); + + 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) + } + + 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"), + } + } + + 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") + } + + fn advance_past_end(&self) { + self.env + .ledger() + .with_mut(|l| l.timestamp += 31 * 24 * 60 * 60); + } + + fn advance_past_dispute(&self) { + self.env.ledger().with_mut(|l| l.timestamp += 86_401); + } + + fn required_auth(&self) -> std::vec::Vec
{ + self.env + .auths() + .iter() + .map(|(addr, _)| addr.clone()) + .collect() + } + + 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:?}" + ); + } + + 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:?}" + ); + } +} + +/// Assert the call got *past* `require_auth` (domain-logic failure, not a trap). +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:?}" + ); + } +} + +// ============================================================ +// Batch bet placement — committed snapshot +// ============================================================ + +#[test] +fn snapshot_place_bets_requires_user_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + + let mut bets = Vec::new(&f.env); + bets.push_back((market_id.clone(), f.yes(), 1_000_000i128)); + + let idempotency_key = soroban_sdk::BytesN::from_array(&f.env, &[1u8; 32]); + f.client() + .place_bets(&user, &bets, &250i128, &idempotency_key); + f.assert_requires_auth(&user, "place_bets"); +} + +// ============================================================ +// Event cancellation — auth boundary (domain logic rejects) +// ============================================================ + +#[test] +fn snapshot_cancel_event_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + + let result = f + .client() + .try_cancel_event(&f.admin, &market_id, &None); + assert_auth_passed(&result, "cancel_event"); +} + +#[test] +#[should_panic] +fn edge_cancel_event_without_auth_panics() { + let f = Fixture::new(); + let market_id = f.market(); + f.env.set_auths(&[]); + f.client() + .cancel_event(&f.admin, &market_id, &None); +} + +// ============================================================ +// Oracle failure refund — auth boundary +// ============================================================ + +#[test] +fn snapshot_refund_on_oracle_failure_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + + let result = f + .client() + .try_refund_on_oracle_failure(&f.admin, &market_id); + assert_auth_passed(&result, "refund_on_oracle_failure"); +} + +#[test] +#[should_panic] +fn edge_refund_on_oracle_failure_without_auth_panics() { + let f = Fixture::new(); + let market_id = f.market(); + f.env.set_auths(&[]); + f.client() + .refund_on_oracle_failure(&f.admin, &market_id); +} + +// ============================================================ +// Distribute payouts — committed snapshot (public entrypoint, no auth) +// ============================================================ + +#[test] +fn snapshot_distribute_payouts_requires_no_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + + // Build state: vote, end market, resolve, then call distribute_payouts + 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()); + + let _ = f.client().try_distribute_payouts(&market_id); + f.assert_no_auth("distribute_payouts"); +} + +// ============================================================ +// Bet-limit administration — committed snapshots +// ============================================================ + +#[test] +fn snapshot_set_global_bet_limits_requires_admin_auth() { + let f = Fixture::new(); + f.client() + .set_global_bet_limits(&f.admin, &100i128, &10_000_000i128); + f.assert_requires_auth(&f.admin, "set_global_bet_limits"); +} + +#[test] +fn snapshot_set_event_bet_limits_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + f.client() + .set_event_bet_limits(&f.admin, &market_id, &200i128, &5_000_000i128); + f.assert_requires_auth(&f.admin, "set_event_bet_limits"); +} + +#[test] +fn snapshot_set_market_max_bet_cap_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + f.client() + .set_market_max_bet_cap(&f.admin, &market_id, &1_000_000i128); + f.assert_requires_auth(&f.admin, "set_market_max_bet_cap"); +} + +#[test] +fn snapshot_remove_market_max_bet_cap_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + f.client() + .set_market_max_bet_cap(&f.admin, &market_id, &1_000_000i128); + f.client() + .remove_market_max_bet_cap(&f.admin, &market_id); + f.assert_requires_auth(&f.admin, "remove_market_max_bet_cap"); +} + +// ============================================================ +// Governance minimum bet — committed snapshot +// ============================================================ + +#[test] +fn snapshot_set_governance_min_bet_bps_requires_admin_auth() { + let f = Fixture::new(); + f.client().set_governance_min_bet_bps(&f.admin, &500u32); + f.assert_requires_auth(&f.admin, "set_governance_min_bet_bps"); +} + +// ============================================================ +// Fee config commit — auth boundary +// ============================================================ + +#[test] +fn snapshot_commit_fee_config_requires_admin_auth() { + let f = Fixture::new(); + let hash = soroban_sdk::BytesN::from_array(&f.env, &[0u8; 32]); + let result = f.client().try_commit_fee_config(&f.admin, &hash); + assert_auth_passed(&result, "commit_fee_config"); +} + +#[test] +#[should_panic] +fn edge_commit_fee_config_without_auth_panics() { + let f = Fixture::new(); + let hash = soroban_sdk::BytesN::from_array(&f.env, &[0u8; 32]); + f.env.set_auths(&[]); + f.client().commit_fee_config(&f.admin, &hash); +} + +// Note: `reveal_fee_config` requires `fees::FeeConfig` which lives in a private +// module and is not accessible from integration tests. Its auth requirement +// (admin) is structurally guaranteed by the same `require_primary_admin` call +// shared with `commit_fee_config`. + +// ============================================================ +// Withdraw collected fees — auth boundary +// ============================================================ + +#[test] +fn snapshot_withdraw_collected_fees_requires_admin_auth() { + let f = Fixture::new(); + let market_id = f.market(); + + // Build enough state so fees exist + 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); + + let result = f.client().try_withdraw_collected_fees(&f.admin, &1i128); + assert_auth_passed(&result, "withdraw_collected_fees"); +} + +#[test] +#[should_panic] +fn edge_withdraw_collected_fees_without_auth_panics() { + let f = Fixture::new(); + f.env.set_auths(&[]); + f.client() + .withdraw_collected_fees(&f.admin, &1i128); +} + +// ============================================================ +// Read-only betting entrypoints — require no auth +// ============================================================ + +#[test] +fn snapshot_get_bet_requires_no_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); + let _ = f.client().get_bet(&market_id, &user); + f.assert_no_auth("get_bet"); +} + +#[test] +fn snapshot_has_user_bet_requires_no_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let user = f.user(); + let _ = f.client().has_user_bet(&market_id, &user); + f.assert_no_auth("has_user_bet"); +} + +#[test] +fn snapshot_get_implied_probability_requires_no_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let _ = f.client().get_implied_probability(&market_id, &f.yes()); + f.assert_no_auth("get_implied_probability"); +} + +#[test] +fn snapshot_get_payout_multiplier_requires_no_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let _ = f.client().get_payout_multiplier(&market_id, &f.yes()); + f.assert_no_auth("get_payout_multiplier"); +} + +#[test] +fn snapshot_get_effective_bet_limits_requires_no_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let _ = f.client().get_effective_bet_limits(&market_id); + f.assert_no_auth("get_effective_bet_limits"); +} + +#[test] +fn snapshot_get_market_max_bet_cap_requires_no_auth() { + let f = Fixture::new(); + let market_id = f.market(); + let _ = f.client().get_market_max_bet_cap(&market_id); + f.assert_no_auth("get_market_max_bet_cap"); +} + +#[test] +fn snapshot_calculate_bet_payout_requires_no_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); + let _ = f.client().calculate_bet_payout(&market_id, &user); + f.assert_no_auth("calculate_bet_payout"); +} + +#[test] +fn snapshot_query_user_bets_paged_requires_no_auth() { + let f = Fixture::new(); + let user = f.user(); + let _ = f.client().query_user_bets_paged(&user, &0u32, &10u32); + f.assert_no_auth("query_user_bets_paged"); +} + +// ============================================================ +// Edge cases — non-admin rejection +// ============================================================ + +#[test] +fn edge_non_admin_set_global_bet_limits_is_unauthorized() { + let f = Fixture::new(); + let attacker = Address::generate(&f.env); + let result = f + .client() + .try_set_global_bet_limits(&attacker, &100i128, &10_000_000i128); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn edge_non_admin_set_market_max_bet_cap_is_unauthorized() { + let f = Fixture::new(); + let market_id = f.market(); + let attacker = Address::generate(&f.env); + let result = f + .client() + .try_set_market_max_bet_cap(&attacker, &market_id, &1_000_000i128); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn edge_non_admin_cancel_event_is_unauthorized() { + let f = Fixture::new(); + let market_id = f.market(); + let attacker = Address::generate(&f.env); + let result = f + .client() + .try_cancel_event(&attacker, &market_id, &None); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +} + +#[test] +fn edge_non_admin_commit_fee_config_is_unauthorized() { + let f = Fixture::new(); + let attacker = Address::generate(&f.env); + let hash = soroban_sdk::BytesN::from_array(&f.env, &[0u8; 32]); + let result = f.client().try_commit_fee_config(&attacker, &hash); + assert_eq!(result, Err(Ok(Error::Unauthorized))); +}