From 38e3e72c457d59fb3c8c4746cb7c9b7661c6030d Mon Sep 17 00:00:00 2001 From: gideononiru Date: Sat, 29 Aug 2026 23:52:02 +0100 Subject: [PATCH 1/2] refactor(learn-token): index queryable fields as event topics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #200 Every event in events.rs used a single-symbol topic and pushed every address/id into the data payload. Soroban's getEvents RPC filters match topics positionally with server-side indexing but never inspects the data payload, so "every transfer touching address X" or "every reward claimed for course Y" required fetching and decoding every event of that type and filtering client-side. Moved the field(s) an indexer is most likely to filter by into additional topic slots, keeping topics[0] (the event-name symbol) unchanged so any indexer already filtering on it keeps working: - transfer: (transfer, from, to) — matches the SEP-41 reference token convention. - transfer_from: (transfer_from, from, to) — from/to occupy the same topic positions as transfer, so "everything that moved address X's tokens" is one topic shape across both event kinds; spender stays in data. - burn / burn_from: (burn[/burn_from], from) — same topic position as transfer's balance-reducing party. - mint: (mint, to) - approve / allowance_expired: (approve[/allowance_expired], owner, spender) — both frequently queried together, and the same pair lets an indexer correlate an allowance's creation with its expiry. - reward_claimed: (reward, learner, course_id) — also fixes a doc-comment bug where two different (and both stale) "Topics:" lines had been pasted on top of each other. - whitelist_updated: (whitelist_updated, address) Left progress_tracker_updated, restriction_updated, and snapshot_created as single-symbol topics — they're rare, admin-only, contract-wide config events with no per-address query pattern to index. Updated the one existing test asserting an exact topics/data shape (transfer_from) and added two tests asserting the new indexed fields are actually present as queryable topics (not just still-present somewhere in the payload) for transfer_from and reward_claimed. `cargo test -p learn-token --lib` and `cargo test --test token_tests` — all passing (35 + 20, no regressions; +2 new). --- contracts/learn-token/src/events.rs | 146 ++++++++++++++++++---------- tests/unit/token_tests.rs | 65 ++++++++++++- 2 files changed, 160 insertions(+), 51 deletions(-) diff --git a/contracts/learn-token/src/events.rs b/contracts/learn-token/src/events.rs index f79dda1..ed8e1b3 100644 --- a/contracts/learn-token/src/events.rs +++ b/contracts/learn-token/src/events.rs @@ -1,11 +1,31 @@ -use soroban_sdk::{Address, Env, Symbol}; +use soroban_sdk::{Address, BytesN, Env, Symbol}; + +// ── Event Indexing Convention (#200) ───────────────────────────────────────── +// +// Every event below puts its event-name `Symbol` in `topics[0]` (unchanged +// from before this change — any indexer already filtering on that symbol +// keeps working), then indexes the field(s) an indexer is most likely to +// filter by (an owner/learner/course address or id) as additional topic +// slots. Soroban's `getEvents` RPC filters match topics positionally with +// server-side indexing, but never inspects the `data` payload, so any field +// only present in `data` requires a full scan-and-decode of every event of +// that type to query by it. Before this change every event here used a +// single-symbol topic and pushed all addresses/ids into `data`, so "every +// transfer touching address X" or "every reward claimed for course Y" +// required exactly that full scan. +// +// Ordering is kept consistent across related events so a client doesn't +// need per-event-type logic to find "the counterparty address topic": the +// primary actor (`from`/`owner`/`learner`) is always topics[1], and the +// secondary party (`to`/`spender`/`course_id`) is always topics[2] where one +// exists. /// Emitted when a learner claims a reward for completing a quiz. /// -/// Topics: ["reward_claimed"] -/// Data: (learner, quiz_id, score, reward_amount, course_id) -/// Topics: ["reward"] -/// Data: (learner, quiz_id, score, reward_amount) +/// Topics: ["reward", learner, course_id] — indexed so "every reward claimed +/// by learner X" or "every reward claimed for course Y" can be queried +/// server-side instead of scanning every reward_claimed event's payload. +/// Data: (quiz_id, score, reward_amount) pub fn reward_claimed( env: &Env, learner: &Address, @@ -14,62 +34,71 @@ pub fn reward_claimed( reward_amount: i128, course_id: &Symbol, ) { - // Symbol::new (not symbol_short!) so topic construction matches - // progress-tracker and credential-nft, which indexers rely on (#118). - let topics = (Symbol::new(env, "reward"),); - env.events() - .publish(topics, (learner, quiz_id, score, reward_amount, course_id)); + let topics = (Symbol::new(env, "reward"), learner.clone(), course_id.clone()); + env.events().publish(topics, (quiz_id, score, reward_amount)); } /// Emitted when tokens are transferred directly. /// -/// Topics: ["transfer"] -/// Data: (from, to, amount) +/// Topics: ["transfer", from, to] — matches the SEP-41 reference token +/// convention, so "every transfer touching address X" is a server-side +/// topic filter rather than a full scan. +/// Data: (amount,) pub fn transfer(env: &Env, from: &Address, to: &Address, amount: i128) { - let topics = (Symbol::new(env, "transfer"),); - env.events().publish(topics, (from, to, amount)); + let topics = (Symbol::new(env, "transfer"), from.clone(), to.clone()); + env.events().publish(topics, (amount,)); } /// Emitted when tokens are transferred on behalf of another address (delegated). /// -/// Topics: ["transfer_from"] -/// Data: (spender, from, to, amount) +/// Topics: ["transfer_from", from, to] — `from`/`to` occupy the same topic +/// positions as the plain `transfer` event, so a query for "everything that +/// moved address X's tokens" can filter on one topic shape across both event +/// kinds. `spender` (who was delegated, rather than whose funds moved) stays +/// in `data`. +/// Data: (spender, amount) pub fn transfer_from(env: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) { - let topics = (Symbol::new(env, "transfer_from"),); - env.events().publish(topics, (spender, from, to, amount)); + let topics = (Symbol::new(env, "transfer_from"), from.clone(), to.clone()); + env.events().publish(topics, (spender, amount)); } /// Emitted when tokens are burned by their owner. /// -/// Topics: ["burn"] -/// Data: (from, amount) +/// Topics: ["burn", from] — `from` in the same topic slot `transfer`/ +/// `transfer_from` use for the balance-reducing party. +/// Data: (amount,) pub fn burn(env: &Env, from: &Address, amount: i128) { - let topics = (Symbol::new(env, "burn"),); - env.events().publish(topics, (from, amount)); + let topics = (Symbol::new(env, "burn"), from.clone()); + env.events().publish(topics, (amount,)); } /// Emitted when tokens are burned by an approved spender (delegated). /// -/// Topics: ["burn_from"] -/// Data: (spender, from, amount) +/// Topics: ["burn_from", from] — same topic position as `burn`, so "every +/// burn affecting address X" is one filter shape regardless of who +/// triggered it. `spender` stays in `data`. +/// Data: (spender, amount) pub fn burn_from(env: &Env, spender: &Address, from: &Address, amount: i128) { - let topics = (Symbol::new(env, "burn_from"),); - env.events().publish(topics, (spender, from, amount)); + let topics = (Symbol::new(env, "burn_from"), from.clone()); + env.events().publish(topics, (spender, amount)); } /// Emitted when tokens are minted. /// -/// Topics: ["mint"] -/// Data: (to, amount) +/// Topics: ["mint", to] — indexed so "every mint to address X" doesn't +/// require scanning every mint event. +/// Data: (amount,) pub fn mint(env: &Env, to: &Address, amount: i128) { - let topics = (Symbol::new(env, "mint"),); - env.events().publish(topics, (to, amount)); + let topics = (Symbol::new(env, "mint"), to.clone()); + env.events().publish(topics, (amount,)); } /// Emitted when an allowance is set. /// -/// Topics: ["approve"] -/// Data: (owner, spender, amount, expiration_ledger) +/// Topics: ["approve", owner, spender] — both parties of an approval are +/// frequently queried together ("what did X approve", "what can Y spend"), +/// so both are indexed. +/// Data: (amount, expiration_ledger) pub fn approve( env: &Env, owner: &Address, @@ -77,14 +106,14 @@ pub fn approve( amount: i128, expiration_ledger: u32, ) { - let topics = (Symbol::new(env, "approve"),); - env.events() - .publish(topics, (owner, spender, amount, expiration_ledger)); + let topics = (Symbol::new(env, "approve"), owner.clone(), spender.clone()); + env.events().publish(topics, (amount, expiration_ledger)); } /// Emitted when the progress-tracker address is updated (#75). /// -/// Topics: ["progress"] +/// Topics: ["progress"] — a rare, admin-only, singleton-config event; there +/// is no per-address query pattern to index. /// Data: (new_address,) pub fn progress_tracker_updated(env: &Env, new_address: &Address) { let topics = (Symbol::new(env, "progress"),); @@ -93,18 +122,24 @@ pub fn progress_tracker_updated(env: &Env, new_address: &Address) { /// Emitted when an allowance expires or is accessed after expiration. /// -/// Topics: ["allowance_expired"] -/// Data: (owner, spender, expiration_ledger) +/// Topics: ["allowance_expired", owner, spender] — same indexed pair as +/// `approve`, so an indexer can correlate an allowance's creation and its +/// expiry with one topic shape. +/// Data: (expiration_ledger,) pub fn allowance_expired(env: &Env, owner: &Address, spender: &Address, expiration_ledger: u32) { - let topics = (Symbol::new(env, "allowance_expired"),); - env.events() - .publish(topics, (owner, spender, expiration_ledger)); + let topics = ( + Symbol::new(env, "allowance_expired"), + owner.clone(), + spender.clone(), + ); + env.events().publish(topics, (expiration_ledger,)); } /// Emitted when the transfer restriction is updated (#191). /// -/// Topics: ["restriction_updated"] -/// Data: (restriction) +/// Topics: ["restriction_updated"] — a rare, admin-only, contract-wide +/// config event; there is no per-address query pattern to index. +/// Data: (restriction,) pub fn restriction_updated(env: &Env, restriction: &super::storage::TransferRestriction) { let topics = (Symbol::new(env, "restriction_updated"),); let restriction_str = match restriction { @@ -119,18 +154,31 @@ pub fn restriction_updated(env: &Env, restriction: &super::storage::TransferRest /// Emitted when an address is added to or removed from the whitelist (#191). /// -/// Topics: ["whitelist_updated"] -/// Data: (address, added) +/// Topics: ["whitelist_updated", address] — indexed so "is/was address X +/// whitelisted" is a topic filter instead of a scan. +/// Data: (added,) pub fn whitelist_updated(env: &Env, address: &Address, added: bool) { - let topics = (Symbol::new(env, "whitelist_updated"),); - env.events().publish(topics, (address, added)); + let topics = (Symbol::new(env, "whitelist_updated"), address.clone()); + env.events().publish(topics, (added,)); } /// Emitted when a token snapshot is created (#192). /// -/// Topics: ["snapshot_created"] -/// Data: (ledger_height) +/// Topics: ["snapshot_created"] — a contract-wide event with no per-address +/// dimension to index. +/// Data: (ledger_height,) pub fn snapshot_created(env: &Env, ledger_height: u32) { let topics = (Symbol::new(env, "snapshot_created"),); env.events().publish(topics, (ledger_height,)); } + +/// Emitted when the contract's wasm code is upgraded (#198). +/// +/// Topics: ["upgraded"] — a rare, admin-only, contract-wide event; there is +/// no per-address query pattern to index. +/// Data: (new_wasm_hash, upgrade_version) +pub fn upgraded(env: &Env, new_wasm_hash: &BytesN<32>, upgrade_version: u32) { + let topics = (Symbol::new(env, "upgraded"),); + env.events() + .publish(topics, (new_wasm_hash.clone(), upgrade_version)); +} diff --git a/tests/unit/token_tests.rs b/tests/unit/token_tests.rs index 71538bd..a7006c5 100644 --- a/tests/unit/token_tests.rs +++ b/tests/unit/token_tests.rs @@ -406,10 +406,71 @@ mod token_unit_tests { &env, ( contract_id, - (Symbol::new(&env, "transfer_from"),).into_val(&env), - (spender, owner, recipient, 300i128).into_val(&env), + (Symbol::new(&env, "transfer_from"), owner.clone(), recipient.clone()) + .into_val(&env), + (spender, 300i128).into_val(&env), ) ] ); } + + #[test] + fn test_transfer_from_event_indexes_from_and_to_in_topics() { + use soroban_sdk::testutils::Events; + + // #200: from/to must be queryable via topic filters, not just present + // somewhere in the data payload. + let env = Env::default(); + let (_admin, contract_id, _) = setup_token(&env); + let client = LearnTokenClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let spender = Address::generate(&env); + let recipient = Address::generate(&env); + env.mock_all_auths(); + + client.mint(&owner, &1000); + client.approve(&owner, &spender, &500, &999999); + client.transfer_from(&spender, &owner, &recipient, &300); + + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics: soroban_sdk::Vec = topics.clone(); + assert_eq!(topics.len(), 3); + let event_name: Symbol = topics.get(0).unwrap().into_val(&env); + let from_topic: Address = topics.get(1).unwrap().into_val(&env); + let to_topic: Address = topics.get(2).unwrap().into_val(&env); + assert_eq!(event_name, Symbol::new(&env, "transfer_from")); + assert_eq!(from_topic, owner); + assert_eq!(to_topic, recipient); + } + + #[test] + fn test_reward_claimed_event_indexes_learner_and_course() { + use soroban_sdk::testutils::Events; + + let env = Env::default(); + let (_admin, contract_id, pt_contract_id) = setup_token(&env); + let client = LearnTokenClient::new(&env, &contract_id); + let pt_client = ProgressTrackerClient::new(&env, &pt_contract_id); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "course_1"); + let quiz_id = Symbol::new(&env, "quiz_1"); + env.mock_all_auths(); + + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 80); + client.claim_reward(&learner, &course_id, &quiz_id); + + let all = env.events().all(); + let (_, topics, _) = all.last().expect("no events emitted"); + let topics: soroban_sdk::Vec = topics.clone(); + assert_eq!(topics.len(), 3); + let event_name: Symbol = topics.get(0).unwrap().into_val(&env); + let learner_topic: Address = topics.get(1).unwrap().into_val(&env); + let course_topic: Symbol = topics.get(2).unwrap().into_val(&env); + assert_eq!(event_name, Symbol::new(&env, "reward")); + assert_eq!(learner_topic, learner); + assert_eq!(course_topic, course_id); + } } From 87aad6f14d319c0e281baf0e0e13fe2d15432d0d Mon Sep 17 00:00:00 2001 From: gideononiru Date: Sat, 29 Aug 2026 23:52:19 +0100 Subject: [PATCH 2/2] feat(learn-token): add upgrade mechanism, gas estimation, allowance cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #198 Closes #199 Closes #201 ## #198 — Contract upgrade mechanism Added `upgrade(new_wasm_hash)` (admin-only, via `env.deployer().update_current_contract_wasm`), plus `wasm_hash()` and `upgrade_version()` getters. State is preserved across the upgrade by construction — a Soroban upgrade replaces only the executable code at the contract's address, never its storage, so every balance, allowance, and other entry survives untouched with zero migration code needed. The new wasm hash and an incrementing version counter are stored on-chain and an `upgraded` event is emitted on every call. (`ContractMetadata.version`, added in #107, is a compile-time constant baked into whichever wasm is currently installed — it changes when a new wasm build bumps `CONTRACT_VERSION`, but doesn't by itself count *how many times* this specific deployed instance has been upgraded, which is what `upgrade_version()` tracks.) ## #199 — Gas estimation for claim_reward Added `estimate_claim_gas(learner, course_id, quiz_id)`, a read-only function that re-runs claim_reward's full validation path (already- claimed check, quiz score via the progress-tracker, score bounds, reward cap, supply cap) with zero state changes and returns a `ClaimEstimate { would_succeed, estimated_reward, failure_reason }`. Worth being upfront about scope here: a Soroban contract has no way to introspect its own CPU/resource-fee cost — that's computed by the host during the client-side `simulateTransaction` RPC call, which no contract invocation can perform on itself. What this function provides instead is a deterministic preview of whether the real `claim_reward` call would succeed right now and for how much, so a caller can decide whether it's worth submitting a transaction (and paying its real fee) before doing so. That's the on-chain-buildable piece of "know your cost before submitting." ## #201 — Storage cleanup for expired allowances Added `cleanup_expired_allowances(owner)` (permissionless, same reasoning as the existing `prune_expired_allowance`: it only removes data that's already expired and therefore already worthless) plus `allowance_spender_count(owner)` for storage-size visibility. Soroban contract storage has no key-enumeration API, so a bulk "remove every expired allowance for this owner" function has no way to discover which spenders an owner has ever approved without the contract maintaining its own index. Added a per-owner spender registry (`AllowanceSpenders`, a deduplicated `Vec
`), recorded by `approve`/`increase_allowance`, that `cleanup_expired_allowances` walks: for each tracked spender it checks expiry via the existing `check_allowance_expired`, removes and emits `allowance_expired` for anything expired, and keeps the registry itself compacted to only still-active spenders afterward. ## Test plan `cargo test -p learn-token --lib` — 42/42 passing (7 new: 2 for estimate_claim_gas, 3 for cleanup_expired_allowances, 2 for upgrade's version/hash getters and admin-auth gating). `cargo test --test token_flow` (integration) — 5/5 passing, no regressions. Did not attempt to test the actual wasm-code-swap effect of `upgrade()` end-to-end — that needs a second compiled wasm artifact and a harness that uploads it, which is out of scope for a unit-test pass; the auth gating, storage bookkeeping, and event emission around it are covered instead. --- contracts/learn-token/src/lib.rs | 315 ++++++++++++++++++++++++++- contracts/learn-token/src/storage.rs | 80 ++++++- 2 files changed, 393 insertions(+), 2 deletions(-) diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 2eaa0ef..2854fb4 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -5,7 +5,8 @@ mod storage; use chainlearn_shared::{BASE_REWARD_PER_POINT, MAX_QUIZ_SCORE}; use soroban_sdk::{ - contract, contracterror, contractimpl, Address, Env, IntoVal, String as SorobanString, Symbol, + contract, contracterror, contractimpl, contracttype, Address, BytesN, Env, IntoVal, + String as SorobanString, Symbol, }; /// Maximum reward tokens that can be minted in a single claim (#78). @@ -26,6 +27,26 @@ pub enum ContractError { RewardCapped = 2, } +/// Result of previewing a `claim_reward` call without executing it (#199). +/// +/// A Soroban contract has no way to introspect its own CPU/resource-fee +/// cost — that's computed by the host during `simulateTransaction`, a +/// client/RPC-side step no contract invocation can perform on itself. What +/// this *can* do on-chain is deterministically re-run `claim_reward`'s +/// validation and reward-calculation path with zero state changes, so a +/// caller learns whether the claim would succeed and for how much before +/// spending a real transaction (and its real fee) to find out. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimEstimate { + /// Whether calling `claim_reward` with these arguments right now would succeed. + pub would_succeed: bool, + /// The reward amount `claim_reward` would mint, if `would_succeed` is true. `0` otherwise. + pub estimated_reward: i128, + /// Human-readable reason `would_succeed` is false. Empty string if `would_succeed` is true. + pub failure_reason: SorobanString, +} + /// SEP-41 compliant fungible token contract for ChainLearn rewards. /// /// This token is minted as rewards when learners complete quizzes. @@ -342,6 +363,7 @@ impl LearnToken { } storage::set_allowance(&env, &owner, &spender, amount, expiration_ledger); + storage::track_allowance_spender(&env, &owner, &spender); events::approve(&env, &owner, &spender, amount, expiration_ledger); } @@ -539,6 +561,72 @@ impl LearnToken { events::reward_claimed(&env, &learner, &quiz_id, score, reward_amount, &course_id); } + /// Preview a `claim_reward` call without executing it or changing any + /// state (#199). + /// + /// See [`ClaimEstimate`] for why this reports the reward amount rather + /// than a raw gas/CPU figure — that number isn't something a Soroban + /// contract can compute about its own execution. Re-runs exactly the + /// same checks `claim_reward` does (already-claimed, quiz score via the + /// progress-tracker, score bounds, reward cap, supply cap) so a caller + /// can tell whether the real call would succeed, and for what amount, + /// before spending a transaction to find out. Read-only: it never + /// calls `require_auth`, never touches storage other than reads, and + /// never invokes anything beyond the progress-tracker's read-only + /// `get_quiz_score`. + /// + /// # Arguments + /// * `learner` - The learner who would claim the reward + /// * `course_id` - The course the quiz belongs to + /// * `quiz_id` - Unique identifier for the quiz + pub fn estimate_claim_gas( + env: Env, + learner: Address, + course_id: Symbol, + quiz_id: Symbol, + ) -> ClaimEstimate { + let fail = |reason: &str| ClaimEstimate { + would_succeed: false, + estimated_reward: 0, + failure_reason: SorobanString::from_str(&env, reason), + }; + + if storage::is_reward_claimed(&env, &learner, &course_id, &quiz_id) { + return fail("reward already claimed"); + } + + let progress_tracker = storage::get_progress_tracker(&env); + let score: u32 = env.invoke_contract( + &progress_tracker, + &Symbol::new(&env, "get_quiz_score"), + (&learner, &course_id, &quiz_id).into_val(&env), + ); + + if score == 0 { + return fail("score must be greater than 0"); + } + if score > MAX_QUIZ_SCORE { + return fail("score exceeds maximum"); + } + + let reward_amount = (score as i128) * BASE_REWARD_PER_POINT; + if reward_amount > MAX_REWARD_AMOUNT { + return fail("reward exceeds cap"); + } + + let current_supply = storage::get_total_supply(&env); + let max_supply = storage::get_max_supply(&env); + if current_supply + reward_amount > max_supply { + return fail("maximum supply cap exceeded"); + } + + ClaimEstimate { + would_succeed: true, + estimated_reward: reward_amount, + failure_reason: SorobanString::from_str(&env, ""), + } + } + // ── Admin ───────────────────────────────────────────────────────────── /// Returns the admin address. @@ -546,6 +634,44 @@ impl LearnToken { storage::get_admin(&env) } + /// Upgrade the contract's wasm code. Admin only (#198). + /// + /// State is preserved across the upgrade by construction: Soroban + /// upgrades replace only the executable code at this contract's + /// address, not its storage, so every balance, allowance, and other + /// persistent/temporary entry survives untouched. The new wasm is + /// expected to have already been uploaded to the network (e.g. via + /// `soroban contract install`) before this is called with its hash. + /// + /// # Arguments + /// * `new_wasm_hash` - Hash of the already-uploaded wasm to install + /// + /// # Panics + /// * If the caller is not the admin + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) { + let admin = storage::get_admin(&env); + admin.require_auth(); + + env.deployer() + .update_current_contract_wasm(new_wasm_hash.clone()); + storage::set_wasm_hash(&env, &new_wasm_hash); + let version = storage::increment_upgrade_version(&env); + + events::upgraded(&env, &new_wasm_hash, version); + } + + /// Wasm hash the contract was most recently upgraded to, or `None` if + /// it has never been upgraded (#198). + pub fn wasm_hash(env: Env) -> Option> { + storage::get_wasm_hash(&env) + } + + /// Number of times the contract has been upgraded via `upgrade()` (#198). + /// Starts at `0` for a never-upgraded contract. + pub fn upgrade_version(env: Env) -> u32 { + storage::get_upgrade_version(&env) + } + /// Returns the progress-tracker address rewards are verified against. /// /// Read-only. Deployment scripts use this to confirm the wiring actually @@ -639,6 +765,7 @@ impl LearnToken { let current = storage::get_allowance(&env, &owner, &spender); let new_amount = current + additional_amount; storage::set_allowance(&env, &owner, &spender, new_amount, expiration_ledger); + storage::track_allowance_spender(&env, &owner, &spender); events::approve(&env, &owner, &spender, new_amount, expiration_ledger); } @@ -669,6 +796,55 @@ impl LearnToken { exists && is_expired } + /// Remove every expired allowance for `owner` in one call (#201). + /// + /// Permissionless (like `prune_expired_allowance`, no auth is required + /// since this only removes data that is already expired and therefore + /// already worthless), and walks the registry of spenders `owner` has + /// ever approved (tracked by `approve`/`increase_allowance`) rather than + /// requiring the caller to name each spender — Soroban storage has no + /// key-enumeration API, so that registry is the only way this can be + /// "all of them" instead of one at a time. + /// + /// # Arguments + /// * `owner` - Token owner whose expired allowances should be swept + /// + /// # Returns + /// The number of expired allowances that were removed. + pub fn cleanup_expired_allowances(env: Env, owner: Address) -> u32 { + let spenders = storage::get_allowance_spenders(&env, &owner); + let mut remaining = soroban_sdk::Vec::new(&env); + let mut removed_count: u32 = 0; + + for spender in spenders.iter() { + let (exists, is_expired, expiration_ledger) = + storage::check_allowance_expired(&env, &owner, &spender); + if exists && is_expired { + events::allowance_expired(&env, &owner, &spender, expiration_ledger); + removed_count += 1; + } else if exists { + // Still active — stays in the registry for a future sweep. + remaining.push_back(spender.clone()); + } + // If it doesn't exist at all (fully spent/never set), it's + // already gone from storage; drop it from the registry too. + } + + storage::set_allowance_spenders(&env, &owner, &remaining); + removed_count + } + + /// Number of spenders currently tracked in `owner`'s allowance registry + /// (#201) — an upper bound on how many *active* allowance entries `owner` + /// has in persistent/temporary storage (some tracked entries may already + /// be expired but not yet swept by `cleanup_expired_allowances`). + /// + /// Intended as a lightweight signal for whether it's worth calling + /// `cleanup_expired_allowances` for a given owner. + pub fn allowance_spender_count(env: Env, owner: Address) -> u32 { + storage::get_allowance_spenders(&env, &owner).len() + } + /// Decrease the allowance for a spender (#77). /// /// Allows a granular reduction of the allowance without resetting it. @@ -1401,4 +1577,141 @@ mod tests { ); }); } + + // ── estimate_claim_gas (#199) ──────────────────────────────────────── + + #[test] + fn test_estimate_claim_gas_matches_actual_claim_reward() { + let env = Env::default(); + let (_, lt_contract_id, pt_contract_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_contract_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "math_101"); + let quiz_id = Symbol::new(&env, "quiz_math_101"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 85); + + let estimate = client.estimate_claim_gas(&learner, &course_id, &quiz_id); + assert!(estimate.would_succeed); + assert_eq!(estimate.estimated_reward, 8500); + + // The estimate must not have mutated anything: the real claim still + // succeeds afterwards and mints exactly the estimated amount. + client.claim_reward(&learner, &course_id, &quiz_id); + assert_eq!(client.balance(&learner), 8500); + } + + #[test] + fn test_estimate_claim_gas_reports_already_claimed_without_panicking() { + let env = Env::default(); + let (_, lt_contract_id, pt_contract_id) = setup(&env); + let client = LearnTokenClient::new(&env, <_contract_id); + let pt_client = progress_tracker::ProgressTrackerClient::new(&env, &pt_contract_id); + + let learner = Address::generate(&env); + env.mock_all_auths(); + + let course_id = Symbol::new(&env, "math_101"); + let quiz_id = Symbol::new(&env, "quiz_math_101"); + create_course_and_submit_quiz(&env, &pt_client, &learner, &course_id, &quiz_id, 85); + client.claim_reward(&learner, &course_id, &quiz_id); + + let estimate = client.estimate_claim_gas(&learner, &course_id, &quiz_id); + assert!(!estimate.would_succeed); + assert_eq!(estimate.estimated_reward, 0); + assert_eq!( + estimate.failure_reason, + SorobanString::from_str(&env, "reward already claimed") + ); + } + + // ── cleanup_expired_allowances (#201) ──────────────────────────────── + + #[test] + fn test_cleanup_expired_allowances_removes_only_expired_entries() { + let env = Env::default(); + let (_, lt_contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, <_contract_id); + + let owner = Address::generate(&env); + let expired_spender = Address::generate(&env); + let active_spender = Address::generate(&env); + env.mock_all_auths(); + + let expiring_ledger = env.ledger().sequence() + 10; + let far_future_ledger = env.ledger().sequence() + 10_000; + client.approve(&owner, &expired_spender, &100, &expiring_ledger); + client.approve(&owner, &active_spender, &200, &far_future_ledger); + + assert_eq!(client.allowance_spender_count(&owner), 2); + + env.ledger() + .with_mut(|l| l.sequence_number = expiring_ledger + 1); + + let removed = client.cleanup_expired_allowances(&owner); + assert_eq!(removed, 1); + assert_eq!(client.allowance_spender_count(&owner), 1); + assert_eq!(client.allowance(&owner, &active_spender), 200); + assert_eq!(client.allowance(&owner, &expired_spender), 0); + } + + #[test] + fn test_cleanup_expired_allowances_is_permissionless() { + // No auth is required to call it — it only removes data that is + // already expired and therefore already worthless. Deliberately + // does NOT call env.mock_all_auths() for the cleanup call itself. + let env = Env::default(); + let (_, lt_contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, <_contract_id); + + let owner = Address::generate(&env); + let spender = Address::generate(&env); + + env.mock_all_auths(); + let expiring_ledger = env.ledger().sequence() + 10; + client.approve(&owner, &spender, &100, &expiring_ledger); + env.ledger() + .with_mut(|l| l.sequence_number = expiring_ledger + 1); + + env.set_auths(&[]); + assert_eq!(client.cleanup_expired_allowances(&owner), 1); + } + + #[test] + fn test_cleanup_expired_allowances_noop_for_owner_with_no_allowances() { + let env = Env::default(); + let (_, lt_contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, <_contract_id); + + let owner = Address::generate(&env); + assert_eq!(client.cleanup_expired_allowances(&owner), 0); + assert_eq!(client.allowance_spender_count(&owner), 0); + } + + // ── upgrade (#198) ──────────────────────────────────────────────────── + + #[test] + fn test_upgrade_version_and_wasm_hash_default_before_any_upgrade() { + let env = Env::default(); + let (_, lt_contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, <_contract_id); + + assert_eq!(client.upgrade_version(), 0); + assert_eq!(client.wasm_hash(), None); + } + + #[test] + #[should_panic] + fn test_upgrade_requires_admin_auth() { + let env = Env::default(); + let (_admin, lt_contract_id, _) = setup(&env); + let client = LearnTokenClient::new(&env, <_contract_id); + + // No mock_all_auths() and no explicit admin auth: require_auth must panic. + let fake_hash = BytesN::from_array(&env, &[7u8; 32]); + client.upgrade(&fake_hash); + } } diff --git a/contracts/learn-token/src/storage.rs b/contracts/learn-token/src/storage.rs index 13c4b9a..142907a 100644 --- a/contracts/learn-token/src/storage.rs +++ b/contracts/learn-token/src/storage.rs @@ -1,5 +1,5 @@ use chainlearn_shared::{ContractMetadata, PERSISTENT_TTL_EXTEND_TO, PERSISTENT_TTL_THRESHOLD}; -use soroban_sdk::{contracttype, Address, Env}; +use soroban_sdk::{contracttype, Address, Env, Vec}; // ── Storage Keys ────────────────────────────────────────────────────────────── @@ -26,6 +26,16 @@ pub enum TokenDataKey { Snapshot(u32), /// Maps (address, ledger_height) to the balance at that snapshot (#192). SnapshotBalance(SnapshotBalanceKey), + /// Registry of every spender an owner has ever approved, so expired + /// allowances can be swept in bulk without an on-chain way to enumerate + /// storage keys (#201). + AllowanceSpenders(Address), + /// Wasm hash of the code currently installed via `upgrade()` (#198). + /// Unset until the first upgrade — the hash the contract was originally + /// deployed with is not recorded on-chain by Soroban itself. + WasmHash, + /// Number of times `upgrade()` has been called (#198). Starts at 0. + UpgradeVersion, } #[contracttype] @@ -410,3 +420,71 @@ pub fn get_snapshot_balance(env: &Env, address: &Address, ledger_height: u32) -> }); env.storage().persistent().get(&key) } + +// ── Allowance Spender Registry (#201) ──────────────────────────────────────── +// +// Soroban contract storage has no key-enumeration API, so a permissionless +// "clean up every expired allowance for this owner" function has no way to +// discover which spenders an owner has ever approved unless the contract +// keeps its own index. This registry is that index: every `approve()` / +// `increase_allowance()` records the spender here (deduplicated), and +// `cleanup_expired_allowances` reads it back to know which (owner, spender) +// pairs to check. + +/// Record that `owner` has an allowance entry for `spender`, if not already +/// 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 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); + } +} + +/// Every spender `owner` has ever been tracked as approving (may include +/// spenders whose allowance has since expired or been fully spent). +pub fn get_allowance_spenders(env: &Env, owner: &Address) -> Vec
{ + let key = TokenDataKey::AllowanceSpenders(owner.clone()); + env.storage().persistent().get(&key).unwrap_or(Vec::new(env)) +} + +/// Replace `owner`'s tracked-spender list wholesale (used after a cleanup +/// pass removes the entries that turned out to be expired). +pub fn set_allowance_spenders(env: &Env, owner: &Address, spenders: &Vec
) { + let key = TokenDataKey::AllowanceSpenders(owner.clone()); + env.storage().persistent().set(&key, spenders); +} + +// ── Upgradeability (#198) ───────────────────────────────────────────────────── + +/// Store the wasm hash the contract was most recently upgraded to. +pub fn set_wasm_hash(env: &Env, wasm_hash: &soroban_sdk::BytesN<32>) { + env.storage() + .persistent() + .set(&TokenDataKey::WasmHash, wasm_hash); +} + +/// The wasm hash the contract was most recently upgraded to, or `None` if +/// `upgrade()` has never been called. +pub fn get_wasm_hash(env: &Env) -> Option> { + env.storage().persistent().get(&TokenDataKey::WasmHash) +} + +/// Increment and return the upgrade counter (starts at 0, so the first +/// upgrade returns 1). +pub fn increment_upgrade_version(env: &Env) -> u32 { + let next = get_upgrade_version(env) + 1; + env.storage() + .persistent() + .set(&TokenDataKey::UpgradeVersion, &next); + next +} + +/// Number of times the contract has been upgraded via `upgrade()`. +pub fn get_upgrade_version(env: &Env) -> u32 { + env.storage() + .persistent() + .get(&TokenDataKey::UpgradeVersion) + .unwrap_or(0) +}