Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 5 additions & 10 deletions contracts/credential-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"),),
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down
5 changes: 3 additions & 2 deletions contracts/learn-token/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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);
}
Expand Down
38 changes: 23 additions & 15 deletions contracts/progress-tracker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,8 @@ impl ProgressTracker {
module_id,
);

env.storage().persistent().set(
types::write_entry(
&env,
&ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()),
&progress,
);
Expand Down Expand Up @@ -466,7 +467,8 @@ impl ProgressTracker {
);
}

env.storage().persistent().set(
types::write_entry(
&env,
&ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()),
&progress,
);
Expand Down Expand Up @@ -632,7 +634,8 @@ impl ProgressTracker {
true,
);

env.storage().persistent().set(
types::write_entry(
&env,
&ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()),
&progress,
);
Expand Down Expand Up @@ -718,7 +721,8 @@ impl ProgressTracker {
}
}

env.storage().persistent().set(
types::write_entry(
&env,
&ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()),
&progress,
);
Expand Down Expand Up @@ -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"),),
Expand Down Expand Up @@ -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());
Expand All @@ -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(
Expand Down Expand Up @@ -2205,7 +2209,8 @@ impl ProgressTracker {
module_id,
);

env.storage().persistent().set(
types::write_entry(
&env,
&ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()),
&progress,
);
Expand Down Expand Up @@ -2259,7 +2264,8 @@ impl ProgressTracker {
);
}

env.storage().persistent().set(
types::write_entry(
&env,
&ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()),
&progress,
);
Expand Down Expand Up @@ -2314,7 +2320,8 @@ impl ProgressTracker {
true,
);

env.storage().persistent().set(
types::write_entry(
&env,
&ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()),
&progress,
);
Expand Down Expand Up @@ -2378,7 +2385,8 @@ impl ProgressTracker {
}
}

env.storage().persistent().set(
types::write_entry(
&env,
&ProgressTrackerDataKey::Progress(learner.clone(), course_id.clone()),
&progress,
);
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
7 changes: 6 additions & 1 deletion docs/deployment-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,12 @@ After initialization, verify the deployment:
1. **Check Deployment File**: Ensure `deployments-<network>.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 <PROGRESS_TRACKER_ID> --network testnet --source <STELLAR_SECRET_KEY> -- admin
soroban contract invoke \
--id <PROGRESS_TRACKER_ID> \
--rpc-url <RPC_URL> \
--network-passphrase "<NETWORK_PASSPHRASE>" \
--source <STELLAR_SECRET_KEY> \
-- admin
```
3. **Cross-Contract Verification**: Ensure that the `learn-token` and `credential-nft` contracts return the correct `progress-tracker` ID.

Expand Down
20 changes: 9 additions & 11 deletions docs/integration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion docs/testing-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
30 changes: 30 additions & 0 deletions tests/unit/credential_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/progress_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down