From c59f819e6edddc45c5a185afb5c1f8eed43f8967 Mon Sep 17 00:00:00 2001 From: Hallab Date: Wed, 26 Aug 2026 09:53:09 +0100 Subject: [PATCH 1/5] fix(precision): harden commit reveal forfeiture --- contracts/src/betting.rs | 6 ++-- contracts/src/settlement.rs | 15 +++++++-- contracts/src/tests/commit_reveal_e2e.rs | 40 ++++++++++++++++++---- contracts/src/tests/mod.rs | 2 +- docs/EVENT_SCHEMA.md | 42 ++++++++++++++++++++++++ 5 files changed, 92 insertions(+), 13 deletions(-) diff --git a/contracts/src/betting.rs b/contracts/src/betting.rs index f21c709..5cbd25a 100644 --- a/contracts/src/betting.rs +++ b/contracts/src/betting.rs @@ -486,7 +486,7 @@ pub fn commit_prediction( // Reject clearly invalid commitment placeholders early (before balance // reads / deductions) so griefing commits cannot lock liquidity. if is_zero_bytes32(&env, &hash) { - return Err(ContractError::InvalidPrice); + return Err(ContractError::InvalidCommitment); } if amount <= 0 { @@ -602,7 +602,7 @@ pub fn reveal_prediction( // Enforce salt entropy before any storage reads so malformed reveals fail // fast with an explicit error (not HashMismatch after a wasted lookup). if !salt_has_minimum_entropy(&salt) { - return Err(ContractError::InvalidPrice); + return Err(ContractError::InvalidSalt); } // Single read of the active round @@ -927,4 +927,4 @@ pub fn mint_initial(env: Env, user: Address) -> i128 { ); initial_amount -} \ No newline at end of file +} diff --git a/contracts/src/settlement.rs b/contracts/src/settlement.rs index 712f6a0..626e2f5 100644 --- a/contracts/src/settlement.rs +++ b/contracts/src/settlement.rs @@ -1330,7 +1330,7 @@ pub fn _resolve_precision_mode( round_id: u64, final_price: u128, skip_payout: bool, -) -> Result { +) -> Result<(i128, i128), ContractError> { let mut participants: Vec
= env .storage() .persistent() @@ -1356,6 +1356,7 @@ pub fn _resolve_precision_mode( let p_len = participants.len() as usize; let mut participant_amounts: StdVec = StdVec::with_capacity(p_len); let mut participant_prices: StdVec = StdVec::with_capacity(p_len); + let mut participant_revealed: StdVec = StdVec::with_capacity(p_len); let mut is_winner_mask: StdVec = StdVec::with_capacity(p_len); for i in 0..participants.len() { @@ -1384,12 +1385,14 @@ pub fn _resolve_precision_mode( .as_ref() .map(|p| p.predicted_price) .unwrap_or(0u128); + let revealed = pred_opt.is_some(); total_pot = total_pot .checked_add(amount) .ok_or(ContractError::Overflow)?; participant_amounts.push(amount); participant_prices.push(cached_price); + participant_revealed.push(revealed); is_winner_mask.push(false); if let Some(pred) = pred_opt { @@ -1476,6 +1479,14 @@ pub fn _resolve_precision_mode( let stake = participant_amounts[idx]; let predicted_price = participant_prices[idx]; + if !participant_revealed[idx] { + #[allow(deprecated)] + env.events().publish( + (symbol_short!("forfeit"), symbol_short!("predict")), + (user.clone(), round_id, stake), + ); + } + #[allow(deprecated)] env.events().publish( (symbol_short!("outcome"), symbol_short!("loss")), @@ -2248,4 +2259,4 @@ pub fn _update_stats_loss(env: &Env, user: Address) -> Result<(), ContractError> crate::leaderboard::_update_leaderboards(env, user.clone()); crate::leaderboard::_update_season_stats_loss(env, user)?; Ok(()) -} \ No newline at end of file +} diff --git a/contracts/src/tests/commit_reveal_e2e.rs b/contracts/src/tests/commit_reveal_e2e.rs index 2ab04bc..efd3fe1 100644 --- a/contracts/src/tests/commit_reveal_e2e.rs +++ b/contracts/src/tests/commit_reveal_e2e.rs @@ -36,8 +36,9 @@ //! `env.events().all()` only returns events from the *current* ledger. //! //! Per-event topic coverage (the precise shape of `mint/initial`, -//! `round/created`, `commit/predict`, `reveal/predict`, `round/summary`, -//! `claim/winnings` payloads) lives in dedicated test modules: +//! `round/created`, `commit/predict`, `reveal/predict`, `forfeit/predict`, +//! `round/summary`, `claim/winnings` payloads) lives in dedicated test +//! modules: //! //! - [`contracts/src/tests/event_coverage.rs`] — one test per event topic, //! each asserting the full data payload. @@ -54,8 +55,9 @@ use soroban_sdk::xdr::ToXdr; use soroban_sdk::{ - testutils::{Address as _, Ledger as _}, - Address, Bytes, BytesN, Env, + symbol_short, + testutils::{Address as _, Events as _, Ledger as _}, + Address, Bytes, BytesN, Env, TryFromVal, }; use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; @@ -687,12 +689,16 @@ fn test_commit_reveal_e2e_zero_commitment_hash_is_rejected() { let zero_hash = BytesN::from_array(&env, &[0u8; 32]); let result = client.try_commit_prediction(&user, &zero_hash, &ALICE_BET); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); + assert_eq!(result, Err(Ok(ContractError::InvalidCommitment))); + assert_eq!(ContractError::InvalidCommitment as u32, 63); assert_eq!(client.balance(&user), INITIAL_BALANCE); } #[test] fn test_commit_reveal_e2e_weak_salt_is_rejected() { + // Grinding defense: obvious low-entropy placeholders cannot be opened. + // Non-trivial entropy cannot be proven on-chain, so production clients + // must still generate salts with a CSPRNG as documented in PROTOCOL_SPEC. let env = Env::default(); env.mock_all_auths(); let contract_id = env.register(VirtualTokenContract, ()); @@ -718,14 +724,15 @@ fn test_commit_reveal_e2e_weak_salt_is_rejected() { let zero_salt = BytesN::from_array(&env, &[0u8; 32]); assert_eq!( client.try_reveal_prediction(&user, &price, &zero_salt), - Err(Ok(ContractError::InvalidPrice)) + Err(Ok(ContractError::InvalidSalt)) ); let constant_salt = BytesN::from_array(&env, &[0xABu8; 32]); assert_eq!( client.try_reveal_prediction(&user, &price, &constant_salt), - Err(Ok(ContractError::InvalidPrice)) + Err(Ok(ContractError::InvalidSalt)) ); + assert_eq!(ContractError::InvalidSalt as u32, 64); // Correct entropy salt still reveals successfully. client.reveal_prediction(&user, &price, &good_salt); @@ -839,6 +846,25 @@ fn test_commit_reveal_e2e_mixed_reveal_forfeits_unrevealed_to_pot() { assert_eq!(client.get_pending_winnings(&alice), total_pot); assert_eq!(client.get_pending_winnings(&bob), 0); + let forfeits = env.events().all().iter().filter(|(_, topics, data)| { + topics.len() == 2 + && topics + .get(0) + .and_then(|topic| soroban_sdk::Symbol::try_from_val(&env, &topic).ok()) + == Some(symbol_short!("forfeit")) + && topics + .get(1) + .and_then(|topic| soroban_sdk::Symbol::try_from_val(&env, &topic).ok()) + == Some(symbol_short!("predict")) + && <(Address, u64, i128)>::try_from_val(&env, data) + == Ok((bob.clone(), 1u64, BOB_BET)) + }); + assert_eq!( + forfeits.count(), + 1, + "Bob must emit exactly one forfeit event" + ); + client.claim_winnings(&alice); assert_eq!( client.balance(&alice) diff --git a/contracts/src/tests/mod.rs b/contracts/src/tests/mod.rs index 6cedefd..33c0a62 100644 --- a/contracts/src/tests/mod.rs +++ b/contracts/src/tests/mod.rs @@ -8,7 +8,7 @@ mod access_control; mod betting; mod cei_ordering; mod chaos_recovery; -// mod commit_reveal_e2e; // upstream bug: all-unrevealed refunds test expects behavior contract doesn't implement +mod commit_reveal_e2e; mod config_helpers; // mod config_timelock; // upstream bug mod conservation; diff --git a/docs/EVENT_SCHEMA.md b/docs/EVENT_SCHEMA.md index 8057c3e..ba1bbcb 100644 --- a/docs/EVENT_SCHEMA.md +++ b/docs/EVENT_SCHEMA.md @@ -87,6 +87,48 @@ Emitted when a user submits a Precision mode price prediction. --- +### `("commit", "predict")` + +Emitted when a user locks a Precision stake behind a commitment hash. + +| Position | Field | Type | Description | +|----------|--------------|--------------|--------------------------------------------------| +| 0 | `user` | `Address` | User who submitted the commitment | +| 1 | `round_id` | `u64` | Round the commitment belongs to | +| 2 | `commitment` | `BytesN<32>` | SHA-256 commitment digest | +| 3 | `amount` | `i128` | Locked stake in stroops | + +--- + +### `("reveal", "predict")` + +Emitted after a commitment is successfully opened during the reveal window. +The salt is intentionally omitted from the event payload. + +| Position | Field | Type | Description | +|----------|-------------------|-----------|--------------------------------------------------| +| 0 | `user` | `Address` | User who revealed the commitment | +| 1 | `round_id` | `u64` | Round the revealed prediction belongs to | +| 2 | `predicted_price` | `u128` | Revealed price (4 decimal places) | +| 3 | `amount` | `i128` | Locked stake in stroops | + +--- + +### `("forfeit", "predict")` + +Emitted at competitive Precision settlement for each commitment that was not +revealed. The stake remains in the pot and can only be paid to eligible, +revealed winners. It is not emitted when nobody reveals and all stakes are +refunded, or on cancellation/fallback refund paths. + +| Position | Field | Type | Description | +|----------|------------|-----------|--------------------------------------------------| +| 0 | `user` | `Address` | User whose unrevealed commitment was forfeited | +| 1 | `round_id` | `u64` | Round settled | +| 2 | `amount` | `i128` | Forfeited stake in stroops | + +--- + ### `("round", "summary")` ### `("round", "resolved")` From 789df83ae7b38b67162c7afd96fd3ad8c48c52c4 Mon Sep 17 00:00:00 2001 From: Hallab Date: Wed, 26 Aug 2026 10:11:38 +0100 Subject: [PATCH 2/5] test(security): deduplicate oracle scenarios --- contracts/src/tests/security.rs | 5495 +------------------------------ 1 file changed, 47 insertions(+), 5448 deletions(-) diff --git a/contracts/src/tests/security.rs b/contracts/src/tests/security.rs index af0fd21..d86dda4 100644 --- a/contracts/src/tests/security.rs +++ b/contracts/src/tests/security.rs @@ -4,8 +4,6 @@ use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold}; use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; use crate::errors::ContractError; -use crate::types::{DataKey, HbGateConfig, HbGateKey, OraclePayload}; -use crate::types::{DataKeyCore, DataKeyScoped, OraclePayload}; use crate::types::{DataKeyCore, DataKeyScoped, HbGateConfig, HbGateKey, OraclePayload}; use soroban_sdk::{ symbol_short, @@ -181,6 +179,7 @@ fn test_cancelled_round_cannot_be_resolved() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); } @@ -262,6 +261,7 @@ fn test_resolve_round_duplicate_nonce_rejected() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); } @@ -295,6 +295,7 @@ fn test_resolve_round_unique_nonce_resolves() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Round resolved and the nonce is recorded as consumed for that round. @@ -582,6 +583,7 @@ fn test_heartbeat_active_fresh_allows_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -612,6 +614,7 @@ fn test_heartbeat_degraded_fresh_allows_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); } @@ -652,6 +655,7 @@ fn test_heartbeat_stale_within_grace_allows_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); } @@ -690,6 +694,7 @@ fn test_heartbeat_stale_beyond_grace_blocks_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); } @@ -728,6 +733,7 @@ fn test_heartbeat_stale_within_grace_strict_blocks_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); } @@ -763,6 +769,7 @@ fn test_heartbeat_degraded_fresh_strict_blocks_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); } @@ -797,6 +804,7 @@ fn test_heartbeat_offline_blocks_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); } @@ -827,6 +835,7 @@ fn test_heartbeat_none_blocks_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); } @@ -858,6 +867,7 @@ fn test_heartbeat_override_allows_resolve() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -887,6 +897,7 @@ fn test_heartbeat_override_cleared_after_use() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Verify override is consumed (one-shot) @@ -908,6 +919,7 @@ fn test_heartbeat_override_cleared_after_use() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // No override and no heartbeat → must fail assert_eq!(result, Err(Ok(ContractError::OracleHeartbeatUnhealthy))); @@ -939,6 +951,7 @@ fn test_heartbeat_override_emits_event() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); let events = env.events().all(); @@ -1053,6 +1066,7 @@ fn test_oracle_deviation_rejected_when_over_threshold() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); } @@ -1089,6 +1103,7 @@ fn test_oracle_deviation_allows_at_exact_threshold() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -1124,6 +1139,7 @@ fn test_oracle_deviation_rounding_floor_is_deterministic() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -1159,6 +1175,7 @@ fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Verify override event emitted (check before env.as_contract which resets event scope) @@ -1254,6 +1271,7 @@ fn test_resolve_round_nonce_boundary_values() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); @@ -1265,6 +1283,7 @@ fn test_resolve_round_nonce_boundary_values() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); } @@ -1301,6 +1320,7 @@ fn test_resolve_round_wrong_network_id_rejected() { network_id: wrong_network, contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); } @@ -1335,6 +1355,7 @@ fn test_resolve_round_wrong_contract_addr_rejected() { network_id: env.ledger().network_id(), contract_addr: wrong_contract, confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); } @@ -1368,6 +1389,7 @@ fn test_resolve_round_valid_domain_context_resolves() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -1404,6 +1426,7 @@ fn test_resolve_round_both_network_and_contract_wrong() { network_id: wrong_network, contract_addr: wrong_contract, confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); } @@ -1620,6 +1643,7 @@ fn test_confidence_below_threshold_rejected() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: Some(5000u32), + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); } @@ -1651,6 +1675,7 @@ fn test_confidence_above_threshold_accepted() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: Some(9000u32), + attestation: None, }); } @@ -1683,6 +1708,7 @@ fn test_missing_confidence_accepted_when_not_strict() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); } @@ -1714,6 +1740,7 @@ fn test_missing_confidence_rejected_in_strict_mode() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); } @@ -1772,6 +1799,7 @@ fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -1810,6 +1838,7 @@ fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); } @@ -1848,6 +1877,7 @@ fn test_heartbeat_gate_strict_on_allows_when_live() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -1886,6 +1916,7 @@ fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); } @@ -1918,6 +1949,7 @@ fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); } @@ -1952,6 +1984,7 @@ fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); @@ -2010,6 +2043,7 @@ fn test_heartbeat_gate_override_is_one_shot() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); // Second round: no heartbeat, no override — should block @@ -2032,6 +2066,7 @@ fn test_heartbeat_gate_override_is_one_shot() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); } @@ -2074,6 +2109,7 @@ fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -2114,6 +2150,7 @@ fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); } @@ -2153,6 +2190,7 @@ fn test_heartbeat_gate_degraded_status_allowed_when_current() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -2191,6 +2229,7 @@ fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); } @@ -2223,6 +2262,7 @@ fn test_heartbeat_gate_hblocked_event_emitted() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); let events = env.events().all(); @@ -2348,20 +2388,9 @@ fn test_no_confidence_check_when_threshold_unset() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: Some(0u32), + attestation: None, }); } -// SPDX-License-Identifier: MIT -//! Security tests for Oracle data freshness and round validation. - -use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold}; -use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::errors::ContractError; -use crate::types::{DataKey, HbGateConfig, HbGateKey, OraclePayload}; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events, Ledger as _}, - Address, BytesN, Env, IntoVal, TryIntoVal, -}; #[test] fn test_resolve_round_timestamp_after_round_window() { @@ -2393,6 +2422,7 @@ fn test_resolve_round_timestamp_after_round_window() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }; let result = client.try_resolve_round(&payload); @@ -2439,6 +2469,7 @@ fn test_resolve_round_timestamp_before_round_window() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }; let result = client.try_resolve_round(&payload); @@ -2481,6 +2512,7 @@ fn test_resolve_round_timestamp_boundary_lower() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } @@ -2514,5440 +2546,7 @@ fn test_resolve_round_timestamp_boundary_upper() { network_id: env.ledger().network_id(), contract_addr: contract_id.clone(), confidence: None, + attestation: None, }); assert_eq!(client.get_active_round(), None); } - -#[test] -fn test_resolve_round_invalid_round_id() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - // Submit payload with wrong round_id (e.g., 999 instead of 0) - let payload = OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 999, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::InvalidOracleRound))); -} - -#[test] -fn test_resolve_round_valid_payload() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Valid payload: within round window [0, 60+300] and correct round_id - let payload = OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - client.resolve_round(&payload); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_future_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Current ledger time is 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Submit payload with timestamp 1001 (future) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 1001, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::FutureOracleData))); -} - -// ─── Cancel-round security tests (Issue #111) ──────────────────────────────── - -#[test] -fn test_cancelled_round_cannot_be_resolved() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - client.cancel_round(&0u32); - - // After cancellation there is no active round, so resolve_round returns NoActiveRound - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); -} - -#[test] -fn test_cancel_round_without_admin_auth_fails() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - // Initialize with only admin auth - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "create_round", - args: (1_0000000u128, Option::::None).into_val(&env), - sub_invokes: &[], - }, - }]); - client.create_round(&1_0000000, &None); - - // No auth for cancel_round - let result = client.try_cancel_round(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_resolve_round_duplicate_nonce_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Simulate a prior submission having consumed nonce 42 for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 42u64), &true); - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 42u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); -} - -#[test] -fn test_resolve_round_unique_nonce_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 7u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Round resolved and the nonce is recorded as consumed for that round. - assert_eq!(client.get_active_round(), None); - env.as_contract(&contract_id, || { - let consumed: bool = env - .storage() - .persistent() - .get(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 7u64)) - .unwrap_or(false); - assert!(consumed, "resolved nonce must be marked consumed"); - }); -} - -#[test] -fn test_oracle_heartbeat_requires_oracle_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No oracle auth set up — must fail - let result = client.try_update_oracle_heartbeat(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_oracle_heartbeat_updates_timestamp_and_status() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - - client.update_oracle_heartbeat(&0u32); - - let record = client.get_oracle_heartbeat().expect("heartbeat must exist"); - assert_eq!(record.timestamp, 500); - assert_eq!(record.status, 0); - - // Update to degraded status - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - client.update_oracle_heartbeat(&1u32); - - let record = client.get_oracle_heartbeat().unwrap(); - assert_eq!(record.timestamp, 1000); - assert_eq!(record.status, 1); -} - -#[test] -fn test_oracle_heartbeat_invalid_status_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let result = client.try_update_oracle_heartbeat(&3u32); - assert_eq!(result, Err(Ok(ContractError::InvalidMode))); -} - -#[test] -fn test_oracle_liveness_within_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check liveness 100 s later (well within 3600 s default) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_stale_after_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check 4000 s later — beyond 3600 s default threshold - env.ledger().with_mut(|li| { - li.timestamp = 4000; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_offline_status_not_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - // Record offline status - client.update_oracle_heartbeat(&2u32); - - // Even within threshold, offline means not live - env.ledger().with_mut(|li| { - li.timestamp = 10; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_no_heartbeat_returns_false() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded — must return false - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_heartbeat_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.update_oracle_heartbeat(&0u32); - - let events = env.events().all(); - let hb_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hbeat")) - }); - assert!( - hb_event.is_some(), - "Heartbeat event must be emitted on update_oracle_heartbeat" - ); -} - -#[test] -fn test_set_oracle_stale_threshold_admin_only() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No admin auth for set_oracle_stale_threshold - let result = client.try_set_oracle_stale_threshold(&1800u64); - assert!(result.is_err()); -} - -#[test] -fn test_set_oracle_stale_threshold_validation() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Below minimum (< 60) - let result = client.try_set_oracle_stale_threshold(&59u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Above maximum (> 86400) - let result = client.try_set_oracle_stale_threshold(&86_401u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Valid value - apply_oracle_stale_threshold(&env, &client, 1800u64); - assert_eq!(client.get_oracle_stale_threshold(), 1800u64); -} - -#[test] -fn test_oracle_deviation_rejected_when_over_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // Set max deviation to 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // 50% jump: diff_bps = 5000 > 500 - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); -} - -#[test] -fn test_oracle_deviation_allows_at_exact_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Exactly 5%: 1.00 -> 1.05 => diff_bps = 500 - client.resolve_round(&OraclePayload { - price: 1_0500000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_rounding_floor_is_deterministic() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // Start price 3, final 4 => diff_bps = floor(1*10000/3)=3333 - client.create_round(&3u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(3333u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // At threshold should pass - client.resolve_round(&OraclePayload { - price: 4u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); // 5% - client.arm_oracle_deviation_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 2_0000000u128, // 100% jump - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Verify override event emitted (check before env.as_contract which resets event scope) - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("override")) - }); - assert!(override_event.is_some(), "override event must be emitted"); - - // Override is one-shot and must be cleared - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKeyCore::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "override must be cleared after use"); - }); -} - -#[test] -fn test_oracle_liveness_custom_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Set a short 120 s threshold - apply_oracle_stale_threshold(&env, &client, 120u64); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // 100 s later — within custom 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); - - // 130 s later — beyond 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 130; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_resolve_round_nonce_boundary_values() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Pre-seed both boundary nonces as consumed for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 0u64), &true); - env.storage().persistent().set( - &DataKeyScoped::ConsumedOracleNonce(round.round_id, u64::MAX), - &true, - ); - }); - - let zero = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 0u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); - - let max = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: u64::MAX, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); -} - -#[test] -fn test_resolve_round_wrong_network_id_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_wrong_contract_addr_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_contract = Address::generate(&env); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: wrong_contract, - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_valid_domain_context_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Correct network + correct contract => resolves normally - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_both_network_and_contract_wrong() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - let wrong_contract = Address::generate(&env); - - // Network is checked first, so we get OracleNetworkMismatch - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: wrong_contract, - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_protocol_health_no_heartbeat_unknown_oracle() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded → oracle_status=3, oracle_live=false - env.ledger().with_mut(|li| { - li.sequence_number = 1; - }); - let health = client.get_protocol_health(); - assert_eq!(health.oracle_status, 3); // unknown - assert!(!health.oracle_live); - assert!(!health.has_active_round); - assert_eq!(health.status_code, 2); // ORACLE_STALE - assert!(health.ledger_sequence > 0); -} - -#[test] -fn test_protocol_health_oracle_offline() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Record offline heartbeat - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - client.update_oracle_heartbeat(&2u32); // offline - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert_eq!(health.oracle_status, 2); // offline - assert_eq!(health.status_code, 2); // ORACLE_STALE -} - -#[test] -fn test_protocol_health_round_resolvable_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance past end_ledger so round is resolvable - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - // Keep oracle alive - client.update_oracle_heartbeat(&0u32); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 3); // ROUND_STALE -} - -#[test] -fn test_protocol_health_multiple_issues() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // No heartbeat (oracle unknown) + round past end_ledger → multiple issues - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 5); // MULTIPLE_ISSUES -} - -#[test] -fn test_protocol_health_schema_version_present() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let health = client.get_protocol_health(); - assert_eq!(health.schema_version, 3); -} - -#[test] -fn test_protocol_health_round_betting_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 6 - env.ledger().with_mut(|li| { - li.sequence_number = 6; - }); - client.create_round(&1_0000000, &None); - - // Still in betting window (ledger 6, bet_end = 6+6=12) - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 1); // betting - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_protocol_health_round_running_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 0 - client.create_round(&1_0000000, &None); - // Default windows: bet=6, run=12 - // bet_end = 6, end = 12 - - // Advance into running phase (past bet_end, before end) - env.ledger().with_mut(|li| { - li.sequence_number = 8; - }); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 2); // running - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_confidence_below_threshold_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(5000u32), - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_confidence_above_threshold_accepted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(9000u32), - attestation: None, }); -} - -#[test] -fn test_missing_confidence_accepted_when_not_strict() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - // strict mode NOT enabled - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Legacy payload without confidence accepted when strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); -} - -#[test] -fn test_missing_confidence_rejected_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.set_oracle_strict_mode(&true); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, status active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past end_ledger, but strict mode is OFF (default) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000s > 3600s stale threshold - }); - - // Should still resolve — strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) + grace (default 0) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_strict_on_allows_when_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance to resolvable, timestamp within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, offline (status=2) - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&2u32); - - // Advance to resolvable (within threshold, but offline always blocks) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 10; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat recorded at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all — would normally block - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Verify override event emitted - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hoverride")) - }); - assert!(override_event.is_some(), "hoverride event must be emitted"); - - assert_eq!(client.get_active_round(), None); - - // Override is one-shot — must be consumed - env.as_contract(&contract_id, || { - let config = env - .storage() - .persistent() - .get::<_, HbGateConfig>(&HbGateKey::Config) - .unwrap_or(HbGateConfig { - strict_mode: false, - override_armed: false, - grace_seconds: 0, - }); - assert!( - !config.override_armed, - "heartbeat override must be cleared after use" - ); - }); -} - -#[test] -fn test_heartbeat_gate_override_is_one_shot() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - - // First round: arm override, resolve (consumes override) - client.create_round(&1_0000000, &None); - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Second round: no heartbeat, no override — should block - env.ledger().with_mut(|li| { - li.sequence_number = 20; - li.timestamp = 200; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 32; - li.timestamp = 300; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 20, - nonce: 2u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - // Set grace period to 600s (10 minutes) - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) but within grace (3600+600=4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000 < 4200, within grace - }); - - // Should resolve — within grace period - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold + grace (3600 + 600 = 4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4300; // 4300 > 4200, beyond grace - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_degraded_status_allowed_when_current() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, degraded (status=1) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&1u32); - - // Resolve within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - // Degraded but current — should be allowed - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Degraded heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&1u32); - - // Advance past stale threshold (no grace configured) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_hblocked_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_arm_override_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_arm_hb_override(); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_set_strict_mode_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_set_hb_strict_mode(&true); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_getters_return_defaults() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Default values: strict mode OFF, override not armed, grace 0 - assert!(!client.get_hb_strict_mode()); - assert!(!client.get_hb_override_armed()); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_heartbeat_gate_set_and_get_grace_seconds() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.set_hb_grace_seconds(&300u64); - assert_eq!(client.get_hb_grace_seconds(), 300); - - client.set_hb_grace_seconds(&0u64); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_no_confidence_check_when_threshold_unset() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // No min confidence configured - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Even zero confidence accepted when threshold unset - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(0u32), - attestation: None, }); -} - -// SPDX-License-Identifier: MIT -//! Security tests for Oracle data freshness and round validation. - -use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold}; -use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::errors::ContractError; -use crate::types::{DataKeyCore, DataKeyScoped, HbGateConfig, HbGateKey, OraclePayload}; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events, Ledger as _}, - Address, BytesN, Env, IntoVal, TryIntoVal, -}; - -#[test] -fn test_resolve_round_stale_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance ledger time to 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; // Allow resolution - }); - - // Submit payload with timestamp 600 (400s old, > 300s limit) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 600, - round_id: 0, // Starts at ledger 0 - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::StaleOracleData))); -} - -#[test] -fn test_resolve_round_invalid_round_id() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - // Submit payload with wrong round_id (e.g., 999 instead of 0) - let payload = OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 999, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::InvalidOracleRound))); -} - -#[test] -fn test_resolve_round_valid_payload() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Valid payload: within 300s and correct round_id - let payload = OraclePayload { - price: 1_5000000, - timestamp: 900, // 100s old, OK - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - client.resolve_round(&payload); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_future_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Current ledger time is 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Submit payload with timestamp 1001 (future) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 1001, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::FutureOracleData))); -} - -// ─── Cancel-round security tests (Issue #111) ──────────────────────────────── - -#[test] -fn test_cancelled_round_cannot_be_resolved() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - client.cancel_round(&0u32); - - // After cancellation there is no active round, so resolve_round returns NoActiveRound - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); -} - -#[test] -fn test_cancel_round_without_admin_auth_fails() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - // Initialize with only admin auth - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "create_round", - args: (1_0000000u128, Option::::None).into_val(&env), - sub_invokes: &[], - }, - }]); - client.create_round(&1_0000000, &None); - - // No auth for cancel_round - let result = client.try_cancel_round(&0u32); - assert!(result.is_err()); -} - -// ─── Oracle nonce replay protection (Issue #118) ───────────────────────────── - -/// A nonce already consumed for a round must be rejected on re-submission. -/// We seed the consumed-nonce marker to simulate a prior submission, then -/// assert the resolver rejects a payload reusing that nonce for the same round. -#[test] -fn test_resolve_round_duplicate_nonce_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Simulate a prior submission having consumed nonce 42 for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 42u64), &true); - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 42u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); -} - -/// A fresh, unique nonce resolves normally and records the consumed marker. -#[test] -fn test_resolve_round_unique_nonce_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 7u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Round resolved and the nonce is recorded as consumed for that round. - assert_eq!(client.get_active_round(), None); - env.as_contract(&contract_id, || { - let consumed: bool = env - .storage() - .persistent() - .get(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 7u64)) - .unwrap_or(false); - assert!(consumed, "resolved nonce must be marked consumed"); - }); -} - -// ─── Oracle heartbeat and liveness tests ───────────────────────────────────── - -#[test] -fn test_oracle_heartbeat_requires_oracle_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No oracle auth set up — must fail - let result = client.try_update_oracle_heartbeat(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_oracle_heartbeat_updates_timestamp_and_status() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - - client.update_oracle_heartbeat(&0u32); - - let record = client.get_oracle_heartbeat().expect("heartbeat must exist"); - assert_eq!(record.timestamp, 500); - assert_eq!(record.status, 0); - - // Update to degraded status - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - client.update_oracle_heartbeat(&1u32); - - let record = client.get_oracle_heartbeat().unwrap(); - assert_eq!(record.timestamp, 1000); - assert_eq!(record.status, 1); -} - -#[test] -fn test_oracle_heartbeat_invalid_status_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let result = client.try_update_oracle_heartbeat(&3u32); - assert_eq!(result, Err(Ok(ContractError::InvalidMode))); -} - -#[test] -fn test_oracle_liveness_within_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check liveness 100 s later (well within 3600 s default) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_stale_after_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check 4000 s later — beyond 3600 s default threshold - env.ledger().with_mut(|li| { - li.timestamp = 4000; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_offline_status_not_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - // Record offline status - client.update_oracle_heartbeat(&2u32); - - // Even within threshold, offline means not live - env.ledger().with_mut(|li| { - li.timestamp = 10; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_no_heartbeat_returns_false() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded — must return false - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_heartbeat_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.update_oracle_heartbeat(&0u32); - - let events = env.events().all(); - let hb_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hbeat")) - }); - assert!( - hb_event.is_some(), - "Heartbeat event must be emitted on update_oracle_heartbeat" - ); -} - -#[test] -fn test_set_oracle_stale_threshold_admin_only() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No admin auth for set_oracle_stale_threshold - let result = client.try_set_oracle_stale_threshold(&1800u64); - assert!(result.is_err()); -} - -#[test] -fn test_set_oracle_stale_threshold_validation() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Below minimum (< 60) - let result = client.try_set_oracle_stale_threshold(&59u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Above maximum (> 86400) - let result = client.try_set_oracle_stale_threshold(&86_401u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Valid value - apply_oracle_stale_threshold(&env, &client, 1800u64); - assert_eq!(client.get_oracle_stale_threshold(), 1800u64); -} - -// ─── Oracle deviation guardrails tests ─────────────────────────────────────── - -#[test] -fn test_oracle_deviation_rejected_when_over_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // Set max deviation to 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // 50% jump: diff_bps = 5000 > 500 - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000u128, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); -} - -#[test] -fn test_oracle_deviation_allows_at_exact_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Exactly 5%: 1.00 -> 1.05 => diff_bps = 500 - client.resolve_round(&OraclePayload { - price: 1_0500000u128, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_rounding_floor_is_deterministic() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // Start price 3, final 4 => diff_bps = floor(1*10000/3)=3333 - client.create_round(&3u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(3333u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // At threshold should pass - client.resolve_round(&OraclePayload { - price: 4u128, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); // 5% - client.arm_oracle_deviation_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 2_0000000u128, // 100% jump - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Verify override event emitted (check before env.as_contract which resets event scope) - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("override")) - }); - assert!(override_event.is_some(), "override event must be emitted"); - - // Override is one-shot and must be cleared - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKeyCore::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "override must be cleared after use"); - }); -} - -#[test] -fn test_oracle_liveness_custom_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Set a short 120 s threshold - apply_oracle_stale_threshold(&env, &client, 120u64); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // 100 s later — within custom 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); - - // 130 s later — beyond 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 130; - }); - assert!(!client.is_oracle_live()); -} - -/// Boundary nonces (0 and u64::MAX) are rejected on reuse for the same round. -#[test] -fn test_resolve_round_nonce_boundary_values() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Pre-seed both boundary nonces as consumed for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 0u64), &true); - env.storage().persistent().set( - &DataKeyScoped::ConsumedOracleNonce(round.round_id, u64::MAX), - &true, - ); - }); - - let zero = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 0u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); - - let max = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: u64::MAX, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); -} - -// ─── Oracle domain-context validation tests (Issue #143) ──────────────────── - -#[test] -fn test_resolve_round_wrong_network_id_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_wrong_contract_addr_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_contract = Address::generate(&env); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: wrong_contract, - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_valid_domain_context_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Correct network + correct contract => resolves normally - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_both_network_and_contract_wrong() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - let wrong_contract = Address::generate(&env); - - // Network is checked first, so we get OracleNetworkMismatch - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 30, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: wrong_contract, - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -// ─── Protocol health endpoint tests ────────────────────────────────────────── - -#[test] -fn test_protocol_health_no_heartbeat_unknown_oracle() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded → oracle_status=3, oracle_live=false - env.ledger().with_mut(|li| { - li.sequence_number = 1; - }); - let health = client.get_protocol_health(); - assert_eq!(health.oracle_status, 3); // unknown - assert!(!health.oracle_live); - assert!(!health.has_active_round); - assert_eq!(health.status_code, 2); // ORACLE_STALE - assert!(health.ledger_sequence > 0); -} - -#[test] -fn test_protocol_health_oracle_offline() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Record offline heartbeat - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - client.update_oracle_heartbeat(&2u32); // offline - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert_eq!(health.oracle_status, 2); // offline - assert_eq!(health.status_code, 2); // ORACLE_STALE -} - -#[test] -fn test_protocol_health_round_resolvable_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance past end_ledger so round is resolvable - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - // Keep oracle alive - client.update_oracle_heartbeat(&0u32); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 3); // ROUND_STALE -} - -#[test] -fn test_protocol_health_multiple_issues() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // No heartbeat (oracle unknown) + round past end_ledger → multiple issues - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 5); // MULTIPLE_ISSUES -} - -#[test] -fn test_protocol_health_schema_version_present() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let health = client.get_protocol_health(); - assert_eq!(health.schema_version, 3); -} - -#[test] -fn test_protocol_health_round_betting_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 6 - env.ledger().with_mut(|li| { - li.sequence_number = 6; - }); - client.create_round(&1_0000000, &None); - - // Still in betting window (ledger 6, bet_end = 6+6=12) - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 1); // betting - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_protocol_health_round_running_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 0 - client.create_round(&1_0000000, &None); - // Default windows: bet=6, run=12 - // bet_end = 6, end = 12 - - // Advance into running phase (past bet_end, before end) - env.ledger().with_mut(|li| { - li.sequence_number = 8; - }); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 2); // running - assert_eq!(health.status_code, 0); // HEALTHY -} -// ── Oracle confidence score tests ──────────────────────────────────────────── - -#[test] -fn test_confidence_below_threshold_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(5000u32), - }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_confidence_above_threshold_accepted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(9000u32), - }); -} - -#[test] -fn test_missing_confidence_accepted_when_not_strict() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - // strict mode NOT enabled - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Legacy payload without confidence accepted when strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); -} - -#[test] -fn test_missing_confidence_rejected_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.set_oracle_strict_mode(&true); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -// ── Oracle heartbeat health gate tests (Issue #264) ────────────────────────── - -/// Helper: create a round with oracle heartbeat, advance to resolvable state. -fn _setup_heartbeat_gate_test( - env: &Env, - client: &VirtualTokenContractClient, - heartbeat_timestamp: u64, - heartbeat_status: u32, -) { - env.ledger().with_mut(|li| { - li.timestamp = heartbeat_timestamp; - }); - client.update_oracle_heartbeat(&heartbeat_status); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; // past end_ledger - li.timestamp = heartbeat_timestamp + 100; // within threshold - }); -} - -#[test] -fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, status active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past end_ledger, but strict mode is OFF (default) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000s > 3600s stale threshold - }); - - // Should still resolve — strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) + grace (default 0) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_strict_on_allows_when_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance to resolvable, timestamp within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, offline (status=2) - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&2u32); - - // Advance to resolvable (within threshold, but offline always blocks) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 10; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat recorded at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all — would normally block - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - assert_eq!(client.get_active_round(), None); - - // Verify override event emitted - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hoverride")) - }); - assert!(override_event.is_some(), "hoverride event must be emitted"); - - // Override is one-shot — must be consumed - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKey::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "heartbeat override must be cleared after use"); - }); -} - -#[test] -fn test_heartbeat_gate_override_is_one_shot() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - - // First round: arm override, resolve (consumes override) - client.create_round(&1_0000000, &None); - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - // Second round: no heartbeat, no override — should block - env.ledger().with_mut(|li| { - li.sequence_number = 20; - li.timestamp = 200; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 32; - li.timestamp = 300; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - // Set grace period to 600s (10 minutes) - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) but within grace (3600+600=4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000 < 4200, within grace - }); - - // Should resolve — within grace period - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold + grace (3600 + 600 = 4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4300; // 4300 > 4200, beyond grace - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_degraded_status_allowed_when_current() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, degraded (status=1) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&1u32); - - // Resolve within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - // Degraded but current — should be allowed - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Degraded heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&1u32); - - // Advance past stale threshold (no grace configured) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_hblocked_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let _ = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }); - - let events = env.events().all(); - let blocked_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hblocked")) - }); - assert!(blocked_event.is_some(), "hblocked event must be emitted"); -} - -#[test] -fn test_heartbeat_gate_arm_override_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_arm_hb_override(); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_set_strict_mode_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_set_hb_strict_mode(&true); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_getters_return_defaults() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Default values: strict mode OFF, override not armed, grace 0 - assert!(!client.get_hb_strict_mode()); - assert!(!client.get_hb_override_armed()); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_heartbeat_gate_set_and_get_grace_seconds() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.set_hb_grace_seconds(&300u64); - assert_eq!(client.get_hb_grace_seconds(), 300); - - client.set_hb_grace_seconds(&0u64); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_no_confidence_check_when_threshold_unset() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // No min confidence configured - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Even zero confidence accepted when threshold unset - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(0u32), - }); -} -// SPDX-License-Identifier: MIT -//! Security tests for Oracle data freshness and round validation. - -use super::config_helpers::{apply_oracle_max_deviation_bps, apply_oracle_stale_threshold}; -use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; -use crate::errors::ContractError; -use crate::types::{DataKey, HbGateConfig, HbGateKey, OraclePayload}; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events, Ledger as _}, - Address, BytesN, Env, IntoVal, TryIntoVal, -}; - -#[test] -fn test_resolve_round_stale_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance ledger time to 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; // Allow resolution - }); - - // Submit payload with timestamp 600 (400s old, > 300s limit) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 600, - round_id: 0, // Starts at ledger 0 - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::StaleOracleData))); -} - -#[test] -fn test_resolve_round_invalid_round_id() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - // Submit payload with wrong round_id (e.g., 999 instead of 0) - let payload = OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 999, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::InvalidOracleRound))); -} - -#[test] -fn test_resolve_round_valid_payload() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Valid payload: within 300s and correct round_id - let payload = OraclePayload { - price: 1_5000000, - timestamp: 900, // 100s old, OK - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - client.resolve_round(&payload); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_future_timestamp() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Current ledger time is 1000 - env.ledger().with_mut(|li| { - li.timestamp = 1000; - li.sequence_number = 12; - }); - - // Submit payload with timestamp 1001 (future) - let payload = OraclePayload { - price: 1_5000000, - timestamp: 1001, - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - }; - - let result = client.try_resolve_round(&payload); - assert_eq!(result, Err(Ok(ContractError::FutureOracleData))); -} - -// ─── Cancel-round security tests (Issue #111) ──────────────────────────────── - -#[test] -fn test_cancelled_round_cannot_be_resolved() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - client.cancel_round(&0u32); - - // After cancellation there is no active round, so resolve_round returns NoActiveRound - env.ledger().with_mut(|li| { - li.sequence_number = 12; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); -} - -#[test] -fn test_cancel_round_without_admin_auth_fails() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - // Initialize with only admin auth - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "create_round", - args: (1_0000000u128, Option::::None).into_val(&env), - sub_invokes: &[], - }, - }]); - client.create_round(&1_0000000, &None); - - // No auth for cancel_round - let result = client.try_cancel_round(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_resolve_round_duplicate_nonce_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Simulate a prior submission having consumed nonce 42 for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 42u64), &true); - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 42u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNonceReused))); -} - -#[test] -fn test_resolve_round_unique_nonce_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 7u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Round resolved and the nonce is recorded as consumed for that round. - assert_eq!(client.get_active_round(), None); - env.as_contract(&contract_id, || { - let consumed: bool = env - .storage() - .persistent() - .get(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 7u64)) - .unwrap_or(false); - assert!(consumed, "resolved nonce must be marked consumed"); - }); -} - -#[test] -fn test_oracle_heartbeat_requires_oracle_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No oracle auth set up — must fail - let result = client.try_update_oracle_heartbeat(&0u32); - assert!(result.is_err()); -} - -#[test] -fn test_oracle_heartbeat_updates_timestamp_and_status() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - - client.update_oracle_heartbeat(&0u32); - - let record = client.get_oracle_heartbeat().expect("heartbeat must exist"); - assert_eq!(record.timestamp, 500); - assert_eq!(record.status, 0); - - // Update to degraded status - env.ledger().with_mut(|li| { - li.timestamp = 1000; - }); - client.update_oracle_heartbeat(&1u32); - - let record = client.get_oracle_heartbeat().unwrap(); - assert_eq!(record.timestamp, 1000); - assert_eq!(record.status, 1); -} - -#[test] -fn test_oracle_heartbeat_invalid_status_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let result = client.try_update_oracle_heartbeat(&3u32); - assert_eq!(result, Err(Ok(ContractError::InvalidMode))); -} - -#[test] -fn test_oracle_liveness_within_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check liveness 100 s later (well within 3600 s default) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_stale_after_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Check 4000 s later — beyond 3600 s default threshold - env.ledger().with_mut(|li| { - li.timestamp = 4000; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_offline_status_not_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - // Record offline status - client.update_oracle_heartbeat(&2u32); - - // Even within threshold, offline means not live - env.ledger().with_mut(|li| { - li.timestamp = 10; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_liveness_no_heartbeat_returns_false() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded — must return false - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_oracle_heartbeat_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.update_oracle_heartbeat(&0u32); - - let events = env.events().all(); - let hb_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hbeat")) - }); - assert!( - hb_event.is_some(), - "Heartbeat event must be emitted on update_oracle_heartbeat" - ); -} - -#[test] -fn test_set_oracle_stale_threshold_admin_only() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No admin auth for set_oracle_stale_threshold - let result = client.try_set_oracle_stale_threshold(&1800u64); - assert!(result.is_err()); -} - -#[test] -fn test_set_oracle_stale_threshold_validation() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Below minimum (< 60) - let result = client.try_set_oracle_stale_threshold(&59u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Above maximum (> 86400) - let result = client.try_set_oracle_stale_threshold(&86_401u64); - assert_eq!(result, Err(Ok(ContractError::InvalidDuration))); - - // Valid value - apply_oracle_stale_threshold(&env, &client, 1800u64); - assert_eq!(client.get_oracle_stale_threshold(), 1800u64); -} - -#[test] -fn test_oracle_deviation_rejected_when_over_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // Set max deviation to 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // 50% jump: diff_bps = 5000 > 500 - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleDeviationExceeded))); -} - -#[test] -fn test_oracle_deviation_allows_at_exact_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - // 5% (500 bp) - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Exactly 5%: 1.00 -> 1.05 => diff_bps = 500 - client.resolve_round(&OraclePayload { - price: 1_0500000u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_rounding_floor_is_deterministic() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // Start price 3, final 4 => diff_bps = floor(1*10000/3)=3333 - client.create_round(&3u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(3333u32)); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // At threshold should pass - client.resolve_round(&OraclePayload { - price: 4u128, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_oracle_deviation_override_allows_over_threshold_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000u128, &None); - let round = client.get_active_round().unwrap(); - - apply_oracle_max_deviation_bps(&env, &client, Some(500u32)); // 5% - client.arm_oracle_deviation_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - client.resolve_round(&OraclePayload { - price: 2_0000000u128, // 100% jump - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Verify override event emitted (check before env.as_contract which resets event scope) - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("override")) - }); - assert!(override_event.is_some(), "override event must be emitted"); - - // Override is one-shot and must be cleared - env.as_contract(&contract_id, || { - let armed: bool = env - .storage() - .persistent() - .get(&DataKeyCore::OracleDeviationOverrideArmed) - .unwrap_or(false); - assert!(!armed, "override must be cleared after use"); - }); -} - -#[test] -fn test_oracle_liveness_custom_threshold() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Set a short 120 s threshold - apply_oracle_stale_threshold(&env, &client, 120u64); - - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // 100 s later — within custom 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - assert!(client.is_oracle_live()); - - // 130 s later — beyond 120 s threshold - env.ledger().with_mut(|li| { - li.timestamp = 130; - }); - assert!(!client.is_oracle_live()); -} - -#[test] -fn test_resolve_round_nonce_boundary_values() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Pre-seed both boundary nonces as consumed for this round. - env.as_contract(&contract_id, || { - env.storage() - .persistent() - .set(&DataKeyScoped::ConsumedOracleNonce(round.round_id, 0u64), &true); - env.storage().persistent().set( - &DataKeyScoped::ConsumedOracleNonce(round.round_id, u64::MAX), - &true, - ); - }); - - let zero = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 0u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(zero, Err(Ok(ContractError::OracleNonceReused))); - - let max = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: u64::MAX, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(max, Err(Ok(ContractError::OracleNonceReused))); -} - -#[test] -fn test_resolve_round_wrong_network_id_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_wrong_contract_addr_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_contract = Address::generate(&env); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: wrong_contract, - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_resolve_round_valid_domain_context_resolves() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - // Correct network + correct contract => resolves normally - client.resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_resolve_round_both_network_and_contract_wrong() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - let round = client.get_active_round().unwrap(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 1000; - }); - - let wrong_network = BytesN::from_array(&env, &[0xFFu8; 32]); - let wrong_contract = Address::generate(&env); - - // Network is checked first, so we get OracleNetworkMismatch - let result = client.try_resolve_round(&OraclePayload { - price: 1_5000000, - timestamp: 900, - round_id: round.start_ledger, - nonce: 1u64, - network_id: wrong_network, - contract_addr: wrong_contract, - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNetworkMismatch))); -} - -#[test] -fn test_protocol_health_no_heartbeat_unknown_oracle() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // No heartbeat recorded → oracle_status=3, oracle_live=false - env.ledger().with_mut(|li| { - li.sequence_number = 1; - }); - let health = client.get_protocol_health(); - assert_eq!(health.oracle_status, 3); // unknown - assert!(!health.oracle_live); - assert!(!health.has_active_round); - assert_eq!(health.status_code, 2); // ORACLE_STALE - assert!(health.ledger_sequence > 0); -} - -#[test] -fn test_protocol_health_oracle_offline() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Record offline heartbeat - env.ledger().with_mut(|li| { - li.timestamp = 500; - }); - client.update_oracle_heartbeat(&2u32); // offline - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert_eq!(health.oracle_status, 2); // offline - assert_eq!(health.status_code, 2); // ORACLE_STALE -} - -#[test] -fn test_protocol_health_round_resolvable_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Advance past end_ledger so round is resolvable - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - // Keep oracle alive - client.update_oracle_heartbeat(&0u32); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 3); // ROUND_STALE -} - -#[test] -fn test_protocol_health_multiple_issues() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // No heartbeat (oracle unknown) + round past end_ledger → multiple issues - env.ledger().with_mut(|li| { - li.sequence_number = 30; - li.timestamp = 100; - }); - - let health = client.get_protocol_health(); - assert!(!health.oracle_live); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 3); // resolvable - assert_eq!(health.status_code, 5); // MULTIPLE_ISSUES -} - -#[test] -fn test_protocol_health_schema_version_present() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - let health = client.get_protocol_health(); - assert_eq!(health.schema_version, 3); -} - -#[test] -fn test_protocol_health_round_betting_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 6 - env.ledger().with_mut(|li| { - li.sequence_number = 6; - }); - client.create_round(&1_0000000, &None); - - // Still in betting window (ledger 6, bet_end = 6+6=12) - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 1); // betting - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_protocol_health_round_running_phase() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - let user = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - client.mint_initial(&user); - - // Oracle heartbeat active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Create round at ledger 0 - client.create_round(&1_0000000, &None); - // Default windows: bet=6, run=12 - // bet_end = 6, end = 12 - - // Advance into running phase (past bet_end, before end) - env.ledger().with_mut(|li| { - li.sequence_number = 8; - }); - - let health = client.get_protocol_health(); - assert!(health.has_active_round); - assert_eq!(health.active_round_phase, 2); // running - assert_eq!(health.status_code, 0); // HEALTHY -} - -#[test] -fn test_confidence_below_threshold_rejected() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(5000u32), - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_confidence_above_threshold_accepted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(9000u32), - attestation: None, }); -} - -#[test] -fn test_missing_confidence_accepted_when_not_strict() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - // strict mode NOT enabled - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Legacy payload without confidence accepted when strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); -} - -#[test] -fn test_missing_confidence_rejected_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_oracle_min_confidence_bps(&Some(8000u32)); - client.set_oracle_strict_mode(&true); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::InvalidPrice))); -} - -#[test] -fn test_heartbeat_gate_strict_off_allows_settlement_even_when_stale() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, status active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past end_ledger, but strict mode is OFF (default) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000s > 3600s stale threshold - }); - - // Should still resolve — strict mode is off - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_strict_on_blocks_when_stale_past_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) + grace (default 0) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_strict_on_allows_when_live() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, active - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance to resolvable, timestamp within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_blocks_offline_status_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, offline (status=2) - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&2u32); - - // Advance to resolvable (within threshold, but offline always blocks) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 10; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_blocks_no_heartbeat_in_strict_mode() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat recorded at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_override_bypasses_block_and_emits_event() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all — would normally block - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Verify override event emitted - let events = env.events().all(); - let override_event = events.iter().find(|e| { - let (_contract, topics, _data) = e; - topics.len() == 2 - && topics.get(0).unwrap().try_into_val(&env) == Ok(symbol_short!("oracle")) - && topics.get(1).unwrap().try_into_val(&env) == Ok(symbol_short!("hoverride")) - }); - assert!(override_event.is_some(), "hoverride event must be emitted"); - - assert_eq!(client.get_active_round(), None); - - // Override is one-shot — must be consumed - env.as_contract(&contract_id, || { - let config = env - .storage() - .persistent() - .get::<_, HbGateConfig>(&HbGateKey::Config) - .unwrap_or(HbGateConfig { - strict_mode: false, - override_armed: false, - grace_seconds: 0, - }); - assert!( - !config.override_armed, - "heartbeat override must be cleared after use" - ); - }); -} - -#[test] -fn test_heartbeat_gate_override_is_one_shot() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - - // First round: arm override, resolve (consumes override) - client.create_round(&1_0000000, &None); - client.arm_hb_override(); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - // Second round: no heartbeat, no override — should block - env.ledger().with_mut(|li| { - li.sequence_number = 20; - li.timestamp = 200; - }); - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 32; - li.timestamp = 300; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 20, - nonce: 2u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_grace_period_allows_stale_within_grace() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - // Set grace period to 600s (10 minutes) - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold (3600s) but within grace (3600+600=4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; // 4000 < 4200, within grace - }); - - // Should resolve — within grace period - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_grace_period_blocks_after_grace_expires() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.set_hb_grace_seconds(&600u64); - - client.create_round(&1_0000000, &None); - - // Heartbeat at t=0, active - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&0u32); - - // Advance past stale threshold + grace (3600 + 600 = 4200) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4300; // 4300 > 4200, beyond grace - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_degraded_status_allowed_when_current() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Heartbeat at t=100, degraded (status=1) - env.ledger().with_mut(|li| { - li.timestamp = 100; - }); - client.update_oracle_heartbeat(&1u32); - - // Resolve within threshold - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 200; - }); - - // Degraded but current — should be allowed - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(client.get_active_round(), None); -} - -#[test] -fn test_heartbeat_gate_degraded_stale_past_grace_blocked() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // Degraded heartbeat at t=0 - env.ledger().with_mut(|li| { - li.timestamp = 0; - }); - client.update_oracle_heartbeat(&1u32); - - // Advance past stale threshold (no grace configured) - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 4000; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_hblocked_event_emitted() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - client.set_hb_strict_mode(&true); - client.create_round(&1_0000000, &None); - - // No heartbeat at all - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - let result = client.try_resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: None, - attestation: None, }); - - assert_eq!(result, Err(Ok(ContractError::OracleNotLive))); -} - -#[test] -fn test_heartbeat_gate_arm_override_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_arm_hb_override(); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_set_strict_mode_requires_admin_auth() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - - env.mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &admin, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id, - fn_name: "initialize", - args: (&admin, &oracle).into_val(&env), - sub_invokes: &[], - }, - }]); - client.initialize(&admin, &oracle); - - // No auth — should fail - let result = client.try_set_hb_strict_mode(&true); - assert!(result.is_err()); -} - -#[test] -fn test_heartbeat_gate_getters_return_defaults() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - // Default values: strict mode OFF, override not armed, grace 0 - assert!(!client.get_hb_strict_mode()); - assert!(!client.get_hb_override_armed()); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_heartbeat_gate_set_and_get_grace_seconds() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - client.initialize(&admin, &oracle); - - client.set_hb_grace_seconds(&300u64); - assert_eq!(client.get_hb_grace_seconds(), 300); - - client.set_hb_grace_seconds(&0u64); - assert_eq!(client.get_hb_grace_seconds(), 0); -} - -#[test] -fn test_no_confidence_check_when_threshold_unset() { - let env = Env::default(); - let contract_id = env.register(VirtualTokenContract, ()); - let client = VirtualTokenContractClient::new(&env, &contract_id); - let admin = Address::generate(&env); - let oracle = Address::generate(&env); - env.mock_all_auths(); - - client.initialize(&admin, &oracle); - // No min confidence configured - client.create_round(&1_0000000, &None); - - env.ledger().with_mut(|li| { - li.sequence_number = 12; - li.timestamp = 100; - }); - - // Even zero confidence accepted when threshold unset - client.resolve_round(&OraclePayload { - price: 1_2000000, - timestamp: env.ledger().timestamp(), - round_id: 0, - nonce: 1u64, - network_id: env.ledger().network_id(), - contract_addr: contract_id.clone(), - confidence: Some(0u32), - attestation: None, }); -} - From 7b1d3dac1c02299fcfd14e4a7591d56619f3dd74 Mon Sep 17 00:00:00 2001 From: Hallab Date: Wed, 26 Aug 2026 15:27:04 +0100 Subject: [PATCH 3/5] fix(settlement): rebuild dispute window lifecycle --- contracts/src/settlement.rs | 442 ++++++++++++++++++++------ contracts/src/tests/dispute_window.rs | 128 ++++++++ contracts/src/tests/mod.rs | 1 + docs/EVENT_SCHEMA.md | 44 ++- 4 files changed, 511 insertions(+), 104 deletions(-) create mode 100644 contracts/src/tests/dispute_window.rs diff --git a/contracts/src/settlement.rs b/contracts/src/settlement.rs index 626e2f5..9b59529 100644 --- a/contracts/src/settlement.rs +++ b/contracts/src/settlement.rs @@ -5,13 +5,10 @@ use crate::admin::{ use crate::common::{ _accumulate_pending, _emit_action_rejected, _extend_persistent_ttl, _set_balance, balance, payout_add, payout_mul, sort_addresses, DEFAULT_ARCHIVE_RETENTION, - DEFAULT_ORACLE_TIMESTAMP_SKEW, SECONDS_PER_LEDGER, - payout_add, payout_mul, sort_addresses, DEFAULT_ARCHIVE_RETENTION, MAX_ORACLE_OBSERVATIONS, - TTL_BUMP_AMOUNT, TTL_BUMP_THRESHOLD, -}; -use crate::config::{ - _apply_protocol_fee_precision, _apply_protocol_fee_updown, _read_fee_model, + DEFAULT_ORACLE_TIMESTAMP_SKEW, MAX_ORACLE_OBSERVATIONS, SECONDS_PER_LEDGER, TTL_BUMP_AMOUNT, + TTL_BUMP_THRESHOLD, }; +use crate::config::{_apply_protocol_fee_precision, _apply_protocol_fee_updown, _read_fee_model}; use crate::errors::ContractError; use crate::settlement_math::{ classify_price_direction, compute_deviation_bps, compute_updown_winner_payout, @@ -20,13 +17,103 @@ use crate::settlement_math::{ use crate::storage::clear_round_storage; use crate::types::{ ArchivedRoundSummary, BetSide, DataKeyCore, DataKeyScoped, DeviationReferenceMode, - HbGateConfig, LeaderboardEntry, MultiFeedPayload, OracleHeartbeatRecord, - OraclePayload, OracleQuorumConfig, PrecisionCommitment, PrecisionPayoutPolicy, - PrecisionPrediction, PriceSample, PendingWinningsUpdatedAtKey, Round, RoundArchiveStatus, - RoundMode, TwapSamplesKey, UserOutcomeType, UserPosition, UserRoundOutcome, UserStats, + HbGateConfig, LeaderboardEntry, MultiFeedPayload, OracleHeartbeatRecord, OraclePayload, + OracleQuorumConfig, PendingWinningsUpdatedAtKey, PrecisionCommitment, PrecisionPayoutPolicy, + PrecisionPrediction, PriceSample, Round, RoundArchiveStatus, RoundMode, TwapSamplesKey, + UserOutcomeType, UserPosition, UserRoundOutcome, UserStats, }; use soroban_sdk::xdr::ToXdr; -use soroban_sdk::{symbol_short, Address, Bytes, Env, Map, Vec}; +use soroban_sdk::{contracttype, symbol_short, Address, Bytes, Env, Map, Symbol, Vec}; + +#[contracttype] +#[derive(Clone)] +struct PendingDisputeSettlement { + round: Round, + final_price: u128, + confidence: Option, + resolved_at_ledger: u32, + deadline_ledger: u32, +} + +fn _pending_disputes_key(env: &Env) -> Symbol { + Symbol::new(env, "PendingDisputes") +} + +fn _read_pending_dispute(env: &Env, round_id: u64) -> Option { + let key = _pending_disputes_key(env); + if env.storage().persistent().has(&key) { + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP_THRESHOLD, TTL_BUMP_AMOUNT); + } + let pending: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Map::new(env)); + pending.get(round_id) +} + +fn _write_pending_dispute(env: &Env, settlement: &PendingDisputeSettlement) { + let key = _pending_disputes_key(env); + let mut pending: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Map::new(env)); + pending.set(settlement.round.round_id, settlement.clone()); + env.storage().persistent().set(&key, &pending); + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP_THRESHOLD, TTL_BUMP_AMOUNT); +} + +fn _remove_pending_dispute(env: &Env, round_id: u64) { + let key = _pending_disputes_key(env); + let mut pending: Map = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Map::new(env)); + pending.remove(round_id); + if pending.is_empty() { + env.storage().persistent().remove(&key); + } else { + env.storage().persistent().set(&key, &pending); + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP_THRESHOLD, TTL_BUMP_AMOUNT); + } +} + +/// Clears only storage owned by the terminal round. A newer active round may +/// already exist while an older result is inside its dispute window, so this +/// helper deliberately does not remove `ActiveRound`. +fn _clear_dispute_round_storage(env: &Env, round_id: u64, participants: &Vec
) { + for i in 0..participants.len() { + if let Some(user) = participants.get(i) { + env.storage() + .persistent() + .remove(&DataKeyScoped::Position(round_id, user.clone())); + env.storage() + .persistent() + .remove(&DataKeyScoped::PrecisionPosition(round_id, user.clone())); + env.storage() + .persistent() + .remove(&DataKeyScoped::PrecisionCommitment(round_id, user)); + } + } + env.storage() + .persistent() + .remove(&DataKeyScoped::RoundParticipants(round_id)); + env.storage().persistent().remove(&DataKeyCore::Positions); + env.storage() + .persistent() + .remove(&DataKeyCore::UpDownPositions); + env.storage() + .persistent() + .remove(&DataKeyCore::PrecisionPositions); +} /// Cancels the active round and deterministically refunds all participant stakes. pub fn cancel_round(env: Env, _reason: u32) -> Result<(), ContractError> { @@ -140,9 +227,17 @@ pub fn cancel_round(env: Env, _reason: u32) -> Result<(), ContractError> { if let Some(user) = participants.get(i) { let pred_key = DataKeyScoped::PrecisionPosition(round_id, user.clone()); let commit_key = DataKeyScoped::PrecisionCommitment(round_id, user); - if let Some(pred) = env.storage().persistent().get::<_, PrecisionPrediction>(&pred_key) { + if let Some(pred) = env + .storage() + .persistent() + .get::<_, PrecisionPrediction>(&pred_key) + { pot = pot.checked_add(pred.amount).unwrap_or(pot); - } else if let Some(commit) = env.storage().persistent().get::<_, PrecisionCommitment>(&commit_key) { + } else if let Some(commit) = env + .storage() + .persistent() + .get::<_, PrecisionCommitment>(&commit_key) + { pot = pot.checked_add(commit.amount).unwrap_or(pot); } } @@ -373,7 +468,11 @@ pub fn resolve_round(env: Env, payload: OraclePayload) -> Result<(), ContractErr .checked_sub(round.start_ledger) .ok_or(ContractError::Overflow)?; let round_end_estimate = round_start - .checked_add((round_duration_ledgers as u64).checked_mul(SECONDS_PER_LEDGER).ok_or(ContractError::Overflow)?) + .checked_add( + (round_duration_ledgers as u64) + .checked_mul(SECONDS_PER_LEDGER) + .ok_or(ContractError::Overflow)?, + ) .ok_or(ContractError::Overflow)?; let lower_bound = round_start.saturating_sub(skew); @@ -768,12 +867,11 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co let median_price: u128 = if n % 2 == 1 { sorted_prices.get(n / 2).ok_or(ContractError::Overflow)? } else { - let mid1 = sorted_prices.get(n / 2 - 1).ok_or(ContractError::Overflow)?; + let mid1 = sorted_prices + .get(n / 2 - 1) + .ok_or(ContractError::Overflow)?; let mid2 = sorted_prices.get(n / 2).ok_or(ContractError::Overflow)?; - mid1 - .checked_add(mid2) - .ok_or(ContractError::Overflow)? - / 2 + mid1.checked_add(mid2).ok_or(ContractError::Overflow)? / 2 }; if median_price == 0 { @@ -821,13 +919,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co #[allow(deprecated)] env.events().publish( (symbol_short!("oracle"), symbol_short!("rejected")), - ( - round.round_id, - start_price, - median_price, - diff_bps, - max_bps, - ), + (round.round_id, start_price, median_price, diff_bps, max_bps), ); return Err(ContractError::OracleDeviationExceeded); } @@ -840,13 +932,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co #[allow(deprecated)] env.events().publish( (symbol_short!("oracle"), symbol_short!("override")), - ( - round.round_id, - start_price, - median_price, - diff_bps, - max_bps, - ), + (round.round_id, start_price, median_price, diff_bps, max_bps), ); } } @@ -875,9 +961,7 @@ pub fn resolve_round_multi(env: Env, payload: MultiFeedPayload) -> Result<(), Co .map_err(|_| ContractError::Overflow)?; if diff_bps <= quorum_cfg.outlier_threshold_bps { - survivors = survivors - .checked_add(1) - .ok_or(ContractError::Overflow)?; + survivors = survivors.checked_add(1).ok_or(ContractError::Overflow)?; } } } @@ -947,8 +1031,10 @@ fn _settle_round_with_price( env, round, RoundArchiveStatus::FallbackRefund, - payload.price, + final_price, &threshold_participants, + 0, + confidence, ); _refund_under_threshold(env, round, &threshold_participants)?; #[allow(deprecated)] @@ -960,7 +1046,43 @@ fn _settle_round_with_price( } } - let (fee_amount, total_pot) = match round.mode { + let dispute_ledgers = crate::config::get_dispute_ledgers(env); + if dispute_ledgers > 0 { + let resolved_at_ledger = env.ledger().sequence(); + let deadline_ledger = resolved_at_ledger + .checked_add(dispute_ledgers) + .ok_or(ContractError::Overflow)?; + _write_pending_dispute( + env, + &PendingDisputeSettlement { + round: round.clone(), + final_price, + confidence, + resolved_at_ledger, + deadline_ledger, + }, + ); + + env.storage().persistent().remove(&DataKeyCore::ActiveRound); + #[allow(deprecated)] + env.events().publish( + (symbol_short!("round"), symbol_short!("pending")), + (round_id, final_price, resolved_at_ledger, deadline_ledger), + ); + return Ok(()); + } + + _complete_settlement(env, round, final_price, confidence).map(|_| ()) +} + +fn _complete_settlement( + env: &Env, + round: &Round, + final_price: u128, + confidence: Option, +) -> Result<(i128, u32), ContractError> { + let round_id = round.round_id; + let fee_amount = match round.mode { RoundMode::UpDown => { let (one_sided, fee) = _resolve_updown_mode(env, round, final_price, false)?; if one_sided { @@ -970,10 +1092,9 @@ fn _settle_round_with_price( (round_id, round.pool_up, round.pool_down), ); } - let pot = round.pool_up.checked_add(round.pool_down).unwrap_or(0); - (fee, pot) + fee } - RoundMode::Precision => _resolve_precision_mode(env, round_id, final_price, false)?, + RoundMode::Precision => _resolve_precision_mode(env, round_id, final_price, false)?.0, }; let participants: Vec
= env @@ -981,54 +1102,28 @@ fn _settle_round_with_price( .persistent() .get(&DataKeyScoped::RoundParticipants(round_id)) .unwrap_or(Vec::new(env)); - let participant_count = participants.len(); + let participant_count = participants.len() as u32; _archive_round( env, round, RoundArchiveStatus::Resolved, - payload.price, - &participants, final_price, - participant_count, + &participants, fee_amount, - payload.confidence, + confidence, ); - // Mode-scoped position cleanup (eliminates redundant storage delete lookups) - match round.mode { - RoundMode::UpDown => { - for i in 0..raw_participants.len() { - if let Some(user) = raw_participants.get(i) { - env.storage() - .persistent() - .remove(&DataKeyScoped::Position(round_id, user)); - } - } - } - RoundMode::Precision => { - for i in 0..raw_participants.len() { - if let Some(user) = raw_participants.get(i) { - env.storage() - .persistent() - .remove(&DataKeyScoped::PrecisionPosition(round_id, user.clone())); - env.storage() - .persistent() - .remove(&DataKeyScoped::PrecisionCommitment(round_id, user)); - } - } - } - } - env.storage() - .persistent() - .remove(&DataKeyScoped::RoundParticipants(round_id)); - - env.storage().persistent().remove(&DataKeyCore::ActiveRound); - env.storage().persistent().remove(&DataKeyCore::Positions); - env.storage().persistent().remove(&DataKeyCore::UpDownPositions); - env.storage() + _clear_dispute_round_storage(env, round_id, &participants); + if env + .storage() .persistent() - .remove(&DataKeyCore::PrecisionPositions); + .get::<_, Round>(&DataKeyCore::ActiveRound) + .map(|active| active.round_id == round_id) + .unwrap_or(false) + { + env.storage().persistent().remove(&DataKeyCore::ActiveRound); + } let mode_value: u32 = match round.mode { RoundMode::UpDown => 0, @@ -1045,19 +1140,155 @@ fn _settle_round_with_price( (round_id, final_price, mode_value, confidence, policy), ); - Ok(()) + Ok((fee_amount, participant_count)) } // ─── Internal helpers ──────────────────────────────────────────────────────── +/// Permissionlessly voids a resolved round while its dispute window is open. +/// Payouts and fees remain deferred, so each participant receives exactly the +/// stake recorded for this round. +pub fn void_round(env: Env, round_id: u64) -> Result<(), ContractError> { + _require_supported_schema(&env)?; + _ensure_not_paused(&env)?; + + let pending = + _read_pending_dispute(&env, round_id).ok_or(ContractError::RoundNotCancellable)?; + if env.ledger().sequence() >= pending.deadline_ledger { + return Err(ContractError::RoundNotCancellable); + } + + let participants: Vec
= env + .storage() + .persistent() + .get(&DataKeyScoped::RoundParticipants(round_id)) + .unwrap_or(Vec::new(&env)); + let mut total_refund = 0i128; + + for i in 0..participants.len() { + if let Some(user) = participants.get(i) { + match pending.round.mode { + RoundMode::UpDown => { + let key = DataKeyScoped::Position(round_id, user.clone()); + if let Some(position) = env.storage().persistent().get::<_, UserPosition>(&key) + { + total_refund = payout_add(total_refund, position.amount)?; + _accumulate_pending(&env, user.clone(), position.amount)?; + let side = match position.side { + BetSide::Up => 0, + BetSide::Down => 1, + }; + _persist_user_outcome( + &env, + round_id, + 0, + &user, + side, + 0, + position.amount, + position.amount, + UserOutcomeType::Void, + ); + } + } + RoundMode::Precision => { + let prediction_key = DataKeyScoped::PrecisionPosition(round_id, user.clone()); + let commitment_key = DataKeyScoped::PrecisionCommitment(round_id, user.clone()); + let (stake, predicted_price) = if let Some(prediction) = env + .storage() + .persistent() + .get::<_, PrecisionPrediction>(&prediction_key) + { + (prediction.amount, prediction.predicted_price) + } else if let Some(commitment) = env + .storage() + .persistent() + .get::<_, PrecisionCommitment>(&commitment_key) + { + (commitment.amount, 0) + } else { + (0, 0) + }; + if stake > 0 { + total_refund = payout_add(total_refund, stake)?; + _accumulate_pending(&env, user.clone(), stake)?; + _persist_user_outcome( + &env, + round_id, + 1, + &user, + 2, + predicted_price, + stake, + stake, + UserOutcomeType::Void, + ); + } + } + } + } + } + + _archive_round( + &env, + &pending.round, + RoundArchiveStatus::Voided, + pending.final_price, + &participants, + 0, + pending.confidence, + ); + _clear_dispute_round_storage(&env, round_id, &participants); + _remove_pending_dispute(&env, round_id); + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("round"), symbol_short!("voided")), + (round_id, participants.len() as u32, total_refund), + ); + Ok(()) +} + +/// Permissionlessly finalizes the staged oracle result once the dispute window +/// has closed. Calling at the exact deadline is allowed. +pub fn finalize_round(env: Env, round_id: u64) -> Result<(), ContractError> { + _require_supported_schema(&env)?; + _ensure_not_paused(&env)?; + + let pending = _read_pending_dispute(&env, round_id).ok_or(ContractError::NoActiveRound)?; + if env.ledger().sequence() < pending.deadline_ledger { + return Err(ContractError::RoundNotEnded); + } + + let (fee_amount, participant_count) = _complete_settlement( + &env, + &pending.round, + pending.final_price, + pending.confidence, + )?; + _remove_pending_dispute(&env, round_id); + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("round"), symbol_short!("finalized")), + (round_id, pending.final_price, participant_count, fee_amount), + ); + Ok(()) +} + #[allow(clippy::too_many_arguments)] pub fn _resolve_updown_mode( env: &Env, round: &Round, final_price: u128, - skip_payout: bool, + _skip_payout: bool, ) -> Result<(bool, i128), ContractError> { - let participants = sort_addresses(raw_participants.clone()); + let raw_participants: Vec
= env + .storage() + .persistent() + .get(&DataKeyScoped::RoundParticipants(round.round_id)) + .unwrap_or(Vec::new(env)); + let participants = sort_addresses(raw_participants); // Pure price-direction classification and one-sided check delegated to // settlement_math for auditability and golden-vector coverage. @@ -1079,7 +1310,7 @@ pub fn _resolve_updown_mode( let positions: Map = if participants.is_empty() { env.storage() .persistent() - .get(&DataKey::UpDownPositions) + .get(&DataKeyCore::UpDownPositions) .unwrap_or(Map::new(env)) } else { Map::new(env) @@ -1444,7 +1675,8 @@ pub fn _resolve_precision_mode( .ok_or(ContractError::Overflow)?; } } - let (payout_pool, fee) = _apply_protocol_fee_precision(env, round_id, total_pot, winner_stakes)?; + let (payout_pool, fee) = + _apply_protocol_fee_precision(env, round_id, total_pot, winner_stakes)?; fee_amount = fee; let payouts = _calculate_precision_payouts(env, &winners, payout_pool)?; @@ -1602,7 +1834,8 @@ pub fn _resolve_precision_legacy( .ok_or(ContractError::Overflow)?; } } - let (payout_pool, fee) = _apply_protocol_fee_precision(env, round_id, total_pot, winner_stakes)?; + let (payout_pool, fee) = + _apply_protocol_fee_precision(env, round_id, total_pot, winner_stakes)?; fee_amount = fee; let payouts = _calculate_precision_payouts(env, &winners, payout_pool)?; @@ -1785,7 +2018,7 @@ pub fn _archive_round( round: &Round, status: RoundArchiveStatus, final_price: u128, - participants: &[Address], + participants: &Vec
, fee_amount: i128, confidence: Option, ) { @@ -1807,7 +2040,7 @@ pub fn _archive_round( // Record per-user participation index for paginated history queries. for i in 0..participants.len() { if let Some(user) = participants.get(i) { - let index_key = DataKey::UserArchivedRoundIds(user.clone()); + let index_key = DataKeyScoped::UserArchivedRoundIds(user.clone()); let mut user_rounds: Vec = env .storage() .persistent() @@ -1845,8 +2078,10 @@ pub fn _archive_round( } else { for i in 0..participants.len() { if let Some(user) = participants.get(i) { - let pred_key = DataKeyScoped::PrecisionPosition(round.round_id, user.clone()); - let commit_key = DataKeyScoped::PrecisionCommitment(round.round_id, user.clone()); + let pred_key = + DataKeyScoped::PrecisionPosition(round.round_id, user.clone()); + let commit_key = + DataKeyScoped::PrecisionCommitment(round.round_id, user.clone()); let pred_opt = env .storage() @@ -2075,7 +2310,9 @@ pub fn _refund_under_threshold( .remove(&DataKeyScoped::RoundParticipants(round_id)); env.storage().persistent().remove(&DataKeyCore::ActiveRound); env.storage().persistent().remove(&DataKeyCore::Positions); - env.storage().persistent().remove(&DataKeyCore::UpDownPositions); + env.storage() + .persistent() + .remove(&DataKeyCore::UpDownPositions); env.storage() .persistent() .remove(&DataKeyCore::PrecisionPositions); @@ -2127,7 +2364,9 @@ pub fn _twap_reference_price(env: &Env, window_samples: u32) -> Result Result Vec { let key = TwapSamplesKey::Samples; if env.storage().persistent().has(&key) { - env.storage().persistent().extend_ttl( - &key, - TTL_BUMP_THRESHOLD, - TTL_BUMP_AMOUNT, - ); + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP_THRESHOLD, TTL_BUMP_AMOUNT); } - env.storage().persistent().get(&key).unwrap_or(Vec::new(env)) + env.storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(env)) } /// Appends a settled price to the TWAP sample ring, evicting the oldest @@ -2160,11 +2400,9 @@ pub fn _record_twap_sample(env: &Env, price: u128, timestamp: u64) { samples.remove(0); } env.storage().persistent().set(&key, &samples); - env.storage().persistent().extend_ttl( - &key, - TTL_BUMP_THRESHOLD, - TTL_BUMP_AMOUNT, - ); + env.storage() + .persistent() + .extend_ttl(&key, TTL_BUMP_THRESHOLD, TTL_BUMP_AMOUNT); } /// Returns `true` if the oracle heartbeat health gate should block settlement (Issue #264). diff --git a/contracts/src/tests/dispute_window.rs b/contracts/src/tests/dispute_window.rs new file mode 100644 index 0000000..b3af54b --- /dev/null +++ b/contracts/src/tests/dispute_window.rs @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +//! End-to-end coverage for the staged dispute-window settlement lifecycle. + +use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; +use crate::errors::ContractError; +use crate::types::{BetSide, OraclePayload, RoundArchiveStatus}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events, Ledger as _}, + Address, Env, TryIntoVal, +}; + +fn setup( + env: &Env, +) -> ( + VirtualTokenContractClient<'_>, + Address, + Address, + Address, + u64, +) { + let contract_id = env.register(VirtualTokenContract, ()); + let client = VirtualTokenContractClient::new(env, &contract_id); + let admin = Address::generate(env); + let oracle = Address::generate(env); + let alice = Address::generate(env); + let bob = Address::generate(env); + env.mock_all_auths(); + + client.initialize(&admin, &oracle); + client.update_oracle_heartbeat(&0u32); + client.set_dispute_ledgers(&5u32); + client.mint_initial(&alice); + client.mint_initial(&bob); + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &60i128, &BetSide::Up); + client.place_bet(&bob, &40i128, &BetSide::Down); + + let round = client.get_active_round().unwrap(); + env.ledger() + .with_mut(|ledger| ledger.sequence_number = round.end_ledger); + client.resolve_round(&OraclePayload { + price: 2_000, + timestamp: env.ledger().timestamp(), + round_id: round.start_ledger, + nonce: 1, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); + + (client, contract_id, alice, bob, round.round_id) +} + +fn has_round_event(env: &Env, contract_id: &Address, action: soroban_sdk::Symbol) -> bool { + env.events().all().iter().any(|(emitter, topics, _)| { + emitter == *contract_id + && topics.len() == 2 + && topics.get(0).unwrap().try_into_val(env) == Ok(symbol_short!("round")) + && topics.get(1).unwrap().try_into_val(env) == Ok(action.clone()) + }) +} + +#[test] +fn void_during_window_refunds_exact_stakes_and_conserves_pot() { + let env = Env::default(); + let (client, contract_id, alice, bob, round_id) = setup(&env); + + assert_eq!(client.get_pending_winnings(&alice), 0); + assert_eq!(client.get_pending_winnings(&bob), 0); + assert_eq!( + client.try_finalize_round(&round_id), + Err(Ok(ContractError::RoundNotEnded)) + ); + + let treasury_before = client.get_protocol_fee_treasury(); + client.void_round(&round_id); + + let alice_refund = client.get_pending_winnings(&alice); + let bob_refund = client.get_pending_winnings(&bob); + let treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + assert_eq!(alice_refund, 60); + assert_eq!(bob_refund, 40); + assert_eq!(alice_refund + bob_refund + treasury_delta, 100); + assert_eq!(treasury_delta, 0); + assert_eq!( + client.get_archived_round(&round_id).unwrap().status, + RoundArchiveStatus::Voided + ); + assert!(has_round_event( + &env, + &contract_id, + symbol_short!("pending") + )); + assert!(has_round_event(&env, &contract_id, symbol_short!("voided"))); +} + +#[test] +fn finalize_after_window_settles_and_late_void_is_blocked() { + let env = Env::default(); + let (client, contract_id, alice, bob, round_id) = setup(&env); + env.ledger().with_mut(|ledger| ledger.sequence_number += 5); + + assert_eq!( + client.try_void_round(&round_id), + Err(Ok(ContractError::RoundNotCancellable)) + ); + + let treasury_before = client.get_protocol_fee_treasury(); + client.finalize_round(&round_id); + + let alice_payout = client.get_pending_winnings(&alice); + let bob_payout = client.get_pending_winnings(&bob); + let treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + assert_eq!(alice_payout, 100); + assert_eq!(bob_payout, 0); + assert_eq!(alice_payout + bob_payout + treasury_delta, 100); + assert_eq!( + client.get_archived_round(&round_id).unwrap().status, + RoundArchiveStatus::Resolved + ); + assert!(has_round_event( + &env, + &contract_id, + symbol_short!("finalized") + )); +} diff --git a/contracts/src/tests/mod.rs b/contracts/src/tests/mod.rs index 33c0a62..2d763f0 100644 --- a/contracts/src/tests/mod.rs +++ b/contracts/src/tests/mod.rs @@ -14,6 +14,7 @@ mod config_helpers; mod conservation; mod cost_benchmarks; mod deviation_reference; +mod dispute_window; mod edge_cases; mod drill; mod event_coverage; diff --git a/docs/EVENT_SCHEMA.md b/docs/EVENT_SCHEMA.md index ba1bbcb..3a6c359 100644 --- a/docs/EVENT_SCHEMA.md +++ b/docs/EVENT_SCHEMA.md @@ -158,7 +158,7 @@ The payload carries the full terminal state of the round: |----------|---------------------|--------------|-----------------------------------------------------------------------------| | 0 | `version` | `u32` | Schema version tag (`0` for this layout). Reserved for future field changes. | | 1 | `round_id` | `u64` | Monotonically increasing round identifier | -| 2 | `status` | `u32` | Terminal status: `0` = Resolved, `1` = Cancelled, `2` = FallbackRefund | +| 2 | `status` | `u32` | Terminal status: `0` = Resolved, `1` = Cancelled, `2` = FallbackRefund, `3` = Voided | | 3 | `mode` | `u32` | Round mode: `0` = UpDown, `1` = Precision | | 4 | `price_start` | `u128` | Opening price at round start (4 decimal places) | | 5 | `price_final` | `u128` | Settlement price from oracle, or `0` for cancelled/fallback rounds | @@ -168,7 +168,47 @@ The payload carries the full terminal state of the round: | 9 | `total_pot` | `i128` | Total accumulated round pot (stroops) | | 10 | `fee_amount` | `i128` | Protocol fees collected (stroops), `0` for non-competitive paths | | 11 | `settled_at_ledger` | `u32` | Ledger sequence number when the round was archived | -| 12 | `confidence` | `Option` | Oracle confidence in basis points (`None` for cancel / fallback) | +| 12 | `confidence` | `Option` | Oracle confidence in basis points (`None` for cancel / fallback; staged value for void) | + +For dispute-enabled rounds, competitive settlement is staged before this +terminal summary is emitted. The archive status additionally uses `3` = +`Voided` when the permissionless refund path is selected. + +### `("round", "pending")` + +Emitted after a valid oracle result is staged and all payouts and protocol fees +are deferred for the configured dispute window. + +| Position | Field | Type | Description | +|----------|----------------------|--------|--------------------------------------------------| +| 0 | `round_id` | `u64` | Round awaiting a terminal dispute decision | +| 1 | `final_price` | `u128` | Validated oracle settlement price | +| 2 | `resolved_at_ledger` | `u32` | Ledger where the oracle result was staged | +| 3 | `deadline_ledger` | `u32` | First ledger where finalization is permitted | + +### `("round", "voided")` + +Emitted when any caller voids a staged round strictly before its deadline. Each +participant is credited exactly their recorded stake and the protocol fee is +zero. + +| Position | Field | Type | Description | +|----------|---------------------|--------|------------------------------------------| +| 0 | `round_id` | `u64` | Voided round | +| 1 | `participant_count` | `u32` | Number of refunded participants | +| 2 | `total_refund` | `i128` | Sum of full-stake refunds in stroops | + +### `("round", "finalized")` + +Emitted when any caller finalizes a staged round at or after its frozen +deadline. The standard settlement and fee policy has completed before emission. + +| Position | Field | Type | Description | +|----------|---------------------|--------|------------------------------------------| +| 0 | `round_id` | `u64` | Finalized round | +| 1 | `final_price` | `u128` | Staged oracle settlement price | +| 2 | `participant_count` | `u32` | Number of settled participants | +| 3 | `fee_amount` | `i128` | Protocol fee collected in stroops | Emitted once per participant during round resolution after that participant's settlement outcome is known. Indexers can use these events to reconstruct the complete participant-level From 195d1ee6d3c0cccd2f06c6841ba19880f59ab3c7 Mon Sep 17 00:00:00 2001 From: Hallab Date: Wed, 26 Aug 2026 15:46:29 +0100 Subject: [PATCH 4/5] fix(errors): add access-control error variants --- bindings/README.md | 4 +- bindings/src/helpers.ts | 12 +++++ bindings/src/index.ts | 57 +++++++++++++++++--- bindings/tests/contract-error-parity.test.js | 5 ++ bindings/tests/integration.test.ts | 7 +++ contracts/src/access_control.rs | 5 +- contracts/src/errors.rs | 20 +++++-- contracts/src/tests/access_control.rs | 3 +- 8 files changed, 99 insertions(+), 14 deletions(-) diff --git a/bindings/README.md b/bindings/README.md index f148cc7..a188a09 100644 --- a/bindings/README.md +++ b/bindings/README.md @@ -63,10 +63,10 @@ suitable for wallet UX. import { decodeContractError, formatContractError } from "bindings" // Structured result for programmatic handling -const decoded = decodeContractError(4) +const decoded = decodeContractError(79) if (decoded) { console.log(`Error ${decoded.code}: ${decoded.variant} — ${decoded.message}`) - // => "Error 4: UnauthorizedAdmin — UnauthorizedAdmin" + // => "Error 79: AccessDenied — AccessDenied" } // One-liner for display in a wallet or bot diff --git a/bindings/src/helpers.ts b/bindings/src/helpers.ts index d74316f..d7c731e 100644 --- a/bindings/src/helpers.ts +++ b/bindings/src/helpers.ts @@ -70,6 +70,17 @@ export class NoRoundTemplateError extends XelmaError { } } +export class AccessDeniedError extends XelmaError { + constructor() { + super( + "Participant is blocked by the active access-control policy", + 79, + "AccessDenied", + ); + this.name = "AccessDeniedError"; + } +} + // ─── Error Mapping ───────────────────────────────────────────── const ERROR_CODE_TO_CLASS: Record XelmaError> = { @@ -80,6 +91,7 @@ const ERROR_CODE_TO_CLASS: Record XelmaError> = { 28: StakeExceedsMaxError, 29: ExposureCapExceededError, 65: NoRoundTemplateError, + 79: AccessDeniedError, }; /** diff --git a/bindings/src/index.ts b/bindings/src/index.ts index caeebe5..42ae371 100644 --- a/bindings/src/index.ts +++ b/bindings/src/index.ts @@ -535,6 +535,10 @@ export const ContractError = { * No pending oracle rotation proposal to accept or cancel */ 54: {message:"NoPendingRotation"}, + /** + * Oracle rotation delay has not elapsed + */ + 55: {message:"RotationDelayNotElapsed"}, /** * Invalid archive retention limit */ @@ -548,20 +552,59 @@ export const ContractError = { */ 64: {message:"InvalidSalt"}, /** - * Epoch mint budget has been fully consumed - */ - 66: {message:"EpochBudgetExceeded"} - * create_next_from_template called with no round template configured + * No round template is configured */ 65: {message:"NoRoundTemplate"}, + /** + * Oracle timestamp is outside the round-relative economic window + */ + 66: {message:"OracleTimestampOutsideWindow"}, + /** + * Epoch mint budget has been fully consumed + */ + 67: {message:"EpochBudgetExceeded"}, /** * Oracle heartbeat is not live and strict mode blocks settlement (Issue #264) */ - 66: {message:"OracleNotLive"}, + 68: {message:"OracleNotLive"}, /** * Invalid precision payout policy */ - 67: {message:"InvalidPayoutPolicy"} + 69: {message:"InvalidPayoutPolicy"}, + /** Stake is below the configured minimum bet. */ + 70: {message:"BelowMinBet"}, + /** Too few oracle observations survived quorum validation. */ + 71: {message:"InsufficientOracleQuorum"}, + /** Multi-feed payload has too few observations. */ + 72: {message:"TooFewObservations"}, + /** Oracle observation was rejected as an outlier. */ + 73: {message:"OracleOutlierRejected"}, + /** Multi-feed payload contains a duplicate source. */ + 74: {message:"DuplicateOracleSource"}, + /** Multi-feed observations are in an invalid order. */ + 75: {message:"InvalidObservationOrder"}, + /** Data key is not allowed for batch TTL touch. */ + 76: {message:"UnsupportedDataKeyForTtlTouch"}, + /** Pending winnings entry was not found. */ + 77: {message:"PendingWinningsNotFound"}, + /** Pending winnings expiry is not configured. */ + 78: {message:"ExpiryNotConfigured"}, + /** Participant is blocked by the active access-control policy. */ + 79: {message:"AccessDenied"}, + /** Governance proposal was not found. */ + 80: {message:"ProposalNotFound"}, + /** Governance proposal has expired. */ + 81: {message:"ProposalExpired"}, + /** Governance proposal is in an invalid state. */ + 82: {message:"GovInvalidState"}, + /** Caller is unauthorized by the governance policy. */ + 83: {message:"GovUnauthorized"}, + /** Action is invalid in the current round lifecycle phase. */ + 84: {message:"IllegalPhaseTransition"}, + /** Oracle heartbeat failed the configured health policy. */ + 85: {message:"OracleHeartbeatUnhealthy"}, + /** Pending winnings have not reached their expiry threshold. */ + 86: {message:"PendingWinningsNotExpired"} } /** @@ -1398,4 +1441,4 @@ export class Client extends ContractClient { get_season_leaderboard_by_wins: this.txFromJSON>, get_season_leaderboard_by_streak: this.txFromJSON> } -} \ No newline at end of file +} diff --git a/bindings/tests/contract-error-parity.test.js b/bindings/tests/contract-error-parity.test.js index 3939fde..75adf9c 100644 --- a/bindings/tests/contract-error-parity.test.js +++ b/bindings/tests/contract-error-parity.test.js @@ -35,6 +35,11 @@ while ((entry = tsEntryRegex.exec(tsMapMatch[1])) !== null) { const rustCodes = new Map(rustVariants.map(v => [v.code, v.name])); describe("Contract Error Parity", () => { + it("maps AccessDenied to its stable contract code", () => { + expect(rustCodes.get(79)).toBe("AccessDenied"); + expect(tsCodes.get(79)).toBe("AccessDenied"); + }); + it("has no missing error codes in TS", () => { const missingInTS = []; for (const [code, name] of rustCodes) { diff --git a/bindings/tests/integration.test.ts b/bindings/tests/integration.test.ts index 2655fc2..636bb97 100644 --- a/bindings/tests/integration.test.ts +++ b/bindings/tests/integration.test.ts @@ -12,6 +12,7 @@ import { StakeExceedsMaxError, ExposureCapExceededError, NoRoundTemplateError, + AccessDeniedError, wrapContractError, type MintResult, type ClaimResult, @@ -211,6 +212,12 @@ describe("wrapContractError", () => { const err = wrapContractError({ code: 65 }); expect(err).toBeInstanceOf(NoRoundTemplateError); }); + + it("maps access-control rejection code 79 to AccessDeniedError", () => { + const err = wrapContractError({ code: 79 }); + expect(err).toBeInstanceOf(AccessDeniedError); + expect((err as AccessDeniedError).variant).toBe("AccessDenied"); + }); }); // ─── simulateBet ─────────────────────────────────────────────── diff --git a/contracts/src/access_control.rs b/contracts/src/access_control.rs index 448a964..61d92ba 100644 --- a/contracts/src/access_control.rs +++ b/contracts/src/access_control.rs @@ -223,6 +223,9 @@ pub fn get_access_policy(env: Env, user: Address) -> (bool, AccessState) { /// must win even on open deployments. /// 2. If allowlist mode is enabled, an `user` that is not allowlisted is rejected. /// 3. Otherwise, the call proceeds (default open). +/// +/// Both rejection paths return the stable, dedicated +/// [`ContractError::AccessDenied`] error (code `79`). pub fn _enforce_access_control(env: &Env, user: &Address) -> Result<(), ContractError> { if env .storage() @@ -263,4 +266,4 @@ fn _emit_list_changed(env: &Env, list: &str, action: &str, user: &Address, recon (symbol_short!("access"), detail), (user.clone(), reconciled), ); -} \ No newline at end of file +} diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index a3fa868..e9393a9 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -60,7 +60,7 @@ pub enum ContractError { OracleTimestampOutsideWindow = 66, /// Pending winnings entry exists but has not yet reached the configured /// expiry threshold — caller must wait before reclaiming. - PendingWinningsNotExpired = 66, + PendingWinningsNotExpired = 86, /// Epoch mint budget has been fully consumed EpochBudgetExceeded = 67, /// Oracle heartbeat is not live and strict mode blocks settlement (Issue #264) @@ -88,5 +88,19 @@ pub enum ContractError { /// Pending winnings entry does not exist or expiry is not configured. PendingWinningsNotFound = 77, /// Pending winnings expiry is not configured (value is 0). - ExpiryNotConfigured = 78, -} + ExpiryNotConfigured = 78, + /// Participant is blocked by the active allowlist or denylist policy. + AccessDenied = 79, + /// Governance proposal does not exist. + ProposalNotFound = 80, + /// Governance proposal is past its execution deadline. + ProposalExpired = 81, + /// Governance proposal cannot transition from its current state. + GovInvalidState = 82, + /// Caller is not authorized by the configured governance policy. + GovUnauthorized = 83, + /// Requested action is not valid in the round's current lifecycle phase. + IllegalPhaseTransition = 84, + /// Oracle heartbeat failed the configured freshness or health policy. + OracleHeartbeatUnhealthy = 85, +} diff --git a/contracts/src/tests/access_control.rs b/contracts/src/tests/access_control.rs index d3c9f60..268364e 100644 --- a/contracts/src/tests/access_control.rs +++ b/contracts/src/tests/access_control.rs @@ -55,6 +55,7 @@ fn test_denylist_blocks_bet_in_open_mode() { client.create_round(&1_0000000, &None); let result = client.try_place_bet(&user, &100_0000000, &BetSide::Up); assert_eq!(result, Err(Ok(ContractError::AccessDenied))); + assert_eq!(ContractError::AccessDenied as u32, 79); } /// `test_allowlist_enforced` — once allowlist mode is enabled, only allowlisted @@ -289,4 +290,4 @@ fn test_protocol_health_reports_access_mode() { client.set_access_control_enabled(&true); let after = client.get_protocol_health(); assert_eq!(after.status_code, 6, "allowlist mode should surface ACCESS_RESTRICTED"); -} \ No newline at end of file +} From 8c57bc75b3aeee01993ed5f9eef3f607384726bd Mon Sep 17 00:00:00 2001 From: Hallab Date: Thu, 27 Aug 2026 16:47:43 +0100 Subject: [PATCH 5/5] chore: combine rebuild and quality fixes Closes #394 Closes #385 Closes #389 Closes #384