A Soroban smart contract workspace for a Stellar-based learning platform. ChainLearn enables on-chain learning progress tracking, quiz-based token rewards, and verifiable credential NFTs.
For a detailed architecture overview including diagrams and storage layout, see docs/architecture.md.
The workspace contains three interconnected contracts and a shared utilities package:
The reward token for the platform. Implements the SEP-41 fungible token standard with additional reward logic:
- Standard interface:
initialize,mint,transfer,transfer_from,burn,burn_from,balance,total_supply,approve,allowance - Burning:
burn(from, amount)andburn_from(spender, from, amount)destroy tokens and reducetotal_supply;burn_fromspends the caller's approved allowance - Reward system:
claim_reward(learner, course_id, quiz_id)looks up the learner's score for that quiz from progress-tracker and mints tokens proportional to it - Anti-fraud: Each quiz reward can only be claimed once per learner
- Reward formula:
score * BASE_REWARD_PER_POINT(100 tokens per point)
Non-transferable (soulbound) credential NFTs that certify course completion:
- Minting:
mint_credential(to, course_id, score, metadata_uri)-- score-gated at 50+ - Completion-gated: Minting calls
progress-tracker.is_eligible_for_credential()and rejects learners who have not completed every module and quiz in the course - Score-verified: Minting also calls
progress-tracker.get_course_score()and rejects anyscorethat does not match the learner's on-chain average, so a caller cannot inflate a credential - Verification:
verify_credential(credential_id)returns full credential info - Lookup:
get_credentials_for(learner, start, limit)returns one page of a learner's credential IDs;get_credential_count(learner)returns the total so callers can page - Revocation: Admin can revoke credentials if needed
- One per course: Each learner can only receive one credential per course
- Non-transferable:
transfer()is rejected -- credentials are permanently bound to the earner - Events:
credential_minted(learner, course_id, credential_id, score, metadata_uri)andcredential_revoked(learner, course_id, credential_id, admin)
Tracks learner enrollment, module completion, and quiz scores:
- Enrollment:
enroll(learner, course_id) - Module tracking:
complete_module(learner, course_id, module_id) - Quiz scores:
submit_quiz_score(learner, course_id, quiz_id, score) - Progress view:
get_progress(learner, course_id)returnsProgressInfo - Verified scores:
get_quiz_score(learner, course_id, quiz_id)for one quiz andget_course_score(learner, course_id)for the course average -- the values learn-token and credential-nft check before minting - Eligibility: Automatic credential eligibility calculation
- Weighted progress: 70% module completion + 30% quiz performance
- Events:
enrolled(learner, course_id, enrolled_at),module_completed(learner, course_id, module_id, overall_progress),quiz_submitted(learner, course_id, quiz_id, score), andcredential_eligible(learner, course_id)-- published once, the moment eligibility flips from false to true, so indexers don't have to pollget_progress
Common types and constants used across all contracts:
MIN_CREDENTIAL_SCORE(50): Minimum score to mint a credentialMAX_QUIZ_SCORE(100): Maximum possible quiz scoreBASE_REWARD_PER_POINT(100): Tokens minted per quiz pointMAX_CREDENTIALS_PAGE_SIZE(50): Maximum credentials returned by one paginated readzero_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).
learn-token and credential-nft both call back into progress-tracker to verify on-chain
progress before releasing a reward or credential, so the progress-tracker contract ID must be
known and supplied when the other two are initialized:
- learn-token:
initialize(admin, name, symbol, decimal, progress_tracker)stores theprogress-trackeraddress.claim_rewardcallsprogress-tracker.get_quiz_score()to read the learner's verified score before minting -- the score cannot be supplied directly by the caller. - credential-nft:
initialize(admin, progress_tracker)stores the same address.mint_credentialcallsprogress-tracker.is_eligible_for_credential()and rejects the mint if the learner has not completed every module and quiz in the course. ./scripts/deploy.shdeploys all three contracts and writes their contract IDs todeployments-<network>.json../scripts/initialize.shthen reads that file and passes theprogress-trackercontract ID as theprogress_trackerargument to bothlearn-token.initializeandcredential-nft.initialize.
chainlearn-contracts/
├── contracts/
│ ├── learn-token/ # SEP-41 fungible token
│ ├── credential-nft/ # NFT credentials
│ └── progress-tracker/ # Learning progress
├── packages/
│ └── shared/ # Shared types and constants
├── tests/
│ ├── integration/ # End-to-end flow tests
│ └── unit/ # Contract unit tests
├── scripts/
│ ├── deploy.sh # Deployment script
│ ├── initialize.sh # Post-deploy initialization
│ └── optimize-wasm.sh # WASM size optimization
├── .github/
│ ├── ISSUE_TEMPLATE/ # Bug report & feature request templates
│ └── workflows/ # CI and deploy pipelines
├── Cargo.toml # Workspace root
├── CODE_OF_CONDUCT.md # Community standards
├── LICENSE # MIT license
└── README.md
- Rust 1.81.0+ with
wasm32-unknown-unknowntarget - Soroban CLI v21+
- Stellar account with XLM for deployment fees
Install the WASM target:
rustup target add wasm32-unknown-unknownInstall Soroban CLI:
cargo install --locked soroban-cli --version 21.0.0Build all contracts for release:
cargo build --release --target wasm32-unknown-unknownShrink the release WASM before deploying (the deploy, upgrade and verify
scripts do this automatically). Add --strip-docs to also remove doc comments
from the embedded contract spec. See the
WASM size audit for the numbers and trade-offs.
./scripts/optimize-wasm.sh
./scripts/measure-size.sh # raw vs optimized sizesBuild in development mode (faster, includes debug info):
cargo build --target wasm32-unknown-unknownRun all tests:
cargo testRun tests for a specific contract:
cargo test -p learn-token
cargo test -p credential-nft
cargo test -p progress-trackerRun with output:
cargo test -- --nocapture- Set your secret key:
export STELLAR_SECRET_KEY="S..."- Deploy contracts:
./scripts/deploy.sh testnet- Initialize contracts:
./scripts/initialize.sh testnetThe script initializes in dependency order -- progress-tracker first, then learn-token and credential-nft, which each store the tracker's address -- and verifies the wiring landed before reporting success.
./scripts/deploy.sh mainnet
./scripts/initialize.sh mainnetsoroban contract invoke \
--id <PROGRESS_TRACKER_ID> \
--source "$STELLAR_SECRET_KEY" \
--rpc-url https://soroban-testnet.stellar.org:443 \
--network-passphrase "Test SDF Network ; September 2015" \
-- \
create_course \
--course_id "rust_101" \
--total_modules 3 \
--total_quizzes 2 \
--module_ids '["mod_basics","mod_ownership","mod_traits"]'- Enroll in a course
- Complete modules one by one
- Submit quiz scores after each module section
- Claim token rewards for each quiz
- Receive credential NFT upon full completion
# Enroll
soroban contract invoke --id <PROGRESS_ID> -- enroll \
--learner <ADDRESS> --course_id "rust_101"
# Complete a module
soroban contract invoke --id <PROGRESS_ID> -- complete_module \
--learner <ADDRESS> --course_id "rust_101" --module_id "mod_basics"
# Submit quiz score
soroban contract invoke --id <PROGRESS_ID> -- submit_quiz_score \
--learner <ADDRESS> --course_id "rust_101" --quiz_id "quiz_1" --score 85
# Claim token reward
soroban contract invoke --id <TOKEN_ID> -- claim_reward \
--learner <ADDRESS> --course_id "rust_101" --quiz_id "quiz_1"
# Mint credential (after completing all modules and quizzes)
soroban contract invoke --id <CREDENTIAL_ID> -- mint_credential \
--to <ADDRESS> --course_id "rust_101" --score 80 \
--metadata_uri "ipfs://Qm..."
# List a learner's credentials, one page at a time
soroban contract invoke --id <CREDENTIAL_ID> -- get_credential_count \
--learner <ADDRESS>
soroban contract invoke --id <CREDENTIAL_ID> -- get_credentials_for \
--learner <ADDRESS> --start 0 --limit 50Overall progress is calculated as a weighted average using integer division:
- Module completion: 70% weight (proportion of modules completed)
- Quiz performance: 30% weight (average quiz score / 100)
Formula: progress = (completed_modules * 70 / total_modules) + (avg_quiz_score * 30 / 100)
Note: Integer division is used throughout; calculations are performed left-to-right with rounding down at each step.
A learner is eligible for a credential when:
- All course modules are completed
- All quizzes are submitted
- Average quiz score >= 50 (MIN_CREDENTIAL_SCORE)
- Auth: All state-changing functions require authorization from the relevant party
- Verified progress: Credential minting is gated on the progress-tracker's eligibility check, so a credential cannot be self-issued without actually completing the course
- Double-claim prevention: Token rewards and quiz submissions are tracked to prevent duplicates
- Score gating: Credentials require a minimum passing score (50)
- Admin controls: Only the admin can create courses, mint tokens, and revoke credentials
- Soulbound enforcement: Credential NFTs are permanently bound to the earner;
transfer()is rejected to prevent trading or theft of credentials
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
initialize |
— | admin: Address |
Result<(), ContractError> |
Initialize the tracker. Can only be called once. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
create_course |
Admin | course_id: Symbol, total_modules: u32, total_quizzes: u32, module_ids: Vec<Symbol>, quiz_ids: Vec<Symbol> |
— | Register a new course with modules and quizzes. Module IDs and quiz IDs must each be unique. |
archive_course |
Admin | course_id: Symbol |
— | Archive a course, preventing new enrollments. |
set_course_content_hash |
Admin | course_id: Symbol, content_hash: Symbol |
— | Set or update the content integrity hash. |
set_course_difficulty |
Admin | course_id: Symbol, difficulty: u32 |
— | Set difficulty (0=beginner, 1=intermediate, 2=advanced). |
set_course_tags |
Admin | course_id: Symbol, tags: Vec<Symbol> |
— | Set tags for course categorization. |
update_course_version |
Admin | course_id: Symbol |
— | Increment course content version. |
set_course_prerequisites |
Admin | course_id: Symbol, prerequisites: Vec<Symbol> |
— | Set prerequisite courses. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
enroll |
Learner | learner: Address, course_id: Symbol |
— | Enroll in a course. |
enroll_checked |
Learner | learner: Address, course_id: Symbol, expected_content_hash: Option<Symbol> |
— | Enroll with content hash verification. |
complete_module |
Learner | learner: Address, course_id: Symbol, module_id: Symbol |
— | Mark a module complete (sequential ordering enforced). |
batch_complete_module |
Learner | learner: Address, course_id: Symbol, module_ids: Vec<Symbol> |
— | Complete multiple modules atomically. |
submit_quiz_score |
Learner | learner: Address, course_id: Symbol, quiz_id: Symbol, score: u32 |
— | Submit a quiz score (0-100). |
batch_submit_quiz_score |
Learner | learner: Address, course_id: Symbol, quiz_scores: Vec<(Symbol, u32)> |
— | Submit multiple quiz scores (partial failures skipped). |
retake_quiz |
Learner | learner: Address, course_id: Symbol, quiz_id: Symbol, new_score: u32 |
— | Retake a quiz with a higher score. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
get_progress |
— | learner: Address, course_id: Symbol |
ProgressInfo |
Full progress for a learner in a course. |
get_completion_percentage |
— | learner: Address, course_id: Symbol |
u32 |
Progress percentage (0-100) only. |
export_progress |
— | learner: Address, course_id: Symbol |
ProgressExport |
Complete progress snapshot including all quiz results. |
get_quiz_score |
— | learner: Address, course_id: Symbol, quiz_id: Symbol |
u32 |
Verified score for one quiz. |
get_course_score |
— | learner: Address, course_id: Symbol |
u32 |
Average quiz score across all submitted quizzes. |
is_eligible_for_credential |
— | learner: Address, course_id: Symbol |
bool |
Whether the learner qualifies for a credential. |
get_course |
— | course_id: Symbol |
Course |
Course configuration. |
get_course_difficulty |
— | course_id: Symbol |
u32 |
Difficulty level of a course. |
get_course_created_at |
— | course_id: Symbol |
u64 |
Immutable creation timestamp. |
get_course_tags |
— | course_id: Symbol |
Vec<Symbol> |
Tags for a course. |
get_courses_by_tag |
— | tag: Symbol |
Vec<Symbol> |
All courses with a given tag. |
get_learner_stats |
— | learner: Address |
LearnerStats |
Aggregate stats across all courses. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
contract_metadata |
— | — | VersionedContractMetadata |
Contract name, version, and upgrade count. |
contract_version |
— | — | u32 |
On-chain upgrade counter. |
is_initialized |
— | — | bool |
Whether initialize() has been called. |
get_storage_size |
— | — | u64 |
Number of persistent storage entries. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
initialize |
— | admin: Address, name: SorobanString, symbol: SorobanString, decimal: u32, progress_tracker: Address, max_supply: i128 |
Result<(), ContractError> |
Initialize the token. Can only be called once. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
name |
— | — | SorobanString |
Token name. |
symbol |
— | — | SorobanString |
Token symbol. |
decimals |
— | — | u32 |
Number of decimal places. |
total_supply |
— | — | i128 |
Current total supply. |
balance |
— | address: Address |
i128 |
Token balance of an address. |
total_minted_to |
— | address: Address |
i128 |
Cumulative amount ever minted to an address. |
transfer |
Owner | from: Address, to: Address, amount: i128 |
— | Transfer tokens. |
transfer_from |
Spender | spender: Address, from: Address, to: Address, amount: i128 |
— | Transfer on behalf of another address. |
approve |
Owner | owner: Address, spender: Address, amount: i128, expiration_ledger: u32 |
— | Approve a spender. |
increase_allowance |
Owner | owner: Address, spender: Address, additional_amount: i128, expiration_ledger: u32 |
— | Increase allowance (front-run safe). |
allowance |
— | owner: Address, spender: Address |
i128 |
Remaining allowance. |
burn |
Owner | from: Address, amount: i128 |
— | Burn tokens. |
burn_from |
Spender | spender: Address, from: Address, amount: i128 |
— | Burn via allowance. |
mint |
Minter | caller: Address, to: Address, amount: i128 |
— | Mint new tokens (admin/minter only). |
prune_expired_allowance |
— | owner: Address, spender: Address |
bool |
Remove an expired allowance from storage. |
cleanup_expired_allowances |
— | owner: Address |
u32 |
Remove all expired allowances for an owner. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
claim_reward |
Learner | learner: Address, course_id: Symbol, quiz_id: Symbol |
— | Claim token reward for a quiz. Score verified on-chain. |
batch_claim_reward |
Learner | learner: Address, course_id: Symbol, quiz_ids: Vec<Symbol> |
Vec<Symbol> |
Claim rewards for multiple quizzes. Partial failures skipped. |
estimate_claim_gas |
— | learner: Address, course_id: Symbol, quiz_id: Symbol |
ClaimEstimate |
Preview a claim without executing (read-only). |
get_claim_history |
— | learner: Address |
Vec<ClaimRecord> |
Full reward claim history for a learner. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
set_transfer_restriction |
Admin | restriction: TransferRestriction |
— | Apply other restrictions immediately; stage WhitelistOnly for a 48-hour grace period. |
propose_transfer_restriction |
Admin | restriction: TransferRestriction |
— | Explicitly stage WhitelistOnly for delayed activation. |
accept_transfer_restriction |
Admin | — | — | Activate a pending restriction after 48 hours. |
cancel_transfer_restriction |
Admin | — | — | Cancel a pending restriction. |
pending_transfer_restriction |
— | — | Option<PendingTransferRestriction> |
Read the pending restriction and its proposal time. |
get_transfer_restriction |
— | — | TransferRestriction |
Current transfer restriction. |
add_to_whitelist |
Admin | address: Address |
— | Add address to whitelist. |
remove_from_whitelist |
Admin | address: Address |
— | Remove address from whitelist. |
is_whitelisted |
— | address: Address |
bool |
Check whitelist status. |
snapshot does not capture balances; it only emits snapshot_created. Balances are recorded
per address with record_balance_snapshot, because a contract cannot enumerate all holders.
Workflow: (1) snapshot(h) to announce, (2) record_balance_snapshot(addr, h) for each address
that matters (e.g. each voter) before its balance changes, (3) read with balance_at(addr, h).
balance_at returns 0 for any address that was not recorded at that height.
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
snapshot |
Admin | ledger_height: u32 |
— | Marker only: emits snapshot_created, stores no balances. |
record_balance_snapshot |
Admin | address: Address, ledger_height: u32 |
— | Store the address's current balance under ledger_height. |
balance_at |
— | address: Address, ledger_height: u32 |
i128 |
Balance recorded for the address at that height (0 if none). |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
admin |
— | — | Address |
Admin address. |
has_role |
— | address: Address, role: AdminRole |
bool |
Check role assignment. |
grant_role |
Admin | caller: Address, address: Address, role: AdminRole |
— | Grant a role. |
revoke_role |
Admin | caller: Address, address: Address, role: AdminRole |
— | Revoke a role. |
add_admin |
Admin | caller: Address, admin_info: AdminInfo |
— | Add admin with role. |
remove_admin |
Admin | caller: Address, admin_info: AdminInfo |
— | Remove admin. |
get_admins |
— | — | Vec<AdminInfo> |
List all admins and roles. |
execute_multisig_op |
Multi-Admin | caller: Address, co_signer: Address, operation: Symbol |
— | Execute a critical operation requiring two admins. |
upgrade_multisig |
Multi-Admin | caller: Address, co_signer: Address, new_wasm_hash: BytesN<32> |
— | Upgrade contract with multi-sig. |
upgrade |
Admin | new_wasm_hash: BytesN<32> |
— | Upgrade contract WASM (single admin). |
wasm_hash |
— | — | Option<BytesN<32>> |
Current WASM hash. |
upgrade_version |
— | — | u32 |
Number of upgrades performed. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
transfer_admin |
Admin | new_admin: Address |
— | Initiate delayed admin transfer. |
accept_admin |
Pending | — | — | Accept admin role after delay. |
cancel_admin_transfer |
Admin | — | — | Cancel pending admin transfer. |
pending_admin |
— | — | Option<PendingAdminTransfer> |
Pending transfer details. |
admin_transfer_delay |
— | — | u64 |
Current delay in seconds. |
set_admin_transfer_delay |
Admin | delay_seconds: u64 |
— | Set the delay (minimum 3,600 seconds). |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
progress_tracker |
— | — | Address |
Progress-tracker contract address. |
set_progress_tracker |
Admin | new_progress_tracker: Address |
— | Update progress-tracker address. |
max_supply |
— | — | i128 |
Maximum supply cap. |
set_max_supply |
Admin | new_max_supply: i128 |
— | Update supply cap (max 2x increase). |
contract_metadata |
— | — | ContractMetadata |
Contract name and version. |
is_initialized |
— | — | bool |
Whether initialized. |
get_storage_size |
— | — | u32 |
Number of persistent storage entries. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
pause |
Pauser | caller: Address |
— | Pause all state-changing operations. |
unpause |
Pauser | caller: Address |
— | Resume operations. |
is_paused |
— | — | bool |
Whether paused. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
create_vesting_schedule |
Admin | beneficiary: Address, total_amount: i128, cliff_timestamp: u64, duration_seconds: u64 |
— | Create a vesting schedule. |
claim_vested_tokens |
Beneficiary | beneficiary: Address |
— | Claim vested tokens. |
get_vesting_schedule |
— | beneficiary: Address |
Option<VestingSchedule> |
Vesting schedule details. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
create_proposal |
Admin | description: String, choices: u32, start_time: u64, end_time: u64, snapshot_ledger: u32 |
u64 |
Create a proposal using a past ledger for voting power. |
vote |
Token Holder | proposal_id: u64, choice: u32 |
— | Cast a vote using the voter's recorded balance at the proposal snapshot. |
execute_proposal |
— | proposal_id: u64 |
— | Execute a passed proposal. |
get_proposal |
— | proposal_id: u64 |
Option<Proposal> |
Proposal details. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
initialize |
— | admin: Address, progress_tracker: Address |
Result<(), ContractError> |
Initialize the contract. Can only be called once. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
mint_credential |
Admin + Learner | to: Address, course_id: Symbol, score: u32, metadata_uri: Symbol |
u64 |
Mint a credential NFT. Score-gated (>=50) and verified on-chain. |
verify_credential |
— | credential_id: u64 |
CredentialInfo |
Verify a credential and return its info. |
verify_credential_with_display |
— | credential_id: u64 |
CredentialVerification |
Verify with optional display properties. |
revoke_credential |
Admin | credential_id: u64 |
— | Revoke a credential. |
revoke_credential_with_reason |
Admin | credential_id: u64, reason: Symbol |
— | Revoke with a reason. |
get_revocation_reason |
— | credential_id: u64 |
Option<Symbol> |
Revocation reason, if any. |
renew_credential |
Admin | credential_id: u64, new_expiry: u32 |
— | Renew expiration (0 = no expiration). |
is_credential_valid |
— | credential_id: u64 |
bool |
Check if valid (exists, not revoked, not expired). |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
set_credential_display |
Admin | credential_id: u64, image_url: Option<Symbol>, description: Option<Symbol>, issuer_name: Option<Symbol> |
— | Set display properties. |
get_credential_display |
— | credential_id: u64 |
Option<CredentialDisplay> |
Get display properties. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
get_credentials_for |
— | learner: Address, start: u32, limit: u32 |
Vec<u64> |
Paginated credential IDs for a learner. |
get_credential_count |
— | learner: Address |
u32 |
Total credentials a learner holds. |
get_total_credentials_count |
— | — | u64 |
Total credentials issued across all learners. |
get_credentials_by_course |
— | course_id: Symbol |
Vec<u64> |
All credential IDs for a course. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
transfer |
— | from: Address, to: Address, credential_id: u64 |
Result<(), ContractError> |
Always returns Err(Soulbound). Credentials are non-transferable. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
generate_certificate |
Learner | learner: Address, course_id: Symbol |
Symbol |
Generate a course completion certificate URI. |
get_certificate_uri |
— | learner: Address, course_id: Symbol |
Option<Symbol> |
Query a generated certificate URI. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
admin |
— | — | Address |
Admin address. |
progress_tracker |
— | — | Address |
Progress-tracker address. |
transfer_admin |
Admin | new_admin: Address |
— | Initiate delayed admin transfer. |
accept_admin |
Pending | — | — | Accept admin role after delay. |
cancel_admin_transfer |
Admin | — | — | Cancel pending admin transfer. |
pending_admin |
— | — | Option<PendingAdminTransfer> |
Pending transfer details. |
admin_transfer_delay |
— | — | u64 |
Current delay in seconds. |
set_admin_transfer_delay |
Admin | delay_seconds: u64 |
— | Set the transfer delay. |
contract_metadata |
— | — | ContractMetadata |
Contract name and version. |
is_initialized |
— | — | bool |
Whether initialized. |
get_storage_size |
— | — | u64 |
Number of persistent storage entries. |
| Function | Auth | Parameters | Returns | Description |
|---|---|---|---|---|
emergency_pause |
Admin | — | — | Pause all state-changing operations. |
unpause |
Admin | — | — | Resume operations. |
struct ProgressInfo {
enrolled_at: u64, // Timestamp of enrollment
modules_completed_bitmap: u64, // Bitmap of completed module indices
quizzes_submitted: u32, // Number of quizzes submitted
total_quiz_score: u64, // Sum of submitted scores (average is derived)
overall_progress: u32, // Progress percentage (0-100)
eligible_for_credential: bool, // Qualifies for credential
completed_version: Option<u32>, // Course version when eligibility was reached
}Module completion and individual quiz submissions are not duplicated here:
they are stored once, under the ModuleCompleted and QuizResult storage
keys. Use get_quiz_score(learner, course_id, quiz_id) to read a single
result.
struct Course {
course_id: Symbol, // Unique course identifier
total_modules: u32, // Number of modules
total_quizzes: u32, // Number of quizzes
module_ids: Vec<Symbol>, // Ordered module identifiers
quiz_ids: Vec<Symbol>, // Quiz identifiers
archived: bool, // Whether archived
content_hash: Symbol, // Off-chain content hash (optional)
prerequisites: Vec<Symbol>, // Required courses
version: u32, // Content version
updated_at: u64, // Last modification timestamp
created_at: u64, // Immutable creation timestamp
difficulty: u32, // 0=beginner, 1=intermediate, 2=advanced
tags: Vec<Symbol>, // Categorization tags
}struct CredentialInfo {
learner: Address, // Credential holder
course_id: Symbol, // Course identifier
score: u32, // Final score (0-100)
issued_at: u64, // Issuance timestamp
revoked: bool, // Revocation status
metadata_uri: Symbol, // Off-chain metadata URI
expires_at: u32, // Expiration ledger (0 = no expiration)
}struct QuizResult {
quiz_id: Symbol, // Quiz identifier
course_id: Symbol, // Parent course
score: u32, // Score achieved
submitted_at: u64, // Submission timestamp
}struct LearnerStats {
courses_enrolled: u32, // Total enrolled courses
courses_completed: u32, // Courses with credential eligibility
total_quizzes_submitted: u32, // Quizzes submitted across all courses
total_quiz_score: u64, // Sum of all quiz scores
average_score: u32, // Average quiz score (floored)
potential_rewards: i128, // Reward value of submitted scores; not necessarily claimed
}struct ClaimRecord {
course_id: Symbol, // Course the quiz belonged to
quiz_id: Symbol, // Quiz that was claimed
amount: i128, // Reward amount minted
timestamp: u64, // Claim timestamp
}struct ContractMetadata {
name: SorobanString, // Contract name (e.g. "learn-token")
version: SorobanString, // Semantic version (e.g. "1.0.0")
}| Event | Topics | Data | Description |
|---|---|---|---|
course_created |
— | (course_id, total_modules, total_quizzes, module_ids) |
New course registered. |
enrolled |
— | (learner, course_id, enrolled_at) |
Learner enrolled. |
module_completed |
— | (learner, course_id, module_id, overall_progress) |
Module marked complete. |
quiz_submitted |
— | (learner, course_id, quiz_id, score) |
Quiz score submitted. |
quiz_retaken |
— | (learner, course_id, quiz_id, previous_score, new_score) |
Quiz retaken with higher score. |
credential_eligible |
— | (learner, course_id) |
Learner became eligible for credential. |
course_archived |
— | (course_id,) |
Course archived. |
content_hash_set |
— | (course_id, content_hash) |
Content hash updated. |
course_difficulty_set |
— | (course_id, difficulty) |
Difficulty updated. |
course_tags_set |
— | (course_id, tags) |
Tags updated. |
| Event | Topics | Data | Description |
|---|---|---|---|
reward |
[reward, learner, course_id] |
(quiz_id, score, reward_amount) |
Reward claimed. |
transfer |
[transfer, from, to] |
(amount,) |
Tokens transferred. |
transfer_from |
[transfer_from, from, to] |
(spender, amount) |
Delegated transfer. |
burn |
[burn, from] |
(amount,) |
Tokens burned. |
burn_from |
[burn_from, from] |
(spender, amount) |
Delegated burn. |
mint |
[mint, to] |
(amount,) |
Tokens minted. |
approve |
[approve, owner, spender] |
(amount, expiration_ledger) |
Allowance set. |
allowance_expired |
[allowance_expired, owner, spender] |
(expiration_ledger,) |
Expired allowance accessed. |
restriction_updated |
[restriction_updated] |
(restriction,) |
Transfer restriction changed. |
whitelist_updated |
[whitelist_updated, address] |
(added,) |
Whitelist changed. |
snapshot_created |
[snapshot_created] |
(ledger_height,) |
Snapshot announced (marker; balances are recorded separately). |
upgraded |
[upgraded] |
(new_wasm_hash, upgrade_version) |
Contract upgraded. |
role_granted |
[role_granted, address] |
(role,) |
Admin role granted. |
role_revoked |
[role_revoked, address] |
(role,) |
Admin role revoked. |
paused |
[paused] |
(admin, timestamp) |
Contract paused. |
unpaused |
[unpaused] |
(admin, timestamp) |
Contract unpaused. |
vesting_created |
[vesting_created, beneficiary] |
(total_amount, cliff_timestamp, duration_seconds) |
Vesting schedule created. |
vesting_claimed |
[vesting_claimed, beneficiary] |
(claimed_amount, total_claimed) |
Vested tokens claimed. |
proposal_created |
[proposal_created] |
(proposal_id, start_time, end_time) |
Governance proposal created. |
vote_cast |
[vote_cast, voter] |
(proposal_id, choice, voting_power) |
Vote cast. |
proposal_executed |
[proposal_executed] |
(proposal_id, winning_choice, winning_votes) |
Proposal executed. |
max_supply_updated |
[max_supply_updated] |
(old_max_supply, new_max_supply) |
Supply cap changed. |
admin_transfer_initiated |
[admin_transfer_initiated, new_admin] |
(current_admin, initiated_at, accept_after) |
Admin transfer started. |
admin_transfer_accepted |
[admin_transfer_accepted, new_admin] |
(previous_admin,) |
Admin transfer accepted. |
admin_transfer_cancelled |
[admin_transfer_cancelled, new_admin] |
(current_admin,) |
Admin transfer cancelled. |
admin_transfer_delay_updated |
[admin_transfer_delay_updated] |
(old_delay_seconds, new_delay_seconds) |
Delay changed. |
| Event | Topics | Data | Description |
|---|---|---|---|
credential_minted |
— | (learner, course_id, credential_id, score, metadata_uri) |
Credential minted. |
credential_revoked |
— | (learner, course_id, credential_id, admin) |
Credential revoked. |
credential_revoked (with reason) |
— | (learner, course_id, credential_id, admin, reason) |
Credential revoked with reason. |
credential_renewed |
— | (credential_id, new_expiry) |
Credential expiration renewed. |
credential_metadata_updated |
— | (credential_id, new_metadata_uri) |
Metadata URI updated. |
credential_display_set |
— | (credential_id,) |
Display properties set. |
certificate_generated |
[certificate_generated, learner, course_id] |
(cert_uri,) |
Certificate URI generated. |
- Architecture - Contract relationships, data flow, and storage layout
- Security Model - Authorization, trust boundaries, and threat mitigation
- Upgrade Guide - Procedures for upgrading contracts
- Integration Guide - SDK setup and contract interaction patterns
- WASM Size Audit - Contract sizes, optimizations applied, and remaining opportunities
Contributions are welcome! Before you start:
- Read and follow our Code of Conduct.
- Found a bug? Open a bug report.
- Have an idea? Open a feature request.
- Please report security vulnerabilities privately to the maintainers instead of opening a public issue.
Templates live in .github/ISSUE_TEMPLATE/. Keep them up to date when components, tooling, or required environment details change.
This project is licensed under the MIT License. Copyright (c) 2026 ChainLearn.
AlreadyInitialized: The contract has already been initialized.NotAuthorized: The caller lacks the required administrative or ownership privileges.NotFound: The requested resource (course, module, credential) does not exist.InvalidState: The operation cannot be performed in the current state (e.g., course archived).InsufficientAllowance: The spender is not approved for the required token amount.
For comprehensive integration examples, see the examples/ directory in the repository root, which includes:
- Rust client (
examples/rust_client_example.rs) - JavaScript client (
examples/javascript_client_example.js) - CLI examples (
examples/cli_usage_examples.sh)