From 2acf6637be1a7be2e167a23dc3b8208d626c3ee1 Mon Sep 17 00:00:00 2001 From: Baskarayelu Date: Sat, 31 Jan 2026 12:03:25 +0530 Subject: [PATCH] feat: add refund mechanism for oracle resolution failures - Introduced a new constant for default resolution timeout. - Implemented refund_on_oracle_failure function to handle automatic refunds when oracle resolution fails or times out. - Added RefundOnOracleFailureEvent to emit refund details. - Created comprehensive tests to validate refund functionality for various scenarios, including admin-triggered refunds and timeout conditions. --- contracts/predictify-hybrid/src/config.rs | 4 + contracts/predictify-hybrid/src/events.rs | 29 ++++ contracts/predictify-hybrid/src/lib.rs | 69 ++++++++++ contracts/predictify-hybrid/src/test.rs | 161 ++++++++++++++++++++++ 4 files changed, 263 insertions(+) diff --git a/contracts/predictify-hybrid/src/config.rs b/contracts/predictify-hybrid/src/config.rs index 4626349a..14b17ded 100644 --- a/contracts/predictify-hybrid/src/config.rs +++ b/contracts/predictify-hybrid/src/config.rs @@ -112,6 +112,10 @@ pub const COMMUNITY_WEIGHT_PERCENTAGE: u32 = 30; /// Minimum votes for community consensus pub const MIN_VOTES_FOR_CONSENSUS: u32 = 5; +/// Default resolution timeout in seconds (7 days). After market end_time + this period +/// with no oracle result, anyone may trigger refund on oracle failure. +pub const DEFAULT_RESOLUTION_TIMEOUT_SECONDS: u64 = 604_800; + // ===== ORACLE CONSTANTS ===== /// Maximum oracle price age (1 hour) diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 9698d5fb..b98ee574 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -835,6 +835,21 @@ pub struct MarketClosedEvent { pub timestamp: u64, } +/// Event emitted when a market is refunded due to oracle resolution failure or timeout. +/// +/// Emitted after all bets are refunded in full (no fee deduction). The market is marked +/// as cancelled and no further resolution is possible. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RefundOnOracleFailureEvent { + /// Market ID + pub market_id: Symbol, + /// Total amount refunded to all participants + pub total_refunded: i128, + /// Event timestamp + pub timestamp: u64, +} + /// Market finalized event #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] @@ -1819,6 +1834,20 @@ impl EventEmitter { Self::store_event(env, &symbol_short!("mkt_close"), &event); } + /// Emit refund on oracle failure event (market cancelled, all bets refunded in full). + pub fn emit_refund_on_oracle_failure( + env: &Env, + market_id: &Symbol, + total_refunded: i128, + ) { + let event = RefundOnOracleFailureEvent { + market_id: market_id.clone(), + total_refunded, + timestamp: env.ledger().timestamp(), + }; + Self::store_event(env, &symbol_short!("ref_oracl"), &event); + } + /// Emit market finalized event pub fn emit_market_finalized(env: &Env, market_id: &Symbol, admin: &Address, outcome: &String) { let event = MarketFinalizedEvent { diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 660b88a1..c2dfaf05 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -2635,6 +2635,75 @@ impl PredictifyHybrid { Ok(total_refunded) } + /// Refund all bets when oracle resolution fails or times out (automatic refund path). + /// + /// Callable when: market has ended, no oracle result, and either (1) resolution + /// timeout has passed since market end, or (2) caller is admin (confirmed failure). + /// Refunds full bet amount per user (no fee deduction). Marks market as cancelled and + /// prevents further resolution. Emits refund events. Idempotent when already cancelled. + pub fn refund_on_oracle_failure( + env: Env, + caller: Address, + market_id: Symbol, + ) -> Result { + caller.require_auth(); + + let mut market: Market = env + .storage() + .persistent() + .get(&market_id) + .ok_or(Error::MarketNotFound)?; + + if market.state == MarketState::Cancelled { + return Ok(0); + } + if market.winning_outcome.is_some() { + return Err(Error::MarketAlreadyResolved); + } + if market.oracle_result.is_some() { + return Err(Error::MarketAlreadyResolved); + } + let current_time = env.ledger().timestamp(); + if current_time < market.end_time { + return Err(Error::MarketClosed); + } + + let stored_admin: Option
= env.storage().persistent().get(&Symbol::new(&env, "Admin")); + let is_admin = stored_admin.as_ref().map_or(false, |a| a == &caller); + let timeout_passed = current_time + .saturating_sub(market.end_time) + >= config::DEFAULT_RESOLUTION_TIMEOUT_SECONDS; + if !is_admin && !timeout_passed { + return Err(Error::Unauthorized); + } + + let old_state = market.state.clone(); + market.state = MarketState::Cancelled; + env.storage().persistent().set(&market_id, &market); + + if reentrancy_guard::ReentrancyGuard::check_reentrancy_state(&env).is_err() { + return Err(Error::InvalidState); + } + if reentrancy_guard::ReentrancyGuard::before_external_call(&env).is_err() { + return Err(Error::InvalidState); + } + let refund_result = bets::BetManager::refund_market_bets(&env, &market_id); + reentrancy_guard::ReentrancyGuard::after_external_call(&env); + refund_result?; + + let total_refunded = market.total_staked; + EventEmitter::emit_state_change_event( + &env, + &market_id, + &old_state, + &MarketState::Cancelled, + &String::from_str(&env, "Refund on oracle failure/timeout"), + ); + EventEmitter::emit_refund_on_oracle_failure(&env, &market_id, total_refunded); + + Ok(total_refunded) + } + /// Extend market duration (admin only) pub fn extend_market( env: Env, diff --git a/contracts/predictify-hybrid/src/test.rs b/contracts/predictify-hybrid/src/test.rs index 73bb8284..c408ab28 100644 --- a/contracts/predictify-hybrid/src/test.rs +++ b/contracts/predictify-hybrid/src/test.rs @@ -1451,6 +1451,167 @@ fn test_cancel_event_already_cancelled() { assert_eq!(total_refunded, 0); } +// ===== TESTS FOR REFUND ON ORACLE FAILURE (#257, #258) ===== + +#[test] +fn test_refund_on_oracle_failure_admin_success() { + let test = PredictifyTest::setup(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + let market_id = test.create_test_market(); + + let user1 = test.create_funded_user(); + let user2 = test.create_funded_user(); + test.env.mock_all_auths(); + client.place_bet( + &user1, + &market_id, + &String::from_str(&test.env, "yes"), + &10_000_000, + ); + client.place_bet( + &user2, + &market_id, + &String::from_str(&test.env, "no"), + &20_000_000, + ); + + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + test.env.mock_all_auths(); + let total_refunded = client.refund_on_oracle_failure(&test.admin, &market_id); + assert_eq!(total_refunded, 30_000_000); + + let market_after = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + assert_eq!(market_after.state, MarketState::Cancelled); +} + +#[test] +fn test_refund_on_oracle_failure_full_amount_per_user() { + let test = PredictifyTest::setup(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + let market_id = test.create_test_market(); + let user1 = test.create_funded_user(); + let user2 = test.create_funded_user(); + let amt1 = 10_000_000i128; + let amt2 = 20_000_000i128; + test.env.mock_all_auths(); + client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &amt1); + client.place_bet(&user2, &market_id, &String::from_str(&test.env, "no"), &amt2); + + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + test.env.mock_all_auths(); + let total_refunded = client.refund_on_oracle_failure(&test.admin, &market_id); + assert_eq!(total_refunded, amt1 + amt2); +} + +#[test] +fn test_refund_on_oracle_failure_no_double_refund() { + let test = PredictifyTest::setup(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + let market_id = test.create_test_market(); + let user1 = test.create_funded_user(); + test.env.mock_all_auths(); + client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + test.env.mock_all_auths(); + let first = client.refund_on_oracle_failure(&test.admin, &market_id); + assert_eq!(first, 10_000_000); + + test.env.mock_all_auths(); + let second = client.refund_on_oracle_failure(&test.admin, &market_id); + assert_eq!(second, 0); +} + +#[test] +fn test_refund_on_oracle_failure_after_timeout_any_caller() { + let test = PredictifyTest::setup(); + let client = PredictifyHybridClient::new(&test.env, &test.contract_id); + let market_id = test.create_test_market(); + let user1 = test.create_funded_user(); + let any_caller = test.create_funded_user(); + test.env.mock_all_auths(); + client.place_bet(&user1, &market_id, &String::from_str(&test.env, "yes"), &10_000_000); + + let market = test.env.as_contract(&test.contract_id, || { + test.env + .storage() + .persistent() + .get::(&market_id) + .unwrap() + }); + test.env.ledger().set(LedgerInfo { + timestamp: market.end_time + crate::config::DEFAULT_RESOLUTION_TIMEOUT_SECONDS + 1, + protocol_version: 22, + sequence_number: test.env.ledger().sequence(), + network_id: Default::default(), + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10000, + }); + + test.env.mock_all_auths(); + let total_refunded = client.refund_on_oracle_failure(&any_caller, &market_id); + assert_eq!(total_refunded, 10_000_000); +} + // ===== TESTS FOR MANUAL DISPUTE RESOLUTION (#218, #219) ===== #[test]