diff --git a/bindings/src/index.ts b/bindings/src/index.ts index caeebe5..f8fcd3b 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 yet (must wait MIN_ROTATION_DELAY_SECONDS) + */ + 55: {message:"RotationDelayNotElapsed"}, /** * Invalid archive retention limit */ @@ -548,20 +552,65 @@ 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 */ 65: {message:"NoRoundTemplate"}, + /** + * Oracle payload 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 amount is below the configured minimum bet (dust protection, Issue #269) + */ + 70: {message:"BelowMinBet"}, + /** + * Multi-feed resolution: fewer observations survived outlier rejection than the configured quorum threshold + */ + 71: {message:"InsufficientOracleQuorum"}, + /** + * Multi-feed resolution: payload contains fewer observations than the configured minimum + */ + 72: {message:"TooFewObservations"}, + /** + * Multi-feed resolution: outlier observations would dominate the result + */ + 73: {message:"OracleOutlierRejected"}, + /** + * Multi-feed payload contains duplicate source identifiers + */ + 74: {message:"DuplicateOracleSource"}, + /** + * Multi-feed payload has observations that are not sorted or sources are out of expected range + */ + 75: {message:"InvalidObservationOrder"}, + /** + * The requested data key is not allowed for batch TTL touch operations + */ + 76: {message:"UnsupportedDataKeyForTtlTouch"}, + /** + * Pending winnings entry does not exist + */ + 77: {message:"PendingWinningsNotFound"}, + /** + * Pending winnings expiry is not configured (value is 0) + */ + 78: {message:"ExpiryNotConfigured"}, + /** + * Pending winnings entry exists but has not yet reached the configured expiry threshold + */ + 79: {message:"PendingWinningsNotExpired"} } /** diff --git a/contracts/src/admin.rs b/contracts/src/admin.rs index 4a7eb85..f49a93b 100644 --- a/contracts/src/admin.rs +++ b/contracts/src/admin.rs @@ -1174,9 +1174,9 @@ pub fn _require_supported_schema(env: &Env) -> Result { /// # Errors /// - `AdminNotSet` — contract not initialized. /// - `ContractPaused` — contract is fully paused. +/// - `ExpiryNotConfigured` — pending winnings expiry is disabled (0 or absent). +/// - `PendingWinningsNotFound` — no pending winnings entry for this user. /// - `PendingWinningsNotExpired` — entry exists but hasn't reached the expiry threshold. -/// - `NoActiveRound` — used as a generic "no pending winnings" signal when -/// the entry doesn't exist or expiry is disabled (0). pub fn reclaim_expired_pending_winnings(env: Env, user: Address) -> Result { _require_supported_schema(&env)?; let admin: Address = env @@ -1191,6 +1191,7 @@ pub fn reclaim_expired_pending_winnings(env: Env, user: Address) -> Result Result Result (VirtualTokenContractClient<'_>, Address, Address, Address) { + 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); + (client, contract_id, admin, oracle) +} + +/// Writes the protocol fee bps directly into storage (bypassing timelock). +fn set_fee_bps_now(env: &Env, contract_id: &Address, bps: u32) { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .set(&DataKeyCore::ProtocolFeeBps, &bps); + }); +} + +/// Writes the early cash-out penalty bps directly into storage. +fn set_ec_bps_now(env: &Env, contract_id: &Address, bps: u32) { + env.as_contract(contract_id, || { + env.storage() + .persistent() + .set(&DataKeyCore::EarlyCashoutBps, &bps); + }); +} + +/// Writes the fee model directly into storage. +fn set_fee_model_now(env: &Env, contract_id: &Address, model: FeeModel) { + env.as_contract(contract_id, || { + env.storage().persistent().set(&DataKeyCore::FeeModel, &model); + }); +} + +fn resolve_at(env: &Env, client: &VirtualTokenContractClient, contract_id: &Address, price: u128) { + let round = client + .get_active_round() + .expect("active round required to resolve"); + client.resolve_round(&OraclePayload { + price, + timestamp: env.ledger().timestamp(), + round_id: round.start_ledger, + nonce: 1u64, + network_id: env.ledger().network_id(), + contract_addr: contract_id.clone(), + confidence: None, + attestation: None, + }); +} + +// ─── Row 1: 1-stroop dust cash-out, fee off ───────────────────────────────── + +/// A single 1-stroop position cashes out with fee disabled. The forfeit is +/// `1 * penalty / 10_000 = 0` (floor division), so the user receives a full +/// refund and treasury must not move. +#[test] +fn dust_cashout_fee_off_full_refund() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&1_000u128, &None); + // Alice: dust (1 stroop), Bob: normal (100) + client.place_bet(&alice, &1, &BetSide::Up); + client.place_bet(&bob, &100, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); // 10% penalty + + // Advance to Running phase + env.ledger().with_mut(|li| li.sequence_number = 7); + + let treasury_before = client.get_protocol_fee_treasury(); + let alice_pending_before = client.get_pending_winnings(&alice); + client.cash_out_early(&alice); + + let alice_cashout = client.get_pending_winnings(&alice) - alice_pending_before; + let treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + // Forfeit = 1 * 1000 / 10000 = 0, so full refund + assert_eq!(alice_cashout, 1, "dust cash-out must return full stake when forfeit floors to 0"); + assert_eq!(treasury_delta, 0, "treasury must not move when forfeit is 0"); + + // Pool should be reduced by full 1 stroop + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 0, "pool_up must be 0 after dust cash-out"); + assert_eq!(round.pool_down, 100, "pool_down must be unchanged"); + + // Alice's position is gone + assert!(client.get_user_position(&alice).is_none()); +} + +// ─── Row 2: 1-stroop dust cash-out, fee on ────────────────────────────────── + +/// Same as Row 1 but with settlement fee enabled (10%). The cash-out forfeit +/// still floors to 0, so treasury receives nothing from the cash-out itself. +/// Settlement of the remaining pool then applies the fee. +#[test] +fn dust_cashout_fee_on_full_refund() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let charlie = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + client.mint_initial(&charlie); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &1, &BetSide::Up); // dust + client.place_bet(&bob, &100, &BetSide::Down); + client.place_bet(&charlie, &100, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); // 10% penalty + set_fee_bps_now(&env, &contract_id, 1_000); // 10% settlement fee + + env.ledger().with_mut(|li| li.sequence_number = 7); + + let treasury_before = client.get_protocol_fee_treasury(); + let alice_pending_before = client.get_pending_winnings(&alice); + client.cash_out_early(&alice); + + let alice_cashout = client.get_pending_winnings(&alice) - alice_pending_before; + let ec_treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + assert_eq!(alice_cashout, 1, "dust forfeit floors to 0"); + assert_eq!(ec_treasury_delta, 0, "no forfeit goes to treasury for dust"); + + // Remaining pool: up=0, down=200 → one-sided → refund, no fee + env.ledger().with_mut(|li| li.sequence_number = 12); + let bob_pending_before = client.get_pending_winnings(&bob); + let charlie_pending_before = client.get_pending_winnings(&charlie); + let treasury_before_resolve = client.get_protocol_fee_treasury(); + resolve_at(&env, &client, &contract_id, 900u128); // price down + + let bob_pay = client.get_pending_winnings(&bob) - bob_pending_before; + let charlie_pay = client.get_pending_winnings(&charlie) - charlie_pending_before; + let resolve_treasury_delta = client.get_protocol_fee_treasury() - treasury_before_resolve; + + assert_eq!(bob_pay, 100, "bob refunded"); + assert_eq!(charlie_pay, 100, "charlie refunded"); + assert_eq!(resolve_treasury_delta, 0, "one-sided pool → no fee"); + + // Full conservation: original pot = 1 + 100 + 100 = 201 + assert_eq!( + alice_cashout + bob_pay + charlie_pay + ec_treasury_delta + resolve_treasury_delta, + 201, + "full round conservation" + ); +} + +// ─── Row 3: Dust forfeit rounds to zero with various penalty rates ─────────── + +/// Parametric-style: verify that for any stake * penalty_bps < 10_000 the +/// forfeit is exactly 0 and the user gets a full refund. Uses three specific +/// combinations that sit right at the boundary. +#[test] +fn dust_cashout_fee_on_forfeit_rounds_to_zero() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&1_000u128, &None); + // Stake=9999, penalty=1 bps → 9999 * 1 / 10000 = 0 (floor) + client.place_bet(&alice, &9999, &BetSide::Up); + client.place_bet(&bob, &500, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1); // 0.01% penalty (1 bps) + + env.ledger().with_mut(|li| li.sequence_number = 7); + + let treasury_before = client.get_protocol_fee_treasury(); + let alice_pending_before = client.get_pending_winnings(&alice); + client.cash_out_early(&alice); + + let alice_cashout = client.get_pending_winnings(&alice) - alice_pending_before; + let treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + // 9999 * 1 / 10000 = 0 → full refund + assert_eq!(alice_cashout, 9999); + assert_eq!(treasury_delta, 0); + + // Pool consistency + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 0); + assert_eq!(round.pool_down, 500); +} + +// ─── Row 4: Dust cash-out + remaining pool settles, fee off ───────────────── + +/// Dust stake on the UP side cashes out. Remaining pool (DOWN side only) +/// settles as a one-sided refund. Fee off throughout. +#[test] +fn dust_cashout_settle_remaining_fee_off() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); // dust up + let bob = Address::generate(&env); // normal down + let charlie = Address::generate(&env); // normal up + client.mint_initial(&alice); + client.mint_initial(&bob); + client.mint_initial(&charlie); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &1, &BetSide::Up); // dust + client.place_bet(&charlie, &50, &BetSide::Up); // normal + client.place_bet(&bob, &200, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); + + env.ledger().with_mut(|li| li.sequence_number = 7); + let treasury_before = client.get_protocol_fee_treasury(); + let alice_pending_before = client.get_pending_winnings(&alice); + client.cash_out_early(&alice); + let alice_cashout = client.get_pending_winnings(&alice) - alice_pending_before; + let ec_treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + assert_eq!(alice_cashout, 1); + assert_eq!(ec_treasury_delta, 0); + + // Pool: up=50, down=200 + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 50); + assert_eq!(round.pool_down, 200); + + // Resolve: price down → DOWN wins, fee off + env.ledger().with_mut(|li| li.sequence_number = 12); + let charlie_before = client.get_pending_winnings(&charlie); + let bob_before = client.get_pending_winnings(&bob); + let treasury_before_resolve = client.get_protocol_fee_treasury(); + resolve_at(&env, &client, &contract_id, 900u128); + + let charlie_pay = client.get_pending_winnings(&charlie) - charlie_before; + let bob_pay = client.get_pending_winnings(&bob) - bob_before; + let resolve_treasury_delta = client.get_protocol_fee_treasury() - treasury_before_resolve; + + // Bob wins: gets back his 200 + charlie's 50 = 250 (fee off, no deduction) + assert_eq!(bob_pay, 250); + assert_eq!(charlie_pay, 0); + assert_eq!(resolve_treasury_delta, 0); + + // Conservation: original pot = 1 + 50 + 200 = 251 + assert_eq!( + alice_cashout + bob_pay + charlie_pay + ec_treasury_delta + resolve_treasury_delta, + 251, + ); +} + +// ─── Row 5: Dust cash-out + remaining pool settles, fee on ────────────────── + +/// Dust stake on the UP side cashes out (forfeit = 0). Remaining pool settles +/// with fee on (10% FeeOnPot). The settlement fee is applied to the remaining +/// 250-pot (50 up + 200 down). +#[test] +fn dust_cashout_settle_remaining_fee_on() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); // dust up + let bob = Address::generate(&env); // normal down (winner) + let charlie = Address::generate(&env); // normal up (loser) + client.mint_initial(&alice); + client.mint_initial(&bob); + client.mint_initial(&charlie); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &1, &BetSide::Up); // dust + client.place_bet(&charlie, &50, &BetSide::Up); + client.place_bet(&bob, &200, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); + set_fee_bps_now(&env, &contract_id, 1_000); // 10% settlement fee + + env.ledger().with_mut(|li| li.sequence_number = 7); + let alice_pending_before = client.get_pending_winnings(&alice); + let treasury_before = client.get_protocol_fee_treasury(); + client.cash_out_early(&alice); + let alice_cashout = client.get_pending_winnings(&alice) - alice_pending_before; + let ec_treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + assert_eq!(alice_cashout, 1); + assert_eq!(ec_treasury_delta, 0); + + // Resolve: price down → DOWN wins, fee on 10% of pot (250) + env.ledger().with_mut(|li| li.sequence_number = 12); + let bob_before = client.get_pending_winnings(&bob); + let charlie_before = client.get_pending_winnings(&charlie); + let treasury_before_resolve = client.get_protocol_fee_treasury(); + resolve_at(&env, &client, &contract_id, 900u128); + + let bob_pay = client.get_pending_winnings(&bob) - bob_before; + let charlie_pay = client.get_pending_winnings(&charlie) - charlie_before; + let resolve_treasury_delta = client.get_protocol_fee_treasury() - treasury_before_resolve; + + // Fee on remaining pot: 250 * 1000 / 10000 = 25 + // Fee is taken from losing pool first: min(25, 50) = 25 from losing + // dist_winning = 200, dist_losing = 50 - 25 = 25 + // bob_share = floor(200 * 225 / 200) = 225 + assert_eq!(bob_pay, 225); + assert_eq!(charlie_pay, 0); + assert_eq!(resolve_treasury_delta, 25); + + // Conservation: original pot = 1 + 50 + 200 = 251 + assert_eq!( + alice_cashout + bob_pay + charlie_pay + ec_treasury_delta + resolve_treasury_delta, + 251, + ); +} + +// ─── Row 6: Multiple dust cash-outs, fee off ──────────────────────────────── + +/// Two users each with 1-stroop stakes cash out. Both get full refunds. The +/// remaining non-dust user then wins or loses against an empty opposing pool. +#[test] +fn multiple_dust_cashouts_fee_off() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); // dust up + let bob = Address::generate(&env); // dust down + let charlie = Address::generate(&env); // normal down + client.mint_initial(&alice); + client.mint_initial(&bob); + client.mint_initial(&charlie); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &1, &BetSide::Up); + client.place_bet(&bob, &1, &BetSide::Down); + client.place_bet(&charlie, &100, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); + + env.ledger().with_mut(|li| li.sequence_number = 7); + + // Both dust users cash out + let treasury_before = client.get_protocol_fee_treasury(); + let alice_before = client.get_pending_winnings(&alice); + let bob_before = client.get_pending_winnings(&bob); + client.cash_out_early(&alice); + client.cash_out_early(&bob); + + let alice_cashout = client.get_pending_winnings(&alice) - alice_before; + let bob_cashout = client.get_pending_winnings(&bob) - bob_before; + let ec_treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + assert_eq!(alice_cashout, 1); + assert_eq!(bob_cashout, 1); + assert_eq!(ec_treasury_delta, 0); + + // Pool: up=0, down=100 (one-sided) + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 0); + assert_eq!(round.pool_down, 100); + + // Resolve: one-sided → refund + env.ledger().with_mut(|li| li.sequence_number = 12); + let charlie_before = client.get_pending_winnings(&charlie); + let treasury_before_resolve = client.get_protocol_fee_treasury(); + resolve_at(&env, &client, &contract_id, 1_100u128); + + let charlie_pay = client.get_pending_winnings(&charlie) - charlie_before; + let resolve_treasury_delta = client.get_protocol_fee_treasury() - treasury_before_resolve; + + assert_eq!(charlie_pay, 100); + assert_eq!(resolve_treasury_delta, 0); + + // Conservation: original pot = 1 + 1 + 100 = 102 + assert_eq!( + alice_cashout + bob_cashout + charlie_pay + ec_treasury_delta + resolve_treasury_delta, + 102, + ); +} + +// ─── Row 7: Multiple dust cash-outs + settle, fee on ──────────────────────── + +/// One dust up, one normal up, one normal down. Dust up cashes out. Then +/// settlement with fee on. Full conservation must hold. +#[test] +fn multiple_dust_cashouts_settle_fee_on() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); // dust up + let bob = Address::generate(&env); // normal up + let charlie = Address::generate(&env); // normal down + client.mint_initial(&alice); + client.mint_initial(&bob); + client.mint_initial(&charlie); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &1, &BetSide::Up); + client.place_bet(&bob, &99, &BetSide::Up); + client.place_bet(&charlie, &200, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); + set_fee_bps_now(&env, &contract_id, 500); // 5% settlement fee + + env.ledger().with_mut(|li| li.sequence_number = 7); + let alice_before = client.get_pending_winnings(&alice); + let treasury_before = client.get_protocol_fee_treasury(); + client.cash_out_early(&alice); + let alice_cashout = client.get_pending_winnings(&alice) - alice_before; + let ec_treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + assert_eq!(alice_cashout, 1); + assert_eq!(ec_treasury_delta, 0); + + // Pool: up=99, down=200 + // Resolve: price up → UP wins, fee on 5% of pot (299) + env.ledger().with_mut(|li| li.sequence_number = 12); + let bob_before = client.get_pending_winnings(&bob); + let charlie_before = client.get_pending_winnings(&charlie); + let treasury_before_resolve = client.get_protocol_fee_treasury(); + resolve_at(&env, &client, &contract_id, 1_100u128); + + let bob_pay = client.get_pending_winnings(&bob) - bob_before; + let charlie_pay = client.get_pending_winnings(&charlie) - charlie_before; + let resolve_treasury_delta = client.get_protocol_fee_treasury() - treasury_before_resolve; + + // Total pot = 299, fee = 299 * 500 / 10000 = 14 (floor) + // Fee from losing pool: min(14, 200) = 14 + // dist_winning = 99, dist_losing = 186 + // bob_share = floor(99 * 285 / 99) = 285 + assert_eq!(bob_pay, 285); + assert_eq!(charlie_pay, 0); + assert_eq!(resolve_treasury_delta, 14); + + // Conservation: original pot = 1 + 99 + 200 = 300 + assert_eq!( + alice_cashout + bob_pay + charlie_pay + ec_treasury_delta + resolve_treasury_delta, + 300, + ); +} + +// ─── Row 8: Pool totals consistency after dust cash-out ───────────────────── + +/// Verify that the pool sums (pool_up + pool_down) decrease by exactly the +/// dust stake after cash-out, and that pool_up == 0 when all Up bettors +/// have cashed out. +#[test] +fn dust_cashout_pool_totals_consistency() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); // dust up + let bob = Address::generate(&env); // normal down + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &1, &BetSide::Up); + client.place_bet(&bob, &50, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); + + // Before cash-out: pool_up + pool_down == 1 + 50 == 51 + let round_before = client.get_active_round().unwrap(); + assert_eq!(round_before.pool_up + round_before.pool_down, 51); + + env.ledger().with_mut(|li| li.sequence_number = 7); + client.cash_out_early(&alice); + + // After cash-out: pool_up + pool_down == 0 + 50 == 50 (decreased by exactly 1) + let round_after = client.get_active_round().unwrap(); + assert_eq!(round_after.pool_up + round_after.pool_down, 50); + assert_eq!(round_after.pool_up, 0); + assert_eq!(round_after.pool_down, 50); +} + +// ─── Row 9: All participants are dust, all cash out, fee on ───────────────── + +/// Every participant has a 1-stroop stake and cashes out. Forfeit is 0 for +/// each. The pool drains to zero. No settlement is needed. +#[test] +fn all_dust_cashouts_fee_on_full_round() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let charlie = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + client.mint_initial(&charlie); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &1, &BetSide::Up); + client.place_bet(&bob, &1, &BetSide::Down); + client.place_bet(&charlie, &1, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); + set_fee_bps_now(&env, &contract_id, 1_000); + + // All three cash out + env.ledger().with_mut(|li| li.sequence_number = 7); + let treasury_before = client.get_protocol_fee_treasury(); + let alice_before = client.get_pending_winnings(&alice); + let bob_before = client.get_pending_winnings(&bob); + let charlie_before = client.get_pending_winnings(&charlie); + + client.cash_out_early(&alice); + client.cash_out_early(&bob); + client.cash_out_early(&charlie); + + let alice_cashout = client.get_pending_winnings(&alice) - alice_before; + let bob_cashout = client.get_pending_winnings(&bob) - bob_before; + let charlie_cashout = client.get_pending_winnings(&charlie) - charlie_before; + let treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + // Each 1-stroop forfeit = 0, so full refund each + assert_eq!(alice_cashout, 1); + assert_eq!(bob_cashout, 1); + assert_eq!(charlie_cashout, 1); + assert_eq!(treasury_delta, 0); + + // Pool should be completely drained + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 0); + assert_eq!(round.pool_down, 0); + + // Conservation: original pot = 3 + assert_eq!(alice_cashout + bob_cashout + charlie_cashout + treasury_delta, 3); +} + +// ─── Row 10: Dust on losing side cashes out, fee on ──────────────────────── + +/// Dust stake on the DOWN side (the eventual losing side) cashes out during +/// Running phase. Since forfeit floors to 0, the full 1 stroop is returned. +/// Then UP wins at settlement. The settlement fee applies to the remaining pot. +#[test] +fn dust_on_loser_side_cashout_fee_on() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); // normal up (winner) + let bob = Address::generate(&env); // dust down (loser, cashes out) + let charlie = Address::generate(&env); // normal down + client.mint_initial(&alice); + client.mint_initial(&bob); + client.mint_initial(&charlie); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &200, &BetSide::Up); + client.place_bet(&bob, &1, &BetSide::Down); // dust + client.place_bet(&charlie, &100, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); + set_fee_bps_now(&env, &contract_id, 1_000); // 10% settlement fee + + env.ledger().with_mut(|li| li.sequence_number = 7); + let treasury_before = client.get_protocol_fee_treasury(); + let bob_before = client.get_pending_winnings(&bob); + client.cash_out_early(&bob); + let bob_cashout = client.get_pending_winnings(&bob) - bob_before; + let ec_treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + assert_eq!(bob_cashout, 1, "dust forfeit floors to 0"); + assert_eq!(ec_treasury_delta, 0); + + // Pool: up=200, down=100 + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 200); + assert_eq!(round.pool_down, 100); + + // Resolve: price up → UP wins, fee on 10% of pot (300) + env.ledger().with_mut(|li| li.sequence_number = 12); + let alice_before = client.get_pending_winnings(&alice); + let charlie_before = client.get_pending_winnings(&charlie); + let treasury_before_resolve = client.get_protocol_fee_treasury(); + resolve_at(&env, &client, &contract_id, 1_100u128); + + let alice_pay = client.get_pending_winnings(&alice) - alice_before; + let charlie_pay = client.get_pending_winnings(&charlie) - charlie_before; + let resolve_treasury_delta = client.get_protocol_fee_treasury() - treasury_before_resolve; + + // Fee on pot: 300 * 1000 / 10000 = 30 + // Fee from losing pool: min(30, 100) = 30 + // dist_winning = 200, dist_losing = 70 + // alice_share = floor(200 * 270 / 200) = 270 + assert_eq!(alice_pay, 270); + assert_eq!(charlie_pay, 0); + assert_eq!(resolve_treasury_delta, 30); + + // Conservation: original pot = 200 + 1 + 100 = 301 + assert_eq!( + bob_cashout + alice_pay + charlie_pay + ec_treasury_delta + resolve_treasury_delta, + 301, + ); +} + +// ─── Row 11: Boundary — forfeit transitions from 0 to 1 ──────────────────── + +/// Stake=10 with penalty=1000 bps (10%) yields forfeit = 10*1000/10000 = 1. +/// This is the smallest stake where forfeit is non-zero. Verify exact split: +/// cashout=9, forfeit=1. +#[test] +fn dust_forfeit_boundary_transition() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); // stake=10, boundary dust + let bob = Address::generate(&env); // normal + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &10, &BetSide::Up); + client.place_bet(&bob, &500, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); // 10% penalty + + env.ledger().with_mut(|li| li.sequence_number = 7); + + let treasury_before = client.get_protocol_fee_treasury(); + let alice_pending_before = client.get_pending_winnings(&alice); + client.cash_out_early(&alice); + + let alice_cashout = client.get_pending_winnings(&alice) - alice_pending_before; + let treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + // 10 * 1000 / 10000 = 1 forfeit, 9 cashout + assert_eq!(alice_cashout, 9, "boundary forfeit: cashout = stake - forfeit"); + assert_eq!(treasury_delta, 1, "boundary forfeit: exactly 1 stroop to treasury"); + assert_eq!(alice_cashout + treasury_delta, 10, "conservation: cashout + forfeit == stake"); + + // Pool reduced by full stake + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 0); + assert_eq!(round.pool_down, 500); +} + +// ─── Row 12: Dust cash-out with FeeOnWinnings model ───────────────────────── + +/// Dust stake on the DOWN side cashes out (forfeit = 0). Settlement uses +/// FeeOnWinnings model. The settlement fee is only on net winnings, so the +/// losing pool is reduced by the fee amount instead of the total pot. +#[test] +fn dust_cashout_fee_on_winnings_model() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); // dust down (cashes out) + let bob = Address::generate(&env); // normal up (winner) + let charlie = Address::generate(&env); // normal down + client.mint_initial(&alice); + client.mint_initial(&bob); + client.mint_initial(&charlie); + + client.create_round(&1_000u128, &None); + client.place_bet(&bob, &200, &BetSide::Up); + client.place_bet(&alice, &1, &BetSide::Down); // dust + client.place_bet(&charlie, &100, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); + set_fee_bps_now(&env, &contract_id, 1_000); // 10% fee + set_fee_model_now(&env, &contract_id, FeeModel::FeeOnWinnings); + + // Alice cashes out during Running phase + env.ledger().with_mut(|li| li.sequence_number = 7); + let treasury_before = client.get_protocol_fee_treasury(); + let alice_before = client.get_pending_winnings(&alice); + client.cash_out_early(&alice); + let alice_cashout = client.get_pending_winnings(&alice) - alice_before; + let ec_treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + assert_eq!(alice_cashout, 1, "dust forfeit floors to 0"); + assert_eq!(ec_treasury_delta, 0); + + // Pool: up=200, down=100 + let round = client.get_active_round().unwrap(); + assert_eq!(round.pool_up, 200); + assert_eq!(round.pool_down, 100); + + // Resolve: price up → UP wins + // FeeOnWinnings: fee = losing_pool * bps / 10000 = 100 * 1000 / 10000 = 10 + // dist_winning = 200, dist_losing = 100 - 10 = 90 + // bob_share = floor(200 * 290 / 200) = 290 + env.ledger().with_mut(|li| li.sequence_number = 12); + let bob_before = client.get_pending_winnings(&bob); + let charlie_before = client.get_pending_winnings(&charlie); + let treasury_before_resolve = client.get_protocol_fee_treasury(); + resolve_at(&env, &client, &contract_id, 1_100u128); + + let bob_pay = client.get_pending_winnings(&bob) - bob_before; + let charlie_pay = client.get_pending_winnings(&charlie) - charlie_before; + let resolve_treasury_delta = client.get_protocol_fee_treasury() - treasury_before_resolve; + + assert_eq!(bob_pay, 290, "winner gets: 200 * (200+90)/200"); + assert_eq!(charlie_pay, 0, "loser gets nothing"); + assert_eq!(resolve_treasury_delta, 10, "FeeOnWinnings: 10% of losing pool"); + + // Conservation: original pot = 1 + 200 + 100 = 301 + assert_eq!( + alice_cashout + bob_pay + charlie_pay + ec_treasury_delta + resolve_treasury_delta, + 301, + ); +} + +// ─── Row 13: Dust stake with max penalty (1000 bps), still full refund ────── + +/// Even with the maximum penalty of 1000 bps (10%), a 1-stroop stake still +/// gets a full refund because 1 * 1000 / 10000 = 0 (floor). This tests +/// the extreme boundary: max penalty × min stake. +#[test] +fn dust_cashout_max_penalty_still_full_refund() { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &1, &BetSide::Up); // dust + client.place_bet(&bob, &100, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, 1_000); // max penalty = 10% + + env.ledger().with_mut(|li| li.sequence_number = 7); + + let treasury_before = client.get_protocol_fee_treasury(); + let alice_pending_before = client.get_pending_winnings(&alice); + client.cash_out_early(&alice); + + let alice_cashout = client.get_pending_winnings(&alice) - alice_pending_before; + let treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + // 1 * 1000 / 10000 = 0 → full refund even at max penalty + assert_eq!(alice_cashout, 1, "max penalty × dust → full refund"); + assert_eq!(treasury_delta, 0, "treasury unchanged"); +} + +// ─── Property-based: dust cash-out conservation for random stakes/penalties ─ + +proptest! { + #![proptest_config(ProptestConfig::with_cases(25))] + + /// For any combination of dust-stake (1–99 stroops), penalty bps + /// (1000–10000), and a normal opposing stake, conservation must hold: + /// cashout + forfeit == stake and pool totals decrease by exactly stake. + /// This covers the entire "dust regime" where forfeit may or may not + /// floor to zero depending on the arithmetic. + #[test] + fn dust_cashout_conservation_property( + dust_stake in 1i128..=99i128, + normal_stake in 100i128..=1_000i128, + penalty_bps in 1000u32..=10_000u32, + ) { + let env = Env::default(); + let (client, contract_id, _admin, _oracle) = setup(&env); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&1_000u128, &None); + client.place_bet(&alice, &dust_stake, &BetSide::Up); + client.place_bet(&bob, &normal_stake, &BetSide::Down); + set_ec_bps_now(&env, &contract_id, penalty_bps); + + let total_pot = dust_stake + normal_stake; + + env.ledger().with_mut(|li| li.sequence_number = 7); + + let treasury_before = client.get_protocol_fee_treasury(); + let alice_pending_before = client.get_pending_winnings(&alice); + client.cash_out_early(&alice); + + let alice_cashout = client.get_pending_winnings(&alice) - alice_pending_before; + let ec_treasury_delta = client.get_protocol_fee_treasury() - treasury_before; + + let expected_forfeit = dust_stake * (penalty_bps as i128) / 10_000; + let expected_cashout = dust_stake - expected_forfeit; + + prop_assert_eq!(alice_cashout, expected_cashout, + "cashout must match formula: stake={} penalty_bps={}", dust_stake, penalty_bps); + prop_assert_eq!(ec_treasury_delta, expected_forfeit, + "treasury delta must equal forfeit"); + prop_assert_eq!( + alice_cashout + ec_treasury_delta, + dust_stake, + "cashout + forfeit must equal original stake" + ); + + // Pool must decrease by exactly dust_stake + let round = client.get_active_round().unwrap(); + prop_assert_eq!(round.pool_up, 0, "pool_up must be 0 after cash-out"); + prop_assert_eq!(round.pool_down, normal_stake, "pool_down unchanged"); + + // Resolve remaining round: one-sided (up=0) → refund for bob + env.ledger().with_mut(|li| li.sequence_number = 12); + let bob_before = client.get_pending_winnings(&bob); + let treasury_before_resolve = client.get_protocol_fee_treasury(); + resolve_at(&env, &client, &contract_id, 900u128); + + let bob_pay = client.get_pending_winnings(&bob) - bob_before; + let resolve_treasury_delta = client.get_protocol_fee_treasury() - treasury_before_resolve; + + prop_assert_eq!(bob_pay, normal_stake, "bob gets full refund (one-sided)"); + prop_assert_eq!(resolve_treasury_delta, 0, "one-sided → no settlement fee"); + + // Full conservation: original pot == all payouts + treasury + prop_assert_eq!( + alice_cashout + bob_pay + ec_treasury_delta + resolve_treasury_delta, + total_pot, + "full round conservation violated" + ); + } +} diff --git a/contracts/src/tests/mod.rs b/contracts/src/tests/mod.rs index 6cedefd..0b9400b 100644 --- a/contracts/src/tests/mod.rs +++ b/contracts/src/tests/mod.rs @@ -14,8 +14,9 @@ mod config_helpers; mod conservation; mod cost_benchmarks; mod deviation_reference; -mod edge_cases; mod drill; +mod early_cashout_dust; +mod edge_cases; mod event_coverage; mod fee_model; mod guard_tests; diff --git a/contracts/src/tests/pending_winnings_expiry.rs b/contracts/src/tests/pending_winnings_expiry.rs index 2dc3687..4b40795 100644 --- a/contracts/src/tests/pending_winnings_expiry.rs +++ b/contracts/src/tests/pending_winnings_expiry.rs @@ -84,7 +84,24 @@ fn test_reclaim_fails_when_expiry_disabled() { set_pending_at_current_ledger(&env, &contract_id, &user, 1000); let result = client.try_reclaim_expired_pending_winnings(&user); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); + assert_eq!(result, Err(Ok(ContractError::ExpiryNotConfigured))); +} + +#[test] +fn test_reclaim_fails_when_expiry_disabled_after_enable() { + let (env, admin, contract_id, client) = setup(); + let user = Address::generate(&env); + + env.ledger().with_mut(|li| li.sequence_number = 0); + set_pending_at_current_ledger(&env, &contract_id, &user, 1000); + apply_pending_winnings_expiry(&env, &client, 500); + + // Disable expiry after enabling it + apply_pending_winnings_expiry(&env, &client, 0); + assert_eq!(client.get_pending_winnings_expiry(), 0); + + let result = client.try_reclaim_expired_pending_winnings(&user); + assert_eq!(result, Err(Ok(ContractError::ExpiryNotConfigured))); } #[test] @@ -94,7 +111,38 @@ fn test_reclaim_fails_for_nonexistent_pending() { apply_pending_winnings_expiry(&env, &client, 500); let result = client.try_reclaim_expired_pending_winnings(&user); - assert_eq!(result, Err(Ok(ContractError::NoActiveRound))); + assert_eq!(result, Err(Ok(ContractError::PendingWinningsNotFound))); +} + +#[test] +fn test_reclaim_fails_for_premature_reclaim() { + let (env, admin, contract_id, client) = setup(); + let user = Address::generate(&env); + + // Set pending winnings at ledger 100 with expiry of 500 + env.ledger().with_mut(|li| li.sequence_number = 100); + set_pending_at_current_ledger(&env, &contract_id, &user, 2000); + apply_pending_winnings_expiry(&env, &client, 500); + + // Try at ledger 300: age = 200, which is < 500 + env.ledger().with_mut(|li| li.sequence_number = 300); + let result = client.try_reclaim_expired_pending_winnings(&user); + assert_eq!(result, Err(Ok(ContractError::PendingWinningsNotExpired))); + + // Try at ledger 500: age = 400, still < 500 + env.ledger().with_mut(|li| li.sequence_number = 500); + let result = client.try_reclaim_expired_pending_winnings(&user); + assert_eq!(result, Err(Ok(ContractError::PendingWinningsNotExpired))); + + // Try at ledger 599: age = 499, still < 500 + env.ledger().with_mut(|li| li.sequence_number = 599); + let result = client.try_reclaim_expired_pending_winnings(&user); + assert_eq!(result, Err(Ok(ContractError::PendingWinningsNotExpired))); + + // At ledger 600: age = 500 == expiry → eligible + env.ledger().with_mut(|li| li.sequence_number = 600); + let reclaimed = client.reclaim_expired_pending_winnings(&user); + assert_eq!(reclaimed, 2000); } #[test] diff --git a/docs/WALLET_ERROR_GUIDE.md b/docs/WALLET_ERROR_GUIDE.md index da5a3e6..7762dde 100644 --- a/docs/WALLET_ERROR_GUIDE.md +++ b/docs/WALLET_ERROR_GUIDE.md @@ -5,56 +5,73 @@ This guide maps each smart‑contract error defined in `contracts/src/errors.rs` ## Error Table | Hex Code | Decimal | Enum Identifier | Technical Meaning | Consumer‑Facing Message | |----------|---------|----------------|-------------------|------------------------| -| `0x01` | 1 | AlreadyInitialized | Contract has already been initialized | "Contract already initialized." -| `0x02` | 2 | AdminNotSet | Admin address not set - call initialize first | "Admin not set. Initialize contract first." -| `0x03` | 3 | OracleNotSet | Oracle address not set - call initialize first | "Oracle not set. Initialize contract first." -| `0x04` | 4 | UnauthorizedAdmin | Only admin can perform this action | "Admin only action." -| `0x05` | 5 | UnauthorizedOracle | Only oracle can perform this action | "Oracle only action." -| `0x06` | 6 | InvalidBetAmount | Bet amount must be greater than zero | "Bet amount must be > 0." -| `0x07` | 7 | NoActiveRound | No active round exists | "No active round." -| `0x08` | 8 | RoundEnded | Round has already ended | "Round already ended." -| `0x09` | 9 | InsufficientBalance | User has insufficient balance | "Insufficient balance." -| `0x0a` | 10 | AlreadyBet | User has already placed a bet in this round | "Bet already placed this round." -| `0x0b` | 11 | Overflow | Arithmetic overflow occurred | "Arithmetic overflow." -| `0x0c` | 12 | InvalidPrice | Invalid price value | "Invalid price." -| `0x0d` | 13 | InvalidDuration | Invalid duration value | "Invalid duration." -| `0x0e` | 14 | InvalidMode | Invalid round mode (must be 0 or 1) | "Invalid round mode." -| `0x0f` | 15 | WrongModeForPrediction | Wrong prediction type for current round mode | "Wrong prediction type for round mode." -| `0x10` | 16 | RoundNotEnded | Round has not reached end_ledger yet | "Round not yet ended." -| `0x11` | 17 | InvalidPriceScale | Invalid price scale (must represent 4 decimal places) | "Invalid price scale." -| `0x12` | 18 | StaleOracleData | Oracle data is too old (STALE) | "Stale oracle data." -| `0x13` | 19 | InvalidOracleRound | Oracle payload round_id doesn't match ActiveRound | "Mismatched oracle round ID." -| `0x14` | 20 | RoundAlreadyActive | An active round already exists and cannot be overwritten | "Active round already exists." -| `0x15` | 21 | AdminIsOracle | Admin and Oracle addresses cannot be identical | "Admin cannot be Oracle." -| `0x16` | 22 | ContractPaused | Contract is paused for emergency recovery | "Contract paused." -| `0x17` | 23 | WindowOutOfRange | One or more window values exceed configured maximum bounds | "Window value out of range." -| `0x18` | 24 | FutureOracleData | Oracle payload timestamp is in the future | "Future oracle timestamp." -| `0x19` | 25 | PayoutOverflow | Arithmetic overflow in payout accumulation — no funds moved | "Payout overflow." -| `0x1a` | 26 | RoundCancelled | Round has been cancelled and cannot be resolved | "Round cancelled." -| `0x1b` | 27 | RoundNotCancellable | Round cannot be cancelled (no active round or already resolved) | "Round not cancellable." -| `0x1c` | 28 | StakeExceedsMax | Bet amount exceeds the configured maximum stake | "Bet exceeds max stake." -| `0x1d` | 29 | ExposureCapExceeded | User's cumulative exposure in this round exceeds the configured cap | "Exposure cap exceeded." -| `0x1e` | 30 | PendingWinningsCapExceeded | Pending winnings accumulation would exceed the configured cap | "Pending winnings cap exceeded." -| `0x1f` | 31 | StartPriceTooLow | Start price is below the minimum allowed value | "Start price too low." -| `0x20` | 32 | StartPriceTooHigh | Start price exceeds the maximum allowed value | "Start price too high." -| `0x21` | 33 | OracleNonceReused | Oracle payload nonce was already consumed for this round (replay) | "Oracle nonce reused." -| `0x22` | 34 | InsufficientParticipants | Round has fewer participants than the configured minimum for competitive settlement | "Insufficient participants." -| `0x23` | 35 | InvalidMinParticipants | Minimum participants value is out of valid range (must be 1–10000) | "Invalid min participants."| `0x24` | 36 | InvalidOracleStatus | Oracle heartbeat status is out of range (must be 0, 1, or 2) | "Invalid oracle status." | -| `0x42` | 66 | OracleNotLive | Oracle heartbeat is not live and strict mode blocks settlement | "Oracle heartbeat not live." | `0x25` | 37 | InvalidStaleThreshold | Oracle stale threshold is out of valid range (must be 60–86400 seconds) | "Invalid stale threshold." -| `0x26` | 38 | InvalidOracleDeviationBps | Oracle max deviation bps is invalid (must be > 0) | "Invalid oracle deviation BPS." -| `0x27` | 39 | OracleDeviationExceeded | Oracle final price deviates beyond configured threshold | "Oracle deviation exceeded." -| `0x28` | 40 | UnsupportedSchemaVersion | Stored schema version is unknown or unsupported by this contract build | "Unsupported schema version." -| `0x29` | 41 | InvalidMigrationPath | Migration path is invalid for the stored schema version | "Invalid migration path." -| `0x2a` | 42 | MigrationActiveRound | Migration cannot run while a round is active | "Migration not allowed during active round." -| `0x2b` | 43 | CommitmentNotFound | Commitment for precision prediction not found | "Precision commitment not found." -| `0x2c` | 44 | AlreadyRevealed | Precision prediction has already been revealed | "Prediction already revealed." -| `0x2d` | 45 | InvalidRevealWindow | Attempted to reveal prediction outside the valid window | "Invalid reveal window." -| `0x2e` | 46 | HashMismatch | Revealed prediction hash does not match committed hash | "Hash mismatch." -| `0x2f` | 47 | PrecisionParticipantCapExceeded | Precision round has reached the configured participant cap | "Precision participant cap exceeded." -| `0x30` | 48 | InvalidPrecisionParticipantCap | Precision participant cap is out of range (must be 1–10000) | "Invalid precision participant cap." -| `0x3f` | 63 | InvalidCommitment | Commitment hash is malformed (e.g. all-zero placeholder) | "Invalid commitment hash." -| `0x40` | 64 | InvalidSalt | Reveal salt fails minimum entropy rules | "Invalid reveal salt." -| `0x41` | 65 | NoRoundTemplate | No round template configured | "No round template." +| `0x01` | 1 | AlreadyInitialized | Contract has already been initialized | "Contract already initialized." | +| `0x02` | 2 | AdminNotSet | Admin address not set - call initialize first | "Admin not set. Initialize contract first." | +| `0x03` | 3 | OracleNotSet | Oracle address not set - call initialize first | "Oracle not set. Initialize contract first." | +| `0x04` | 4 | UnauthorizedAdmin | Only admin can perform this action | "Admin only action." | +| `0x05` | 5 | UnauthorizedOracle | Only oracle can perform this action | "Oracle only action." | +| `0x06` | 6 | InvalidBetAmount | Bet amount must be greater than zero | "Bet amount must be > 0." | +| `0x07` | 7 | NoActiveRound | No active round exists | "No active round." | +| `0x08` | 8 | RoundEnded | Round has already ended | "Round already ended." | +| `0x09` | 9 | InsufficientBalance | User has insufficient balance | "Insufficient balance." | +| `0x0a` | 10 | AlreadyBet | User has already placed a bet in this round | "Bet already placed this round." | +| `0x0b` | 11 | Overflow | Arithmetic overflow occurred | "Arithmetic overflow." | +| `0x0c` | 12 | InvalidPrice | Invalid price value | "Invalid price." | +| `0x0d` | 13 | InvalidDuration | Invalid duration value | "Invalid duration." | +| `0x0e` | 14 | InvalidMode | Invalid round mode (must be 0 or 1) | "Invalid round mode." | +| `0x0f` | 15 | WrongModeForPrediction | Wrong prediction type for current round mode | "Wrong prediction type for round mode." | +| `0x10` | 16 | RoundNotEnded | Round has not reached end_ledger yet | "Round not yet ended." | +| `0x11` | 17 | InvalidPriceScale | Invalid price scale (must represent 4 decimal places) | "Invalid price scale." | +| `0x12` | 18 | StaleOracleData | Oracle data is too old (STALE) | "Stale oracle data." | +| `0x13` | 19 | InvalidOracleRound | Oracle payload round_id doesn't match ActiveRound | "Mismatched oracle round ID." | +| `0x14` | 20 | RoundAlreadyActive | An active round already exists and cannot be overwritten | "Active round already exists." | +| `0x15` | 21 | AdminIsOracle | Admin and Oracle addresses cannot be identical | "Admin cannot be Oracle." | +| `0x16` | 22 | ContractPaused | Contract is paused for emergency recovery | "Contract paused." | +| `0x17` | 23 | WindowOutOfRange | One or more window values exceed configured maximum bounds | "Window value out of range." | +| `0x18` | 24 | FutureOracleData | Oracle payload timestamp is in the future | "Future oracle timestamp." | +| `0x19` | 25 | PayoutOverflow | Arithmetic overflow in payout accumulation — no funds moved | "Payout overflow." | +| `0x1a` | 26 | RoundCancelled | Round has been cancelled and cannot be resolved | "Round cancelled." | +| `0x1b` | 27 | RoundNotCancellable | Round cannot be cancelled (no active round or already resolved) | "Round not cancellable." | +| `0x1c` | 28 | StakeExceedsMax | Bet amount exceeds the configured maximum stake | "Bet exceeds max stake." | +| `0x1d` | 29 | ExposureCapExceeded | User's cumulative exposure in this round exceeds the configured cap | "Exposure cap exceeded." | +| `0x1e` | 30 | PendingWinningsCapExceeded | Pending winnings accumulation would exceed the configured cap | "Pending winnings cap exceeded." | +| `0x1f` | 31 | StartPriceTooLow | Start price is below the minimum allowed value | "Start price too low." | +| `0x20` | 32 | StartPriceTooHigh | Start price exceeds the maximum allowed value | "Start price too high." | +| `0x21` | 33 | OracleNonceReused | Oracle payload nonce was already consumed for this round (replay) | "Oracle nonce reused." | +| `0x22` | 34 | InsufficientParticipants | Round has fewer participants than the configured minimum for competitive settlement | "Insufficient participants." | +| `0x23` | 35 | InvalidMinParticipants | Minimum participants value is out of valid range (must be 1–10000) | "Invalid min participants." | +| `0x24` | 36 | InvalidOracleStatus | Oracle heartbeat status is out of range (must be 0, 1, or 2) | "Invalid oracle status." | +| `0x25` | 37 | InvalidStaleThreshold | Oracle stale threshold is out of valid range (must be 60–86400 seconds) | "Invalid stale threshold." | +| `0x26` | 38 | InvalidOracleDeviationBps | Oracle max deviation bps is invalid (must be > 0) | "Invalid oracle deviation BPS." | +| `0x27` | 39 | OracleDeviationExceeded | Oracle final price deviates beyond configured threshold | "Oracle deviation exceeded." | +| `0x28` | 40 | UnsupportedSchemaVersion | Stored schema version is unknown or unsupported by this contract build | "Unsupported schema version." | +| `0x29` | 41 | InvalidMigrationPath | Migration path is invalid for the stored schema version | "Invalid migration path." | +| `0x2a` | 42 | MigrationActiveRound | Migration cannot run while a round is active | "Migration not allowed during active round." | +| `0x2b` | 43 | CommitmentNotFound | Commitment for precision prediction not found | "Precision commitment not found." | +| `0x2c` | 44 | AlreadyRevealed | Precision prediction has already been revealed | "Prediction already revealed." | +| `0x2d` | 45 | InvalidRevealWindow | Attempted to reveal prediction outside the valid window | "Invalid reveal window." | +| `0x2e` | 46 | HashMismatch | Revealed prediction hash does not match committed hash | "Hash mismatch." | +| `0x2f` | 47 | PrecisionParticipantCapExceeded | Precision round has reached the configured participant cap | "Precision participant cap exceeded." | +| `0x30` | 48 | InvalidPrecisionParticipantCap | Precision participant cap is out of range (must be 1–10000) | "Invalid precision participant cap." | +| `0x37` | 55 | RotationDelayNotElapsed | Oracle rotation delay has not elapsed yet | "Rotation delay not elapsed." | +| `0x3e` | 62 | InvalidArchiveRetention | Invalid archive retention limit | "Invalid archive retention." | +| `0x3f` | 63 | InvalidCommitment | Commitment hash is malformed (e.g. all-zero placeholder) | "Invalid commitment hash." | +| `0x40` | 64 | InvalidSalt | Reveal salt fails minimum entropy rules | "Invalid reveal salt." | +| `0x41` | 65 | NoRoundTemplate | No round template configured | "No round template." | +| `0x42` | 66 | OracleTimestampOutsideWindow | Oracle payload timestamp is outside the round-relative economic window | "Oracle timestamp outside window." | +| `0x43` | 67 | EpochBudgetExceeded | Epoch mint budget has been fully consumed | "Epoch budget exceeded." | +| `0x44` | 68 | OracleNotLive | Oracle heartbeat is not live and strict mode blocks settlement | "Oracle heartbeat not live." | +| `0x45` | 69 | InvalidPayoutPolicy | Invalid precision payout policy | "Invalid payout policy." | +| `0x46` | 70 | BelowMinBet | Stake amount is below the configured minimum bet | "Bet below minimum." | +| `0x47` | 71 | InsufficientOracleQuorum | Multi-feed: fewer observations survived outlier rejection than quorum threshold | "Insufficient oracle quorum." | +| `0x48` | 72 | TooFewObservations | Multi-feed: payload contains fewer observations than configured minimum | "Too few oracle observations." | +| `0x49` | 73 | OracleOutlierRejected | Multi-feed: outlier observations dominate the result | "Oracle outlier rejected." | +| `0x4a` | 74 | DuplicateOracleSource | Multi-feed payload contains duplicate source identifiers | "Duplicate oracle source." | +| `0x4b` | 75 | InvalidObservationOrder | Multi-feed payload observations are not sorted or sources out of range | "Invalid observation order." | +| `0x4c` | 76 | UnsupportedDataKeyForTtlTouch | Requested data key not allowed for batch TTL touch | "Unsupported data key for TTL touch." | +| `0x4d` | 77 | PendingWinningsNotFound | Pending winnings entry does not exist | "No pending winnings found." | +| `0x4e` | 78 | ExpiryNotConfigured | Pending winnings expiry is not configured (value is 0) | "Pending winnings expiry not configured." | +| `0x4f` | 79 | PendingWinningsNotExpired | Pending winnings entry has not yet reached the expiry threshold | "Pending winnings not yet expired." | ## Integration Walkthroughs ### 1. Handling errors in a Freighter wallet @@ -105,4 +122,4 @@ assert.include(errorText, "Insufficient balance"); Add the script `scripts/check-doc-drift.js` to your CI pipeline. If the script exits with a non‑zero status, the CI job fails, prompting a documentation update before merging. --- -*Last updated: 2026‑06‑27* +*Last updated: 2026‑08‑26*