From 3dd3f871f3b98869ae068c886b4db99434bfc1fc Mon Sep 17 00:00:00 2001 From: Owoh Chidubem Alexander Date: Wed, 29 Jul 2026 09:26:08 +0100 Subject: [PATCH] feat: query paginated user participation history (#281) - Add DataKey::UserArchivedRoundIds(Address) to track per-user archived round participation - Modify _archive_round to accept participant list and write per-user round ID indices - Add get_user_archive_history(env, user, offset, limit) query with pagination - Add comprehensive tests: resolve, cancel, fallback, precision mode, pagination ordering, offset/limit, bounds, multi-user isolation, empty history - Update TypeScript bindings with get_user_archive_history method - Fix root Cargo.toml (remove invalid dev-dependencies from virtual manifest, pin SDK) --- Cargo.lock | 93 +---- Cargo.toml | 5 +- bindings/src/index.ts | 17 + contracts/src/contract.rs | 88 ++++- contracts/src/tests/archive_participation.rs | 388 +++++++++++++++++++ contracts/src/tests/mod.rs | 1 + contracts/src/types.rs | 5 + 7 files changed, 501 insertions(+), 96 deletions(-) create mode 100644 contracts/src/tests/archive_participation.rs diff --git a/Cargo.lock b/Cargo.lock index 5ec86fa1..d9aff047 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,12 +225,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes-lit" version = "0.0.5" @@ -338,20 +332,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.5.0" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67773048316103656a637612c4a62477603b777d91d9c62ff2290f9cde178fdb" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ - "ctor-proc-macro", - "dtor", + "quote", + "syn 2.0.117", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -514,21 +502,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dyn-clone" version = "1.0.20" @@ -749,15 +722,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "hash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.12.3" @@ -788,16 +752,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -[[package]] -name = "heapless" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" -dependencies = [ - "hash32", - "stable_deref_trait", -] - [[package]] name = "heck" version = "0.5.0" @@ -1558,7 +1512,7 @@ dependencies = [ "soroban-env-common", "soroban-wasmi", "static_assertions", - "stellar-strkey 0.0.13", + "stellar-strkey", "wasmparser 0.116.1", ] @@ -1593,9 +1547,9 @@ dependencies = [ [[package]] name = "soroban-sdk" -version = "23.5.3" +version = "23.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd1e11f9fd537f9df29ec1a66c1b78d666094df56e196565d292ebf9a72732b4" +checksum = "f7c11b7f617a5b355ad8eba4c6ace360d735acc45517447ea832ebe176ca90d0" dependencies = [ "arbitrary", "bytes-lit", @@ -1611,8 +1565,7 @@ dependencies = [ "soroban-env-host", "soroban-ledger-snapshot", "soroban-sdk-macros", - "stellar-strkey 0.0.16", - "visibility", + "stellar-strkey", ] [[package]] @@ -1692,12 +1645,6 @@ dependencies = [ "der", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - [[package]] name = "static_assertions" version = "1.1.0" @@ -1714,17 +1661,6 @@ dependencies = [ "data-encoding", ] -[[package]] -name = "stellar-strkey" -version = "0.0.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084afcb0d458c3d5d5baa2d294b18f881e62cc258ef539d8fdf68be7dbe45520" -dependencies = [ - "crate-git-revision", - "data-encoding", - "heapless", -] - [[package]] name = "stellar-xdr" version = "23.0.0" @@ -1741,7 +1677,7 @@ dependencies = [ "serde", "serde_with", "sha2", - "stellar-strkey 0.0.13", + "stellar-strkey", ] [[package]] @@ -1887,17 +1823,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "visibility" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 59351862..bb1a5c65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,10 +5,7 @@ members = [ ] [workspace.dependencies] -soroban-sdk = "23.0.1" - -[dev-dependencies] -rand = "0.8" +soroban-sdk = "=23.0.1" [profile.release] opt-level = "z" diff --git a/bindings/src/index.ts b/bindings/src/index.ts index 5e239a41..31b12310 100644 --- a/bindings/src/index.ts +++ b/bindings/src/index.ts @@ -740,6 +740,22 @@ export interface Client { */ get_recent_archived_rounds: ({limit}: {limit: u32}, options?: MethodOptions) => Promise>> + /** + * Construct and simulate a get_user_archive_history transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. + * Returns paginated archived participation history for a user (newest first). + * + * Reads the user's on-chain index of archived round IDs, applies offset/limit + * pagination, and resolves each ID to its ArchivedRoundSummary. Stale + * entries (rounds pruned by FIFO retention) are silently skipped. + * + * Standard pagination semantics: + * - offset past the end → empty page + * - limit == 0 → empty page + * - limit capped at MAX_PAGE_SIZE (100) + * - Ordering is newest-first (descending round ID) + */ + get_user_archive_history: ({user, offset, limit}: {user: string, offset: u32, limit: u32}, options?: MethodOptions) => Promise>> + /** * Construct and simulate a place_precision_prediction transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. * Places a precision prediction on the active round (Precision/Legends mode only) @@ -964,6 +980,7 @@ export class Client extends ContractClient { get_precision_predictions: this.txFromJSON>, get_oracle_stale_threshold: this.txFromJSON, get_recent_archived_rounds: this.txFromJSON>, + get_user_archive_history: this.txFromJSON>, place_precision_prediction: this.txFromJSON>, set_oracle_stale_threshold: this.txFromJSON>, get_oracle_max_deviation_bps: this.txFromJSON>, diff --git a/contracts/src/contract.rs b/contracts/src/contract.rs index 884de9dd..230270c3 100644 --- a/contracts/src/contract.rs +++ b/contracts/src/contract.rs @@ -372,6 +372,65 @@ impl VirtualTokenContract { result } + /// Returns paginated archived participation history for a user (newest first). + /// + /// Reads the user's on-chain index of archived round IDs, applies offset/limit + /// pagination, and resolves each ID to its [`ArchivedRoundSummary`]. Stale + /// entries (rounds pruned by FIFO retention) are silently skipped. + /// + /// Standard pagination semantics: + /// - `offset` past the end → empty page + /// - `limit == 0` → empty page + /// - `limit` capped at [`MAX_PAGE_SIZE`] + /// - Ordering is newest-first (descending round ID) + pub fn get_user_archive_history( + env: Env, + user: Address, + offset: u32, + limit: u32, + ) -> Vec { + let env_ref = &env; + let limit = limit.min(MAX_PAGE_SIZE); + if limit == 0 { + return Vec::new(env_ref); + } + + let user_rounds: Vec = env + .storage() + .persistent() + .get(&DataKey::UserArchivedRoundIds(user)) + .unwrap_or(Vec::new(env_ref)); + + let total = user_rounds.len(); + if offset >= total { + return Vec::new(env_ref); + } + + // Read newest-first (the stored list has newest appended at the end). + let start = total.saturating_sub(offset + 1); + let end = start.saturating_sub(limit.saturating_sub(1)); + // Walk descending from start down to end (inclusive). + let mut idx = start; + let mut result: Vec = Vec::new(env_ref); + loop { + if let Some(round_id) = user_rounds.get(idx) { + if let Some(summary) = env + .storage() + .persistent() + .get(&DataKey::ArchivedRound(round_id)) + { + result.push_back(summary); + } + } + if idx == end || result.len() as u32 == limit { + break; + } + idx = idx.saturating_sub(1); + } + + result + } + pub fn get_admin(env: Env) -> Option
{ let key = DataKey::Admin; Self::_extend_persistent_ttl(&env, &key); @@ -1873,7 +1932,7 @@ impl VirtualTokenContract { &round, RoundArchiveStatus::FallbackRefund, payload.price, - count, + &threshold_participants, ); Self::_refund_under_threshold(&env, &round, &threshold_participants)?; #[allow(deprecated)] @@ -1909,14 +1968,13 @@ impl VirtualTokenContract { .persistent() .get(&DataKey::RoundParticipants(round_id)) .unwrap_or(Vec::new(&env)); - let participant_count = participants.len(); Self::_archive_round( &env, &round, RoundArchiveStatus::Resolved, payload.price, - participant_count, + &participants, ); for i in 0..participants.len() { @@ -2539,13 +2597,12 @@ impl VirtualTokenContract { } // Clean up participant list and mark round as cancelled - let participant_count = participants.len(); Self::_archive_round( &env, &round, RoundArchiveStatus::Cancelled, 0, - participant_count, + &participants, ); env.storage() @@ -2701,13 +2758,14 @@ impl VirtualTokenContract { Ok(()) } - /// Persists a compact round summary and enforces FIFO archive retention. + /// Persists a compact round summary, records per-user participation indices, + /// and enforces FIFO archive retention. fn _archive_round( env: &Env, round: &Round, status: RoundArchiveStatus, final_price: u128, - participant_count: u32, + participants: &Vec
, ) { let summary = ArchivedRoundSummary { round_id: round.round_id, @@ -2717,7 +2775,7 @@ impl VirtualTokenContract { status, pool_up: round.pool_up, pool_down: round.pool_down, - participant_count, + participant_count: participants.len() as u32, settled_at_ledger: env.ledger().sequence(), }; @@ -2725,6 +2783,20 @@ impl VirtualTokenContract { .persistent() .set(&DataKey::ArchivedRound(round.round_id), &summary); + // Record per-user participation index. + for i in 0..participants.len() { + if let Some(user) = participants.get(i) { + let index_key = DataKey::UserArchivedRoundIds(user.clone()); + let mut user_rounds: Vec = env + .storage() + .persistent() + .get(&index_key) + .unwrap_or(Vec::new(env)); + user_rounds.push_back(round.round_id); + env.storage().persistent().set(&index_key, &user_rounds); + } + } + let mut recent: Vec = env .storage() .persistent() diff --git a/contracts/src/tests/archive_participation.rs b/contracts/src/tests/archive_participation.rs new file mode 100644 index 00000000..2fa778a3 --- /dev/null +++ b/contracts/src/tests/archive_participation.rs @@ -0,0 +1,388 @@ +use crate::contract::{VirtualTokenContract, VirtualTokenContractClient}; +use crate::types::{BetSide, OraclePayload, RoundArchiveStatus}; +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{Address, Env, Vec}; + +/// Advances the ledger past round end and resolves with the given price. +fn resolve_active_round( + client: &VirtualTokenContractClient, + env: &Env, + final_price: u128, + nonce: u64, +) -> u64 { + let round = client.get_active_round().unwrap(); + let round_id = round.round_id; + env.ledger().with_mut(|li| { + li.sequence_number = round.end_ledger; + }); + client.resolve_round(&OraclePayload { + price: final_price, + timestamp: env.ledger().timestamp(), + round_id: round.start_ledger, + nonce, + network_id: env.ledger().network_id(), + contract_addr: client.address.clone(), + }); + round_id +} + +// ─── Participation recorded after resolve ─────────────────────────────────── + +#[test] +fn test_archived_participation_after_resolve() { + 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 alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&1_0000000, &None); + client.place_bet(&alice, &50_0000000, &BetSide::Up); + client.place_bet(&bob, &50_0000000, &BetSide::Down); + + let round_id = resolve_active_round(&client, &env, 2_0000000, 1); + + let alice_history = client.get_user_archive_history(&alice, &0, &10); + let bob_history = client.get_user_archive_history(&bob, &0, &10); + + assert_eq!(alice_history.len(), 1); + assert_eq!(alice_history.get(0).unwrap().round_id, round_id); + assert_eq!(alice_history.get(0).unwrap().status, RoundArchiveStatus::Resolved); + + assert_eq!(bob_history.len(), 1); + assert_eq!(bob_history.get(0).unwrap().round_id, round_id); + assert_eq!(bob_history.get(0).unwrap().status, RoundArchiveStatus::Resolved); +} + +#[test] +fn test_archived_participation_after_cancel() { + 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 alice = Address::generate(&env); + client.mint_initial(&alice); + + client.create_round(&1_0000000, &None); + client.place_bet(&alice, &100_0000000, &BetSide::Up); + let round_id = client.get_active_round().unwrap().round_id; + + client.cancel_round(&1u32); + + let history = client.get_user_archive_history(&alice, &0, &10); + assert_eq!(history.len(), 1); + assert_eq!(history.get(0).unwrap().round_id, round_id); + assert_eq!(history.get(0).unwrap().status, RoundArchiveStatus::Cancelled); +} + +#[test] +fn test_archived_participation_after_fallback_refund() { + 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 user = Address::generate(&env); + client.mint_initial(&user); + client.set_min_participants(&Some(2u32)); + + client.create_round(&1_0000000, &None); + client.place_bet(&user, &100_0000000, &BetSide::Up); + let round_id = client.get_active_round().unwrap().round_id; + + resolve_active_round(&client, &env, 1_2000000, 1); + + let history = client.get_user_archive_history(&user, &0, &10); + assert_eq!(history.len(), 1); + assert_eq!(history.get(0).unwrap().round_id, round_id); + assert_eq!(history.get(0).unwrap().status, RoundArchiveStatus::FallbackRefund); +} + +// ─── User with no participation returns empty ─────────────────────────────── + +#[test] +fn test_archived_participation_no_history() { + 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 stranger = Address::generate(&env); + + let history = client.get_user_archive_history(&stranger, &0, &10); + assert_eq!(history.len(), 0); +} + +#[test] +fn test_archived_participation_non_participant_after_round() { + 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 alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint_initial(&alice); + + client.create_round(&1_0000000, &None); + client.place_bet(&alice, &50_0000000, &BetSide::Up); + resolve_active_round(&client, &env, 2_0000000, 1); + + let bob_history = client.get_user_archive_history(&bob, &0, &10); + assert_eq!(bob_history.len(), 0); +} + +// ─── Pagination: ordering ─────────────────────────────────────────────────── + +#[test] +fn test_archived_participation_newest_first() { + 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 user = Address::generate(&env); + client.mint_initial(&user); + + for i in 0..5 { + client.create_round(&(1_0000000u128 + i as u128), &None); + client.place_bet(&user, &10_0000000, &BetSide::Up); + resolve_active_round(&client, &env, 2_0000000 + i as u128, i as u64 + 1); + } + + let all = client.get_user_archive_history(&user, &0, &10); + assert_eq!(all.len(), 5); + + for i in 1..all.len() { + let prev = all.get(i - 1).unwrap().round_id; + let curr = all.get(i).unwrap().round_id; + assert!( + prev > curr, + "history must be newest-first: {} <= {}", + prev, + curr + ); + } +} + +// ─── Pagination: offset / limit ───────────────────────────────────────────── + +#[test] +fn test_archived_participation_page_respects_offset_and_limit() { + 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 user = Address::generate(&env); + client.mint_initial(&user); + + let mut round_ids: Vec = Vec::new(&env); + for i in 0..5 { + client.create_round(&(1_0000000u128 + i as u128), &None); + client.place_bet(&user, &10_0000000, &BetSide::Up); + round_ids.push_back(resolve_active_round( + &client, + &env, + 2_0000000 + i as u128, + i as u64 + 1, + )); + } + + let page0 = client.get_user_archive_history(&user, &0, &1); + let page1 = client.get_user_archive_history(&user, &1, &1); + let page2 = client.get_user_archive_history(&user, &2, &1); + + assert_eq!(page0.len(), 1); + assert_eq!(page1.len(), 1); + assert_eq!(page2.len(), 1); + + assert_eq!(page0.get(0).unwrap().round_id, round_ids.get(4).unwrap()); + assert_eq!(page1.get(0).unwrap().round_id, round_ids.get(3).unwrap()); + assert_eq!(page2.get(0).unwrap().round_id, round_ids.get(2).unwrap()); +} + +#[test] +fn test_archived_participation_full_page_matches_all() { + 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 user = Address::generate(&env); + client.mint_initial(&user); + + for i in 0..3 { + client.create_round(&(1_0000000u128 + i as u128), &None); + client.place_bet(&user, &10_0000000, &BetSide::Up); + resolve_active_round(&client, &env, 2_0000000 + i as u128, i as u64 + 1); + } + + let page = client.get_user_archive_history(&user, &0, &10); + assert_eq!(page.len(), 3); +} + +// ─── Pagination: bounds ───────────────────────────────────────────────────── + +#[test] +fn test_archived_participation_offset_past_end_is_empty() { + 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 user = Address::generate(&env); + client.mint_initial(&user); + + client.create_round(&1_0000000, &None); + client.place_bet(&user, &10_0000000, &BetSide::Up); + resolve_active_round(&client, &env, 2_0000000, 1); + + let page_at_end = client.get_user_archive_history(&user, &1, &10); + assert_eq!(page_at_end.len(), 0); + + let page_far = client.get_user_archive_history(&user, &999, &10); + assert_eq!(page_far.len(), 0); +} + +#[test] +fn test_archived_participation_zero_limit_is_empty() { + 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 user = Address::generate(&env); + client.mint_initial(&user); + + client.create_round(&1_0000000, &None); + client.place_bet(&user, &10_0000000, &BetSide::Up); + resolve_active_round(&client, &env, 2_0000000, 1); + + let page = client.get_user_archive_history(&user, &0, &0); + assert_eq!(page.len(), 0); +} + +#[test] +fn test_archived_participation_limit_is_capped() { + 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 user = Address::generate(&env); + client.mint_initial(&user); + + for i in 0..3 { + client.create_round(&(1_0000000u128 + i as u128), &None); + client.place_bet(&user, &10_0000000, &BetSide::Up); + resolve_active_round(&client, &env, 2_0000000 + i as u128, i as u64 + 1); + } + + let page = client.get_user_archive_history(&user, &0, &1_000_000); + assert_eq!(page.len(), 3); +} + +// ─── Multi-user isolation ─────────────────────────────────────────────────── + +#[test] +fn test_archived_participation_multi_user_isolation() { + 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 alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&1_0000000, &None); + client.place_bet(&alice, &50_0000000, &BetSide::Up); + let round1_id = resolve_active_round(&client, &env, 2_0000000, 1); + + client.create_round(&1_0000000, &None); + client.place_bet(&bob, &30_0000000, &BetSide::Down); + let round2_id = resolve_active_round(&client, &env, 1_5000000, 2); + + let alice_hist = client.get_user_archive_history(&alice, &0, &10); + assert_eq!(alice_hist.len(), 1); + assert_eq!(alice_hist.get(0).unwrap().round_id, round1_id); + + let bob_hist = client.get_user_archive_history(&bob, &0, &10); + assert_eq!(bob_hist.len(), 1); + assert_eq!(bob_hist.get(0).unwrap().round_id, round2_id); +} + +// ─── Precision mode ───────────────────────────────────────────────────────── + +#[test] +fn test_archived_participation_precision_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); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + client.mint_initial(&alice); + client.mint_initial(&bob); + + client.create_round(&2000, &Some(1)); + client.place_precision_prediction(&alice, &30_0000000, &2296); + client.place_precision_prediction(&bob, &70_0000000, &2299); + + let round_id = resolve_active_round(&client, &env, 2298, 1); + + let alice_hist = client.get_user_archive_history(&alice, &0, &10); + let bob_hist = client.get_user_archive_history(&bob, &0, &10); + + assert_eq!(alice_hist.len(), 1); + assert_eq!(alice_hist.get(0).unwrap().round_id, round_id); + assert_eq!(bob_hist.len(), 1); + assert_eq!(bob_hist.get(0).unwrap().round_id, round_id); +} diff --git a/contracts/src/tests/mod.rs b/contracts/src/tests/mod.rs index 1c496088..0eecc7a3 100644 --- a/contracts/src/tests/mod.rs +++ b/contracts/src/tests/mod.rs @@ -24,3 +24,4 @@ mod security; mod storage_benchmarks; mod ttl_tests; mod windows; +mod archive_participation; diff --git a/contracts/src/types.rs b/contracts/src/types.rs index 572afb2d..d0b4bcb8 100644 --- a/contracts/src/types.rs +++ b/contracts/src/types.rs @@ -95,6 +95,11 @@ pub enum DataKey { LedgerMintCounter(u32), /// Mint limit configuration: maximum number of mints allowed per ledger. MintLimitConfig, + /// Per-user index of archived round IDs the user participated in. + /// Written during archiving; read for paginated history queries. + /// Not pruned when archived rounds are evicted — stale entries are + /// filtered at query time by checking ArchivedRound existence. + UserArchivedRoundIds(Address), } /// Identifies which critical risk setting is pending timelocked activation.