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/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index 4640b94..0bfc062 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. @@ -365,6 +386,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); } @@ -562,6 +584,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. @@ -569,6 +657,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 @@ -662,6 +788,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); } @@ -692,6 +819,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. @@ -1424,4 +1600,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 93dee4c..fdee519 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, /// Ledger sequence of the most recent transfer made by an address, used /// to enforce per-sender cooldown periods (#191). LastTransfer(Address), @@ -438,3 +448,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) +} diff --git a/tests/unit/token_tests.rs b/tests/unit/token_tests.rs index 2defe71..d614d90 100644 --- a/tests/unit/token_tests.rs +++ b/tests/unit/token_tests.rs @@ -407,10 +407,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); + } }