From 204b980ee6a366fafc2e0aca5a604601d66b9833 Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:02:36 +0100 Subject: [PATCH 1/4] fix(learn-token): remove duplicate is_paused/set_paused definitions storage.rs defined is_paused/set_paused twice with identical bodies -- once under the original Emergency Pause section (#189) and again under the pause-events section (#238), added by two PRs merged back to back without either noticing the other had already added the same pair. Rust rejects the duplicate definitions (E0428), so main currently fails to build. Keep the original definitions and drop the redundant pair; both events and pause/unpause logic already call through the one storage.rs API surface, so nothing else changes. --- contracts/learn-token/src/storage.rs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index f502f1e..3dbcabc 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -732,21 +732,6 @@ pub fn append_claim_record(env: &Env, learner: &Address, record: &ClaimRecord) { ); } -/// Whether the contract is currently paused (#238). -pub fn is_paused(env: &Env) -> bool { - env.storage() - .persistent() - .get(&TokenDataKey::Paused) - .unwrap_or(false) -} - -/// Set the paused flag (#238). -pub fn set_paused(env: &Env, paused: bool) { - env.storage() - .persistent() - .set(&TokenDataKey::Paused, &paused); -} - // ── Vesting Schedules (#225) ────────────────────────────────────────────────── /// Store a vesting schedule for a beneficiary. From f47fb6a061807f22bbaf9ae7de188275943f1e49 Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:15:35 +0100 Subject: [PATCH 2/4] fix(learn-token): repair test suite broken by role-gated mint/pause PR #365 added Minter/Pauser role checks to mint(), pause(), and unpause(), giving each an extra caller: Address parameter, and updated the call sites that existed at the time. It landed back to back with two other PRs (#363, #366) that added their own tests calling the pre-refactor 2-and-0-argument signatures, or referencing an admin binding some of those same tests never captured (destructured as _ or _admin because they didn't need it before). None of the three PRs conflicted at the git-diff level, so all three merged cleanly and the test suite has been failing to compile since. Capture admin from setup() wherever a fixed test now needs it as the mint/pause/unpause caller, and fix one unrelated pre-existing get_proposal(prop_id) call that needed a borrow. No behavioral changes -- cargo test -p learn-token now compiles and all 63 tests pass. --- contracts/learn-token/src/lib.rs | 86 ++++++++++++++++---------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index cfe178b..09cdd9b 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -1509,12 +1509,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); } @@ -1522,14 +1522,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); } @@ -1537,14 +1537,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); @@ -1554,13 +1554,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); @@ -1732,7 +1732,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(); @@ -1752,10 +1752,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(); @@ -1772,41 +1772,41 @@ 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); } @@ -1843,7 +1843,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); @@ -1858,7 +1858,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); @@ -2009,7 +2009,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); @@ -2024,7 +2024,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); @@ -2110,7 +2110,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); @@ -2126,7 +2126,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); @@ -2142,7 +2142,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); @@ -2159,7 +2159,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); @@ -2173,7 +2173,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); @@ -2187,7 +2187,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); @@ -2202,7 +2202,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); @@ -2223,7 +2223,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); @@ -2240,7 +2240,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); @@ -2258,7 +2258,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); @@ -2272,7 +2272,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); @@ -2676,15 +2676,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); @@ -2709,7 +2709,7 @@ 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); } From 54084773ad40dac73464fb95ddea98bb035ca0ff Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:25:00 +0100 Subject: [PATCH 3/4] feat(learn-token): add persistent storage entry-count tracking Soroban prices persistent storage by entry count and size but gives a contract no host API to enumerate or count its own keys, so there is currently no way to answer "how much storage is this contract using" short of an off-chain state export. Add get_storage_size(), backed by a StorageEntryCount counter that storage.rs increments/decrements around every write to a per-entity key -- reward claims, claim history, roles, whitelist entries, snapshots, vesting schedules and claimed amounts, proposals, votes, allowance-spender registries, permit nonces, and per-address minting totals -- guarded by a has() check so overwriting an existing entry never double-counts it. Deliberately excluded: singleton config (admin, name/symbol/decimal, total/max supply, metadata, transfer restriction, wasm hash, upgrade version, paused flag, proposal counter) since those don't grow with usage, and Balance/Allowance since balances are written on every transfer/mint/burn (the issue's "no performance impact" criterion argues against adding a has() check to that path) and allowances live in temporary rather than persistent storage. Closes #254 --- contracts/learn-token/src/lib.rs | 253 +++++++++++++++++++++++++++ contracts/learn-token/src/storage.rs | 135 ++++++++++++-- 2 files changed, 371 insertions(+), 17 deletions(-) diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 09cdd9b..354a3e2 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -286,6 +286,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) } @@ -2713,4 +2728,242 @@ mod tests { 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 3dbcabc..21b8cd4 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, } #[contracttype] @@ -229,7 +231,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. @@ -238,7 +244,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); + } } @@ -469,12 +479,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. @@ -550,16 +564,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`. @@ -595,7 +615,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. @@ -622,10 +646,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); + } } } @@ -691,6 +719,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() @@ -700,6 +729,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). @@ -718,6 +750,7 @@ pub fn get_claim_history(env: &Env, learner: &Address) -> Vec