diff --git a/README.md b/README.md index 6cadb66..23a13a9 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,13 @@ Common types and constants used across all contracts: - `MIN_CREDENTIAL_SCORE` (50): Minimum score to mint a credential - `MAX_QUIZ_SCORE` (100): Maximum possible quiz score +- `TOKEN_DECIMALS` (7): Token decimal places - `BASE_REWARD_PER_POINT` (100): Tokens minted per quiz point - `MAX_CREDENTIALS_PAGE_SIZE` (50): Maximum credentials returned by one paginated read - `zero_address(env)` / `ZERO_ADDRESS_STR`: The all-zero account, rejected as a mint recipient or new admin -Token decimals are not a shared constant: `learn-token` takes them as the `decimal` argument of -`initialize` (the deployment scripts use 7). +Token decimals are defined as `TOKEN_DECIMALS` (7) and passed as the `decimal` argument to +`learn-token`'s `initialize` (the deployment scripts use 7). ### Cross-Contract Dependencies diff --git a/contracts/credential-nft/src/lib.rs b/contracts/credential-nft/src/lib.rs index 9af5a3a..3b07f3e 100644 --- a/contracts/credential-nft/src/lib.rs +++ b/contracts/credential-nft/src/lib.rs @@ -8,7 +8,8 @@ mod xcall; use chainlearn_shared::ContractMetadata; use metadata::{ - remove_entry, CredentialDataKey, CredentialDisplay, CredentialInfo, CredentialVerification, + remove_entry, write_entry, CredentialDataKey, CredentialDisplay, CredentialInfo, + CredentialVerification, }; use mint::validate_metadata_uri; use soroban_sdk::xdr::ToXdr; @@ -240,9 +241,7 @@ impl CredentialNft { validate_metadata_uri(&env, &new_metadata_uri); info.metadata_uri = new_metadata_uri.clone(); - env.storage() - .persistent() - .set(&CredentialDataKey::Credential(credential_id), &info); + write_entry(&env, &CredentialDataKey::Credential(credential_id), &info); env.events().publish( (Symbol::new(&env, "credential_metadata_updated"),), @@ -557,9 +556,7 @@ impl CredentialNft { .persistent() .get(&CredentialDataKey::Admin) .expect("not initialized"); - env.storage() - .persistent() - .set(&CredentialDataKey::Admin, &pending.new_admin); + write_entry(&env, &CredentialDataKey::Admin, &pending.new_admin); remove_entry(&env, &CredentialDataKey::PendingAdmin); events::admin_transfer_accepted(&env, &previous_admin, &pending.new_admin); @@ -622,9 +619,7 @@ impl CredentialNft { .expect("not initialized"); admin.require_auth(); - env.storage() - .persistent() - .set(&CredentialDataKey::AdminTransferDelay, &delay_seconds); + write_entry(&env, &CredentialDataKey::AdminTransferDelay, &delay_seconds); } /// Reject transfer of a credential. diff --git a/contracts/learn-token/src/lib.rs b/contracts/learn-token/src/lib.rs index f117ec9..eef24be 100644 --- a/contracts/learn-token/src/lib.rs +++ b/contracts/learn-token/src/lib.rs @@ -4,6 +4,7 @@ mod events; mod storage; use chainlearn_shared::{BASE_REWARD_PER_POINT, MAX_QUIZ_SCORE}; +pub use chainlearn_shared::TOKEN_DECIMALS; use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, panic_with_error, Address, BytesN, Env, IntoVal, String as SorobanString, Symbol, Vec, @@ -1936,7 +1937,7 @@ mod tests { &admin, &SorobanString::from_str(env, "CLearn"), &SorobanString::from_str(env, "CLRN"), - &7, + &TOKEN_DECIMALS, &pt_contract_id, &1_000_000_000_000_000, ); @@ -2538,7 +2539,7 @@ mod tests { assert_eq!(client.name(), SorobanString::from_str(&env, "CLearn")); assert_eq!(client.symbol(), SorobanString::from_str(&env, "CLRN")); - assert_eq!(client.decimals(), 7); + assert_eq!(client.decimals(), TOKEN_DECIMALS); assert_eq!(client.total_supply(), 0); assert_eq!(client.admin(), admin); } diff --git a/contracts/progress-tracker/src/lib.rs b/contracts/progress-tracker/src/lib.rs index 8fb92da..69413bd 100644 --- a/contracts/progress-tracker/src/lib.rs +++ b/contracts/progress-tracker/src/lib.rs @@ -389,7 +389,8 @@ impl ProgressTracker { module_id, ); - env.storage().persistent().set( + types::write_entry( + &env, &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); @@ -466,7 +467,8 @@ impl ProgressTracker { ); } - env.storage().persistent().set( + types::write_entry( + &env, &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); @@ -632,7 +634,8 @@ impl ProgressTracker { true, ); - env.storage().persistent().set( + types::write_entry( + &env, &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); @@ -718,7 +721,8 @@ impl ProgressTracker { } } - env.storage().persistent().set( + types::write_entry( + &env, &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); @@ -1523,9 +1527,11 @@ impl ProgressTracker { course.version = new_version; course.updated_at = env.ledger().timestamp(); - env.storage() - .persistent() - .set(&ProgressTrackerDataKey::Course(course_id.clone()), &course); + types::write_entry( + &env, + &ProgressTrackerDataKey::Course(course_id.clone()), + &course, + ); env.events().publish( (Symbol::new(&env, "course_version_updated"),), @@ -1760,7 +1766,7 @@ impl ProgressTracker { }; // Store that this achievement type has been earned - env.storage().persistent().set(&achievement_key, &true); + types::write_entry(env, &achievement_key, &true); // Add to learner's achievements list let achievements_key = ProgressTrackerDataKey::Achievements(learner.clone()); @@ -1770,9 +1776,7 @@ impl ProgressTracker { .get(&achievements_key) .unwrap_or_else(|| Vec::new(env)); achievements.push_back(achievement); - env.storage() - .persistent() - .set(&achievements_key, &achievements); + types::write_entry(env, &achievements_key, &achievements); // Emit achievement earned event env.events().publish( @@ -2205,7 +2209,8 @@ impl ProgressTracker { module_id, ); - env.storage().persistent().set( + types::write_entry( + &env, &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); @@ -2259,7 +2264,8 @@ impl ProgressTracker { ); } - env.storage().persistent().set( + types::write_entry( + &env, &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); @@ -2314,7 +2320,8 @@ impl ProgressTracker { true, ); - env.storage().persistent().set( + types::write_entry( + &env, &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); @@ -2378,7 +2385,8 @@ impl ProgressTracker { } } - env.storage().persistent().set( + types::write_entry( + &env, &ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()), &progress, ); diff --git a/docs/architecture.md b/docs/architecture.md index 1161974..855c081 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,6 +124,7 @@ sequenceDiagram | `Balance(address)` | `i128` | Token balance | | `Allowance(owner, spender)` | `AllowanceData` | Spending allowance | | `RewardClaimed(learner, course_id, quiz_id)` | `bool` | Double-claim prevention | +| `SnapshotBalance(address, ledger_height)` | `i128` | Balance snapshot for governance voting | | `ProgressTracker` | `Address` | Progress tracker address | ### credential-nft Storage @@ -143,6 +144,7 @@ The `chainlearn-shared` package provides common types and constants: - `MIN_CREDENTIAL_SCORE` (50): Minimum score for credential eligibility - `MAX_QUIZ_SCORE` (100): Maximum quiz score +- `TOKEN_DECIMALS` (7): Token decimal places - `BASE_REWARD_PER_POINT` (100): Tokens per quiz point - `MAX_CREDENTIALS_PAGE_SIZE` (50): Pagination limit diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 64b7fe1..e7c1069 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -53,7 +53,12 @@ After initialization, verify the deployment: 1. **Check Deployment File**: Ensure `deployments-.json` exists and contains valid Stellar Contract IDs starting with `C`. 2. **On-Chain Verification**: Use the Soroban CLI to invoke read functions on the deployed contracts to verify their state. ```bash - soroban contract invoke --id --network testnet --source -- admin + soroban contract invoke \ + --id \ + --rpc-url \ + --network-passphrase "" \ + --source \ + -- admin ``` 3. **Cross-Contract Verification**: Ensure that the `learn-token` and `credential-nft` contracts return the correct `progress-tracker` ID. diff --git a/docs/integration-guide.md b/docs/integration-guide.md index b26b1e3..b895513 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -429,22 +429,20 @@ for quiz_id in quiz_ids.iter() { client.batch_claim_reward(&learner, &course_id, &quiz_ids); ``` -### 4. Event Listening +### 4. Event Monitoring -Subscribe to events for real-time updates: +In testing environments, inspect emitted events with `env.events().all()`: ```rust -// Rust - Listen for events -env.events().subscribe(|event| { - match event.symbol.as_str() { - "reward_claimed" => { /* Handle reward claimed */ } - "credential_minted" => { /* Handle credential minted */ } - "credential_eligible" => { /* Handle eligibility */ } - _ => {} - } -}); +// Test environment: Inspect emitted events +let all_events = env.events().all(); +for event in all_events.iter() { + // Process or assert on emitted contract events +} ``` +For off-chain indexers and client applications, poll or stream events using the Soroban RPC `getEvents` method with filters for topics such as `reward_claimed`, `credential_minted`, or `credential_eligible`. + ### 5. Pagination Use pagination for large datasets: diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 91503b7..6a5334a 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -45,7 +45,7 @@ When testing for expected panics (e.g., unauthorized access), use the `#[should_ ```rust #[test] -#[should_panic(expected = "HostError: Error(Contract, #1)")] +#[should_panic(expected = "Error(Contract, #1)")] fn test_unauthorized_access() { let (env, client, _admin) = setup_test(); let unauthorized_user = Address::generate(&env); diff --git a/packages/shared/src/constants.rs b/packages/shared/src/constants.rs index 3de9d72..569db16 100644 --- a/packages/shared/src/constants.rs +++ b/packages/shared/src/constants.rs @@ -6,6 +6,9 @@ pub const MIN_CREDENTIAL_SCORE: u32 = 50; /// Maximum score for any quiz. pub const MAX_QUIZ_SCORE: u32 = 100; +/// Token decimal places for the platform token. +pub const TOKEN_DECIMALS: u32 = 7; + /// Base reward per quiz point (in token base units). pub const BASE_REWARD_PER_POINT: i128 = 100; diff --git a/tests/unit/credential_tests.rs b/tests/unit/credential_tests.rs index 46c5c3b..dd5090d 100644 --- a/tests/unit/credential_tests.rs +++ b/tests/unit/credential_tests.rs @@ -579,6 +579,36 @@ mod credential_unit_tests { ); } + #[test] + fn test_storage_size_tracks_admin_transfer_and_metadata_update() { + let env = Env::default(); + let (_admin, contract_id, tracker_id) = setup_contract(&env); + let client = CredentialNftClient::new(&env, &contract_id); + env.mock_all_auths(); + + let initial_size = client.get_storage_size(); + let new_admin = Address::generate(&env); + + client.set_admin_transfer_delay(&0); + assert_eq!(client.get_storage_size(), initial_size + 1); + + let before_transfer = client.get_storage_size(); + client.transfer_admin(&new_admin); + assert_eq!(client.get_storage_size(), before_transfer + 1); + + client.accept_admin(); + assert_eq!(client.get_storage_size(), before_transfer); + + let learner = Address::generate(&env); + let course_id = Symbol::new(&env, "rust_101"); + enrolled_and_completed_with_score(&env, &tracker_id, &learner, &course_id, 85); + let cred_id = client.mint_credential(&learner, &course_id, &85, &Symbol::new(&env, "ipfs_Qm123")); + + let before_update = client.get_storage_size(); + client.update_credential_metadata(&cred_id, &Symbol::new(&env, "ipfs_Qm456")); + assert_eq!(client.get_storage_size(), before_update); + } + #[test] fn test_generate_certificate_deterministic_and_unique() { let env = Env::default(); diff --git a/tests/unit/progress_tests.rs b/tests/unit/progress_tests.rs index a434a7f..21ea27f 100644 --- a/tests/unit/progress_tests.rs +++ b/tests/unit/progress_tests.rs @@ -2214,6 +2214,44 @@ mod progress_unit_tests { assert_eq!(client.get_storage_size(), before + 2); } + #[test] + fn test_storage_size_tracks_update_course_version_and_achievements() { + let env = Env::default(); + let (_admin, contract_id) = setup_contract(&env); + let client = ProgressTrackerClient::new(&env, &contract_id); + env.mock_all_auths(); + + let course_id = create_test_course(&env, &client); + let before_version_update = client.get_storage_size(); + + // Updating course version modifies existing course entry (+0) + client.update_course_version(&course_id, &2); + assert_eq!(client.get_storage_size(), before_version_update); + + // Test transfer_admin and accept_admin + client.set_admin_transfer_delay(&0); + let before_transfer = client.get_storage_size(); + let new_admin = Address::generate(&env); + + client.transfer_admin(&new_admin); + // PendingAdmin added (+1) + assert_eq!(client.get_storage_size(), before_transfer + 1); + + client.accept_admin(); + // PendingAdmin removed (-1), Admin overwritten (+0) + assert_eq!(client.get_storage_size(), before_transfer); + + // Test delegation + let learner = Address::generate(&env); + let delegate = Address::generate(&env); + let before_delegation = client.get_storage_size(); + client.delegate_progress(&learner, &delegate); + assert_eq!(client.get_storage_size(), before_delegation + 1); + + client.revoke_delegation(&learner); + assert_eq!(client.get_storage_size(), before_delegation); + } + // ── Issue #419: tag-to-course reverse index for get_courses_by_tag ──────── #[test]