Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions contracts/predictify-hybrid/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 29 additions & 0 deletions contracts/predictify-hybrid/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i128, Error> {
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<Address> = 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,
Expand Down
161 changes: 161 additions & 0 deletions contracts/predictify-hybrid/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Symbol, Market>(&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::<Symbol, Market>(&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::<Symbol, Market>(&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::<Symbol, Market>(&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::<Symbol, Market>(&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]
Expand Down
Loading