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 { /// safe because `claim_reward` rejects double-claims before reaching here. pub fn append_claim_record(env: &Env, learner: &Address, record: &ClaimRecord) { let data_key = TokenDataKey::ClaimHistory(learner.clone()); + let is_new = !env.storage().persistent().has(&data_key); let mut history: Vec = env .storage() .persistent() @@ -730,15 +763,21 @@ pub fn append_claim_record(env: &Env, learner: &Address, record: &ClaimRecord) { PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, ); + if is_new { + track_entry_created(env); + } } // ── Vesting Schedules (#225) ────────────────────────────────────────────────── /// Store a vesting schedule for a beneficiary. pub fn set_vesting_schedule(env: &Env, beneficiary: &Address, schedule: &VestingSchedule) { - env.storage() - .persistent() - .set(&TokenDataKey::VestingSchedule(beneficiary.clone()), schedule); + let key = TokenDataKey::VestingSchedule(beneficiary.clone()); + let is_new = !env.storage().persistent().has(&key); + env.storage().persistent().set(&key, schedule); + if is_new { + track_entry_created(env); + } } /// Retrieve a vesting schedule for a beneficiary, if one exists. @@ -758,9 +797,12 @@ pub fn get_vesting_claimed(env: &Env, beneficiary: &Address) -> i128 { /// Record cumulative claimed amount for a beneficiary. pub fn set_vesting_claimed(env: &Env, beneficiary: &Address, claimed: i128) { - env.storage() - .persistent() - .set(&TokenDataKey::VestingClaimed(beneficiary.clone()), &claimed); + let key = TokenDataKey::VestingClaimed(beneficiary.clone()); + let is_new = !env.storage().persistent().has(&key); + env.storage().persistent().set(&key, &claimed); + if is_new { + track_entry_created(env); + } } // ── Governance Proposals (#226) ─────────────────────────────────────────────── @@ -784,9 +826,12 @@ pub fn next_proposal_id(env: &Env) -> u64 { /// Store a governance proposal. pub fn set_proposal(env: &Env, proposal_id: u64, proposal: &Proposal) { - env.storage() - .persistent() - .set(&TokenDataKey::Proposal(proposal_id), proposal); + let key = TokenDataKey::Proposal(proposal_id); + let is_new = !env.storage().persistent().has(&key); + env.storage().persistent().set(&key, proposal); + if is_new { + track_entry_created(env); + } } /// Retrieve a governance proposal by ID. @@ -802,7 +847,11 @@ pub fn set_vote(env: &Env, proposal_id: u64, voter: &Address, choice: u32) { proposal_id, voter: voter.clone(), }); + let is_new = !env.storage().persistent().has(&key); env.storage().persistent().set(&key, &choice); + if is_new { + track_entry_created(env); + } } /// Whether a voter has already voted on a proposal. @@ -827,8 +876,60 @@ pub fn get_permit_nonce(env: &Env, owner: &Address) -> u64 { /// Increment the permit nonce for an owner and return the new value. pub fn increment_permit_nonce(env: &Env, owner: &Address) -> u64 { let next = get_permit_nonce(env, owner) + 1; + let key = TokenDataKey::PermitNonce(owner.clone()); + let is_new = !env.storage().persistent().has(&key); + env.storage().persistent().set(&key, &next); + if is_new { + track_entry_created(env); + } + next +} + +// ── Storage Size Tracking (#254) ───────────────────────────────────────────── +// +// Soroban has no host API to enumerate or count a contract's own storage +// keys, so the entry count is tracked by hand: every storage helper that +// creates a brand-new per-entity entry (as opposed to overwriting one that +// already exists) calls `track_entry_created` / `track_entry_removed` +// around the write, guarded by a `has()` check so repeat writes to the +// same key don't inflate the count. +// +// Singleton config values set once at `initialize()` (admin, name, symbol, +// decimals, total supply, max supply, metadata, transfer restriction, +// wasm hash, upgrade version, paused flag, proposal counter) are not +// counted -- they don't grow with usage, so they aren't what a caller is +// asking about when checking "how much storage is this contract using". +// `Balance` and `Allowance` are also excluded: balances are written on +// every transfer/mint/burn and allowances live in temporary storage, so +// instrumenting either would add a `has()` check to the contract's hottest +// paths for a self-referential accounting entry that isn't itself billed +// as persistent storage in the allowance's case. + +/// Increment the storage entry counter. Call exactly once per brand-new +/// persistent entry (i.e. only after confirming the entry did not already +/// exist). +fn track_entry_created(env: &Env) { + let next = get_storage_size(env) + 1; env.storage() .persistent() - .set(&TokenDataKey::PermitNonce(owner.clone()), &next); - next + .set(&TokenDataKey::StorageEntryCount, &next); +} + +/// Decrement the storage entry counter. Call exactly once per persistent +/// entry removed. +fn track_entry_removed(env: &Env) { + let current = get_storage_size(env); + let next = current.saturating_sub(1); + env.storage() + .persistent() + .set(&TokenDataKey::StorageEntryCount, &next); +} + +/// Number of persistent entries the contract has created, net of any that +/// have since been removed. Defaults to 0 before any tracked entry exists. +pub fn get_storage_size(env: &Env) -> u32 { + env.storage() + .persistent() + .get(&TokenDataKey::StorageEntryCount) + .unwrap_or(0) } From 05745e47a123657fcebc2f2b872002c82a660f59 Mon Sep 17 00:00:00 2001 From: Jiggy <142459849+bbjiggy@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:26:08 +0100 Subject: [PATCH 4/4] test(learn-token): extend coverage for per-address minting, claim history, and pause total_minted_to() (#251), get_claim_history() (#252), and pause/unpause (#253) were already implemented and tested on main (merged in #363 for near-duplicate issues #236/#237/#238, before this repo's issue numbers #251-253 were filed against the same scope). Add the edge cases their existing coverage didn't reach: - total_minted_to: a zero-amount mint is allowed (only negative amounts are rejected) and should leave the queryable total at 0. - get_claim_history: existing tests only covered multiple quizzes within one course; verify records also stay correctly distinguished by course_id when a learner claims across different courses. - pause: existing coverage only exercised transfer and mint; burn, claim_reward, and claim_vested are also gated by require_not_paused() and hadn't been exercised while paused. Closes #251, Closes #252, Closes #253 --- contracts/learn-token/src/lib.rs | 75 ++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 354a3e2..c54efe1 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -1609,6 +1609,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] @@ -1727,6 +1740,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] @@ -1826,6 +1868,39 @@ mod tests { 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();