diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 5e55775..a7d3b0c 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -280,6 +280,21 @@ impl LearnToken { storage::get_total_minted_to(&env, &address) } + /// Returns the number of persistent storage entries this contract has + /// created, net of any since removed (#254). + /// + /// Soroban prices persistent storage by entry count and size, so this + /// lets callers monitor and forecast the contract's on-chain storage + /// costs. Only per-entity entries that grow with usage are counted + /// (reward claims, claim history, roles, whitelist, snapshots, vesting + /// schedules, vesting claimed amounts, proposals, votes, allowance- + /// spender registries, permit nonces, per-address minting totals); + /// singleton config values set once at `initialize()` are excluded + /// since they don't contribute to growing storage costs. + pub fn get_storage_size(env: Env) -> u32 { + storage::get_storage_size(&env) + } + pub fn balance(env: Env, address: Address) -> i128 { storage::get_balance(&env, &address) } @@ -1604,12 +1619,12 @@ mod tests { #[test] fn test_total_minted_to_updates_on_mint() { let env = Env::default(); - let (_admin, lt_id, _pt_id) = setup(&env); + let (admin, lt_id, _pt_id) = setup(&env); let client = LearnTokenClient::new(&env, <_id); env.mock_all_auths(); let user = Address::generate(&env); - client.mint(&user, &1_000); + client.mint(&admin, &user, &1_000); assert_eq!(client.total_minted_to(&user), 1_000); } @@ -1617,14 +1632,14 @@ mod tests { #[test] fn test_total_minted_to_accumulates_across_mints() { let env = Env::default(); - let (_admin, lt_id, _pt_id) = setup(&env); + let (admin, lt_id, _pt_id) = setup(&env); let client = LearnTokenClient::new(&env, <_id); env.mock_all_auths(); let user = Address::generate(&env); - client.mint(&user, &1_000); - client.mint(&user, &500); - client.mint(&user, &250); + client.mint(&admin, &user, &1_000); + client.mint(&admin, &user, &500); + client.mint(&admin, &user, &250); assert_eq!(client.total_minted_to(&user), 1_750); } @@ -1632,14 +1647,14 @@ mod tests { #[test] fn test_total_minted_to_is_tracked_per_address() { let env = Env::default(); - let (_admin, lt_id, _pt_id) = setup(&env); + let (admin, lt_id, _pt_id) = setup(&env); let client = LearnTokenClient::new(&env, <_id); env.mock_all_auths(); let alice = Address::generate(&env); let bob = Address::generate(&env); - client.mint(&alice, &900); - client.mint(&bob, &100); + client.mint(&admin, &alice, &900); + client.mint(&admin, &bob, &100); assert_eq!(client.total_minted_to(&alice), 900); assert_eq!(client.total_minted_to(&bob), 100); @@ -1649,13 +1664,13 @@ mod tests { #[test] fn test_total_minted_to_unchanged_by_transfer_and_burn() { let env = Env::default(); - let (_admin, lt_id, _pt_id) = setup(&env); + let (admin, lt_id, _pt_id) = setup(&env); let client = LearnTokenClient::new(&env, <_id); env.mock_all_auths(); let alice = Address::generate(&env); let bob = Address::generate(&env); - client.mint(&alice, &1_000); + client.mint(&admin, &alice, &1_000); client.transfer(&alice, &bob, &400); client.burn(&alice, &100); @@ -1689,6 +1704,19 @@ mod tests { assert_eq!(client.total_minted_to(&learner), balance); } + #[test] + fn test_total_minted_to_zero_amount_mint_leaves_total_at_zero() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let user = Address::generate(&env); + client.mint(&admin, &user, &0); + + assert_eq!(client.total_minted_to(&user), 0); + } + // ── Issue #237: reward claim history ───────────────────────────────── #[test] @@ -1807,6 +1835,35 @@ mod tests { assert_eq!(client.get_claim_history(&learner).len(), 1); } + #[test] + fn test_claim_history_records_across_different_courses() { + let env = Env::default(); + let (_admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + let learner = Address::generate(&env); + let course_a = Symbol::new(&env, "rust_101"); + let course_b = Symbol::new(&env, "solidity_101"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_a, &quiz_id, 80); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_b, &quiz_id, 60); + + env.ledger().with_mut(|li| li.timestamp = 100); + client.claim_reward(&learner, &course_a, &quiz_id); + env.ledger().with_mut(|li| li.timestamp = 200); + client.claim_reward(&learner, &course_b, &quiz_id); + + let history = client.get_claim_history(&learner); + assert_eq!(history.len(), 2); + assert_eq!(history.get(0).unwrap().course_id, course_a); + assert_eq!(history.get(1).unwrap().course_id, course_b); + // Same quiz_id in both courses, kept distinct by course_id. + assert_eq!(history.get(0).unwrap().quiz_id, quiz_id); + assert_eq!(history.get(1).unwrap().quiz_id, quiz_id); + } + // ── Issue #238: pause/unpause events ───────────────────────────────── #[test] @@ -1827,7 +1884,7 @@ mod tests { env.mock_all_auths(); env.ledger().with_mut(|li| li.timestamp = 5_000); - client.pause(); + client.pause(&admin); assert!(client.is_paused()); let events = env.events().all(); @@ -1847,10 +1904,10 @@ mod tests { let client = LearnTokenClient::new(&env, <_id); env.mock_all_auths(); - client.pause(); + client.pause(&admin); env.ledger().with_mut(|li| li.timestamp = 9_000); - client.unpause(); + client.unpause(&admin); assert!(!client.is_paused()); let events = env.events().all(); @@ -1867,45 +1924,78 @@ mod tests { #[should_panic(expected = "already paused")] fn test_pause_twice_panics() { let env = Env::default(); - let (_admin, lt_id, _pt_id) = setup(&env); + let (admin, lt_id, _pt_id) = setup(&env); let client = LearnTokenClient::new(&env, <_id); env.mock_all_auths(); - client.pause(); - client.pause(); + client.pause(&admin); + client.pause(&admin); } #[test] #[should_panic(expected = "not paused")] fn test_unpause_when_not_paused_panics() { let env = Env::default(); - let (_admin, lt_id, _pt_id) = setup(&env); + let (admin, lt_id, _pt_id) = setup(&env); let client = LearnTokenClient::new(&env, <_id); env.mock_all_auths(); - client.unpause(); + client.unpause(&admin); } #[test] fn test_pause_blocks_transfers_and_unpause_restores_them() { let env = Env::default(); - let (_admin, lt_id, _pt_id) = setup(&env); + let (admin, lt_id, _pt_id) = setup(&env); let client = LearnTokenClient::new(&env, <_id); env.mock_all_auths(); let alice = Address::generate(&env); let bob = Address::generate(&env); - client.mint(&alice, &1_000); + client.mint(&admin, &alice, &1_000); - client.pause(); + client.pause(&admin); assert!(client.try_transfer(&alice, &bob, &100).is_err()); - assert!(client.try_mint(&alice, &100).is_err()); + assert!(client.try_mint(&admin, &alice, &100).is_err()); - client.unpause(); + client.unpause(&admin); client.transfer(&alice, &bob, &100); assert_eq!(client.balance(&bob), 100); } + #[test] + fn test_pause_blocks_burn_claim_reward_and_claim_vested() { + let env = Env::default(); + let (admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + let alice = Address::generate(&env); + client.mint(&admin, &alice, &1_000); + + let beneficiary = Address::generate(&env); + client.create_vesting(&beneficiary, &1_000, &0, &1_000); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + client.pause(&admin); + assert!(client.try_burn(&alice, &100).is_err()); + assert!(client + .try_claim_reward(&learner, &course_id, &quiz_id) + .is_err()); + assert!(client.try_claim_vested(&beneficiary).is_err()); + + client.unpause(&admin); + client.burn(&alice, &100); + assert_eq!(client.balance(&alice), 900); + client.claim_reward(&learner, &course_id, &quiz_id); + assert!(client.balance(&learner) > 0); + } + #[test] fn test_initialize() { let env = Env::default(); @@ -1938,7 +2028,7 @@ mod tests { #[test] fn test_mint() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let learner = Address::generate(&env); @@ -1953,7 +2043,7 @@ mod tests { #[test] fn test_transfer() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let alice = Address::generate(&env); @@ -2104,7 +2194,7 @@ mod tests { #[should_panic(expected = "cannot transfer to contract")] fn test_transfer_to_contract_address_panics() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let alice = Address::generate(&env); @@ -2119,7 +2209,7 @@ mod tests { #[should_panic(expected = "cannot transfer to contract")] fn test_transfer_from_to_contract_address_panics() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let owner = Address::generate(&env); @@ -2205,7 +2295,7 @@ mod tests { #[test] fn test_burn_reduces_balance_and_supply() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let alice = Address::generate(&env); @@ -2221,7 +2311,7 @@ mod tests { #[test] fn test_burn_entire_balance() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let alice = Address::generate(&env); @@ -2237,7 +2327,7 @@ mod tests { #[test] fn test_burn_zero_is_a_noop() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let alice = Address::generate(&env); @@ -2254,7 +2344,7 @@ mod tests { #[should_panic(expected = "insufficient balance")] fn test_burn_more_than_balance_panics() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let alice = Address::generate(&env); @@ -2268,7 +2358,7 @@ mod tests { #[should_panic(expected = "negative amount")] fn test_burn_negative_amount_panics() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let alice = Address::generate(&env); @@ -2282,7 +2372,7 @@ mod tests { #[should_panic] fn test_burn_requires_owner_auth() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let alice = Address::generate(&env); @@ -2297,7 +2387,7 @@ mod tests { #[test] fn test_burn_from_spends_allowance() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let owner = Address::generate(&env); @@ -2318,7 +2408,7 @@ mod tests { #[should_panic(expected = "insufficient allowance")] fn test_burn_from_beyond_allowance_panics() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let owner = Address::generate(&env); @@ -2335,7 +2425,7 @@ mod tests { #[should_panic(expected = "insufficient balance")] fn test_burn_from_beyond_balance_panics() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let owner = Address::generate(&env); @@ -2353,7 +2443,7 @@ mod tests { #[should_panic(expected = "insufficient allowance")] fn test_burn_from_without_allowance_panics() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let owner = Address::generate(&env); @@ -2367,7 +2457,7 @@ mod tests { #[test] fn test_burn_from_leaves_other_allowances_untouched() { let env = Env::default(); - let (_, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let owner = Address::generate(&env); @@ -2771,15 +2861,15 @@ mod tests { #[test] fn test_governance_proposal_lifecycle() { let env = Env::default(); - let (_admin, lt_contract_id, _) = setup(&env); + let (admin, lt_contract_id, _) = setup(&env); let client = LearnTokenClient::new(&env, <_contract_id); let voter1 = Address::generate(&env); let voter2 = Address::generate(&env); env.mock_all_auths(); - client.mint(&voter1, &100); - client.mint(&voter2, &200); + client.mint(&admin, &voter1, &100); + client.mint(&admin, &voter2, &200); client.snapshot(&10); @@ -2804,8 +2894,246 @@ mod tests { let winning = client.execute_proposal(&prop_id); assert_eq!(winning, 1); // Choice 1 got 200 votes vs Choice 0's 100 votes - let prop = client.get_proposal(prop_id).unwrap(); + let prop = client.get_proposal(&prop_id).unwrap(); assert!(prop.executed); assert_eq!(prop.winning_choice, 1); } + + // ── Issue #254: storage size tracking ───────────────────────────────── + + #[test] + fn test_storage_size_starts_at_zero() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + // initialize() only writes singleton config entries, none of which + // are counted, so a freshly-initialized contract reports 0. + assert_eq!(client.get_storage_size(), 0); + } + + #[test] + fn test_storage_size_increases_on_first_mint_to_new_address() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let before = client.get_storage_size(); + client.mint(&admin, &Address::generate(&env), &1_000); + + assert_eq!(client.get_storage_size(), before + 1); + } + + #[test] + fn test_storage_size_unchanged_on_repeat_mint_to_same_address() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let user = Address::generate(&env); + client.mint(&admin, &user, &1_000); + let after_first = client.get_storage_size(); + + client.mint(&admin, &user, &500); + client.mint(&admin, &user, &250); + + // The TotalMintedTo(user) entry already exists, so repeat mints + // update it in place rather than creating new entries. + assert_eq!(client.get_storage_size(), after_first); + } + + #[test] + fn test_storage_size_counts_distinct_addresses_separately() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let before = client.get_storage_size(); + client.mint(&admin, &Address::generate(&env), &900); + client.mint(&admin, &Address::generate(&env), &100); + + assert_eq!(client.get_storage_size(), before + 2); + } + + #[test] + fn test_storage_size_zero_amount_mint_still_creates_entry() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let before = client.get_storage_size(); + let user = Address::generate(&env); + client.mint(&admin, &user, &0); + + // The TotalMintedTo(user) entry is created (queryable total stays + // 0), so it still counts as a new persistent entry. + assert_eq!(client.get_storage_size(), before + 1); + assert_eq!(client.total_minted_to(&user), 0); + } + + #[test] + fn test_storage_size_increases_on_first_claim_reward() { + let env = Env::default(); + let (_admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + let before = client.get_storage_size(); + client.claim_reward(&learner, &course_id, &quiz_id); + + // A first-time claim creates three entries: TotalMintedTo(learner), + // RewardClaimed(learner, course, quiz), and ClaimHistory(learner). + assert_eq!(client.get_storage_size(), before + 3); + } + + #[test] + fn test_storage_size_unchanged_by_rejected_double_claim() { + let env = Env::default(); + let (_admin, lt_id, pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_id); + + env.mock_all_auths(); + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + let quiz_id = Symbol::new(&env, "quiz_1"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + + client.claim_reward(&learner, &course_id, &quiz_id); + let after_first_claim = client.get_storage_size(); + + assert!(client + .try_claim_reward(&learner, &course_id, &quiz_id) + .is_err()); + + assert_eq!(client.get_storage_size(), after_first_claim); + } + + #[test] + fn test_storage_size_tracks_role_grant_and_revoke() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let before = client.get_storage_size(); + let minter = Address::generate(&env); + client.grant_role(&admin, &minter, &storage::AdminRole::Minter); + assert_eq!(client.get_storage_size(), before + 1); + + client.revoke_role(&admin, &minter, &storage::AdminRole::Minter); + assert_eq!(client.get_storage_size(), before); + } + + #[test] + fn test_storage_size_tracks_whitelist_add_and_remove() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let before = client.get_storage_size(); + let addr = Address::generate(&env); + client.add_to_whitelist(&addr); + assert_eq!(client.get_storage_size(), before + 1); + + client.remove_from_whitelist(&addr); + assert_eq!(client.get_storage_size(), before); + } + + #[test] + fn test_storage_size_tracks_vesting_schedule_once_across_full_lifecycle() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let beneficiary = Address::generate(&env); + // Pre-mint so claim_vested()'s own add_total_minted_to() call (it + // mints too) updates an existing TotalMintedTo entry rather than + // creating one, isolating this test to the VestingSchedule entry. + client.mint(&admin, &beneficiary, &1); + + let before = client.get_storage_size(); + client.create_vesting(&beneficiary, &10_000, &1_000, &1_000); + assert_eq!(client.get_storage_size(), before + 1); + + // claim_vested() rewrites the same VestingSchedule entry (to flip + // `exhausted`) and creates a new VestingClaimed entry; the + // VestingSchedule rewrite must not be counted a second time. + env.ledger().with_mut(|li| li.timestamp = 2_000); + client.claim_vested(&beneficiary); + assert_eq!(client.get_storage_size(), before + 2); + } + + #[test] + fn test_storage_size_tracks_proposal_and_votes_not_repeat_updates() { + let env = Env::default(); + let (admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let voter1 = Address::generate(&env); + let voter2 = Address::generate(&env); + client.mint(&admin, &voter1, &100); + client.mint(&admin, &voter2, &200); + client.snapshot(&10); + + let before = client.get_storage_size(); + let prop_id = client.create_proposal( + &SorobanString::from_str(&env, "Upgrade Protocol"), + &2, + &1_000, + &2_000, + &10, + ); + // Proposal(prop_id) is the only entry created by create_proposal. + assert_eq!(client.get_storage_size(), before + 1); + + env.ledger().with_mut(|li| li.timestamp = 1_500); + client.vote(&voter1, &prop_id, &0); + client.vote(&voter2, &prop_id, &1); + // Each vote adds one Vote(proposal, voter) entry; the Proposal + // entry itself is only updated (vote_totals), not re-created. + assert_eq!(client.get_storage_size(), before + 3); + + env.ledger().with_mut(|li| li.timestamp = 2_500); + client.execute_proposal(&prop_id); + // execute_proposal() only updates the existing Proposal entry. + assert_eq!(client.get_storage_size(), before + 3); + } + + #[test] + fn test_storage_size_permit_counts_new_entries_once_per_owner_spender() { + let env = Env::default(); + let (_admin, lt_id, _pt_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_id); + + env.mock_all_auths(); + let owner = Address::generate(&env); + let spender = Address::generate(&env); + let before = client.get_storage_size(); + + let exp = env.ledger().sequence() + 100; + client.permit(&owner, &spender, &500, &exp, &0); + // First permit call creates PermitNonce(owner) and + // AllowanceSpenders(owner) (Allowance itself is temporary storage + // and isn't counted). + assert_eq!(client.get_storage_size(), before + 2); + + client.permit(&owner, &spender, &200, &exp, &1); + // Second call to the same owner/spender only updates existing + // entries. + assert_eq!(client.get_storage_size(), before + 2); + } } diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index 53be0c0..90e63ea 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -59,6 +59,8 @@ pub enum TokenDataKey { Vote(ProposalVoteKey), /// Per-address permit nonce for replay protection (#224). PermitNonce(Address), + /// Count of persistent entries the contract has created (#254). + StorageEntryCount, /// List of registered admins and their assigned roles (#212). Admins, } @@ -281,7 +283,11 @@ pub fn grant_role(env: &Env, address: &Address, role: &AdminRole) { address: address.clone(), role: role.clone(), }); + let is_new = !env.storage().persistent().has(&key); env.storage().persistent().set(&key, &true); + if is_new { + track_entry_created(env); + } } /// Revoke a role from an address. @@ -290,7 +296,11 @@ pub fn revoke_role(env: &Env, address: &Address, role: &AdminRole) { address: address.clone(), role: role.clone(), }); + let existed = env.storage().persistent().has(&key); env.storage().persistent().remove(&key); + if existed { + track_entry_removed(env); + } } @@ -522,12 +532,16 @@ pub fn set_reward_claimed( quiz_id: quiz_id.clone(), }; let data_key = TokenDataKey::RewardClaimed(key); + let is_new = !env.storage().persistent().has(&data_key); env.storage().persistent().set(&data_key, &true); env.storage().persistent().extend_ttl( &data_key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, ); + if is_new { + track_entry_created(env); + } } /// Store the progress-tracker contract address. @@ -603,16 +617,22 @@ pub fn is_whitelisted(env: &Env, address: &Address) -> bool { /// Add an address to the whitelist. pub fn add_to_whitelist(env: &Env, address: &Address) { - env.storage() - .persistent() - .set(&TokenDataKey::Whitelist(address.clone()), &true); + let key = TokenDataKey::Whitelist(address.clone()); + let is_new = !env.storage().persistent().has(&key); + env.storage().persistent().set(&key, &true); + if is_new { + track_entry_created(env); + } } /// Remove an address from the whitelist. pub fn remove_from_whitelist(env: &Env, address: &Address) { - env.storage() - .persistent() - .remove(&TokenDataKey::Whitelist(address.clone())); + let key = TokenDataKey::Whitelist(address.clone()); + let existed = env.storage().persistent().has(&key); + env.storage().persistent().remove(&key); + if existed { + track_entry_removed(env); + } } /// Record the ledger sequence of the most recent transfer made by `sender`. @@ -648,7 +668,11 @@ pub fn set_snapshot_balance(env: &Env, address: &Address, ledger_height: u32, ba address: address.clone(), ledger_height, }); + let is_new = !env.storage().persistent().has(&key); env.storage().persistent().set(&key, &balance); + if is_new { + track_entry_created(env); + } } /// Get a snapshot of an address's balance at a given ledger height. @@ -675,10 +699,14 @@ pub fn get_snapshot_balance(env: &Env, address: &Address, ledger_height: u32) -> /// tracked. Idempotent — safe to call on every approval. pub fn track_allowance_spender(env: &Env, owner: &Address, spender: &Address) { let key = TokenDataKey::AllowanceSpenders(owner.clone()); + let is_new = !env.storage().persistent().has(&key); let mut spenders: Vec
= env.storage().persistent().get(&key).unwrap_or(Vec::new(env)); if !spenders.contains(spender) { spenders.push_back(spender.clone()); env.storage().persistent().set(&key, &spenders); + if is_new { + track_entry_created(env); + } } } @@ -744,6 +772,7 @@ pub fn get_total_minted_to(env: &Env, address: &Address) -> i128 { /// balance changes that produced it. pub fn add_total_minted_to(env: &Env, address: &Address, amount: i128) { let data_key = TokenDataKey::TotalMintedTo(address.clone()); + let is_new = !env.storage().persistent().has(&data_key); let current: i128 = env.storage().persistent().get(&data_key).unwrap_or(0); env.storage() .persistent() @@ -753,6 +782,9 @@ pub fn add_total_minted_to(env: &Env, address: &Address, amount: i128) { PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, ); + if is_new { + track_entry_created(env); + } } /// Get a learner's full reward claim history (#237). @@ -771,6 +803,7 @@ pub fn get_claim_history(env: &Env, learner: &Address) -> Vec