Please do not disclose a suspected vulnerability in a public issue, pull request, or discussion.
- Report privately using GitHub's private vulnerability reporting: open the Security tab of the repository and choose Report a vulnerability.
- If that is unavailable, open a public issue that only asks for a private channel (do not include any
details) or mention a maintainer such as
@Abidoyesimze, who will arrange one. - Include the affected contract and function, a description of the impact, steps or a test to reproduce, and any suggested fix.
What to expect
- Acknowledgement of your report within 3 business days.
- An initial assessment and severity rating within 7 days.
- Regular status updates until the issue is resolved, and credit in the release notes if you want it.
Please give maintainers reasonable time to fix and release a patch before any public disclosure. Reporters acting in good faith will not be pursued for their research. See also the Code of Conduct, which covers responsible disclosure and reporting, and docs/security.md for the security design.
This document provides a structured security audit checklist for reviewing the ChainLearn Soroban smart contracts. It covers the three contracts (learn-token, credential-nft, progress-tracker) and the shared utilities package.
- Every state-changing function that requires auth calls
require_auth()on the correct address -
require_auth()is called BEFORE any state reads or writes (first line after pause checks) - Cross-contract calls do not bypass authorization requirements
- Admin-only functions verify the caller holds the required role via
has_role() - Multi-sig operations require both signers and verify they are distinct addresses
- Role assignments are stored correctly and checked consistently
- The admin role grants all permissions (Admin implies Minter and Pauser)
- Role revocation takes effect immediately and cannot be bypassed
- The initial admin is correctly registered at
initialize()time -
add_admin/remove_adminrequire Admin role authorization
- All three contracts reject double-initialization (
AlreadyInitializederror) -
initialize()can only be called once (checked via persistent storage key existence) - Initialization sets all required state (admin, progress-tracker link, metadata)
-
is_initialized()accurately reflects the initialization state
- All arithmetic uses checked or saturating operations where overflow is possible
- Token supply never exceeds
max_supply(checked on every mint path:mint,claim_reward, batch variants) - Reward amounts are bounded:
score * BASE_REWARD_PER_POINT <= MAX_REWARD_AMOUNT - Credential ID counter uses
checked_add()to prevent u64 overflow - Progress percentages use integer division with no rounding that could exceed 100
-
total_quiz_scoreaccumulation cannot overflow (bounded by quiz count * MAX_QUIZ_SCORE) -
modules_completed_bitmapuses u64 and rejects module indices >= 64 -
set_max_supplyprevents increases beyond 2x the current cap (governance safeguard) -
set_max_supplyprevents decreasing below current total supply
- Cross-contract calls are read-only (progress-tracker queries only, no state mutations via callbacks)
-
claim_rewardresolves progress-tracker address once and reuses it across batch iterations -
batch_complete_moduleis atomic: all modules validated before any storage writes -
batch_submit_quiz_scoreuses per-quiz skip logic but writes progress once at the end - No external contract calls exist in the token transfer or burn paths
- State updates happen after validation but before event emission
- No duplicate storage keys for the same logical entity
- Reward claims are tracked per (learner, course_id, quiz_id) to prevent double-claiming
- Quiz results are stored once under their own key, not duplicated into ProgressInfo
- Credential uniqueness is enforced per (learner, course_id) pair
- Module completion uses a bitmap indexed by position in the course's module list
- Sequential module ordering is enforced (module N requires module N-1 completed)
- Allowances use temporary storage (not persistent) since they are short-lived
- Allowance TTL is extended to cover the expiration ledger
- Persistent entries extend TTL when created (
PERSISTENT_TTL_THRESHOLD/PERSISTENT_TTL_EXTEND_TO) - Storage entry counts are tracked for cost monitoring (
get_storage_size) - Singleton config values are not counted in storage size tracking
- Expired allowances can be pruned via
prune_expired_allowanceandcleanup_expired_allowances
- Revoking a credential removes it from the learner's credential list
- Revoking a credential removes it from the course credential index
- The
Revokedflag is stored in its own key for efficient validity checks - Revocation reasons are stored separately and retrievable
- Progress-tracker address is resolved once per batch operation and reused
- Cross-contract calls only invoke read-only functions (
get_quiz_score,get_course_score,is_eligible_for_credential,course_exists) - No cross-contract calls in the allowance, transfer, or burn paths
- The progress-tracker address can be updated by admin when the tracker is upgraded (
set_progress_tracker) -
initialize.shverifies the wiring by reading back the stored progress-tracker address
- Reward formula is consistent:
score * BASE_REWARD_PER_POINT(100 tokens per point) - Maximum reward per quiz is capped at
MAX_QUIZ_SCORE * BASE_REWARD_PER_POINT(10,000) - Total supply cap is enforced on every mint path (admin mint, reward claim, batch variants)
- Burning reduces total supply (circulating supply stays accurate)
-
total_minted_totracks cumulative minting independently of balance changes - Transfer restrictions (WhitelistOnly, Cooldown, MaxAmount) are checked on every transfer
- Transfers to the contract address are rejected (prevents token locking)
- Self-transfers (from == to) are no-ops
- Score verification: minted score must exactly match progress-tracker's
get_course_score - Eligibility gate:
is_eligible_for_credentialis checked before minting - Course existence is verified before minting (clear error message)
- Minimum score threshold (50) is enforced
- One credential per learner per course is enforced
- Credentials are soulbound:
transfer()always returnsErr(Soulbound) - Metadata URI validation: non-empty, >= 8 chars, valid scheme (ipfs, http, https, cert)
- Credential expiration can be set and checked (0 = no expiration)
- Revoked credentials cannot be renewed
- Admin transfer is delayed (default 48 hours) to allow cancellation
- The pending admin must call
accept_adminto complete the transfer - Current admin can call
cancel_admin_transferat any time before acceptance - The
admin_transfer_initiatedevent is emitted for monitoring - Zero address cannot be set as the new admin
- Multiple
transfer_admincalls overwrite the previous pending transfer
- Pause prevents all state-changing operations (transfer, mint, burn, claim, enroll, etc.)
- Pause is checked at the top of every state-changing function
- Read-only functions work normally when paused
- Only Pauser or Admin roles can pause/unpause
- Double-pause and unpause-when-not-paused are rejected with clear errors
- Events are emitted on pause/unpause for monitoring
-
upgrade()requires admin authorization -
upgrade_multisig()requires two distinct admin signers - State is preserved across upgrades (Soroban replaces only executable code)
- The new WASM hash is stored for audit trail
- Upgrade version counter is incremented on each upgrade
-
wasm_hash()andupgrade_version()are readable for verification
- Proposals have a defined voting window (start_time, end_time)
- Voting power is based on token balance at snapshot height
- Double-voting is prevented (one vote per address per proposal)
- Proposals can only be executed once
- Only admin can create proposals
- Cliff period is enforced before any tokens can be claimed
- Linear vesting calculates correct pro-rata amounts
- Claimed amounts are tracked to prevent over-claiming
- Fully claimed (exhausted) schedules cannot claim more
- Only admin can create vesting schedules
- All state-changing operations emit appropriate events
- Events include indexed topics for efficient server-side filtering
- Event data contains all fields needed to reconstruct the operation
-
credential_revokedevents index learner and course_id as topics and carry credential_id and admin in data -
rewardevents include learner, course_id, quiz_id, score, and amount -
credential_eligibleis emitted once per (learner, course) when eligibility first flips
- Zero-amount transfers, mints, and burns are handled correctly
- Unknown course/module/quiz IDs produce clear panic messages
- Paginated queries (
get_credentials_for) clamp limits and reject zero/oversized pages - Empty batch operations (empty quiz_ids, empty module_ids) are no-ops
- Concurrent enrollment attempts are rejected ("already enrolled")
- Quiz retake requires the new score to be strictly higher
- No hardcoded addresses or private keys in contract code
- All public functions have doc comments describing parameters and return values
- Constants are defined in the shared package and used consistently
- No
unsafecode blocks -
#[no_std]is used (no standard library dependencies) - Test coverage exists for all critical paths including error conditions
| Contract | Files | Lines |
|---|---|---|
learn-token |
lib.rs, storage.rs, events.rs |
~5230 |
credential-nft |
lib.rs, mint.rs, verify.rs, metadata.rs, xcall.rs, events.rs |
~2660 |
progress-tracker |
lib.rs, types.rs, rewards.rs, events.rs |
~5050 |
shared |
constants.rs, lib.rs |
~100 |
| Total | ~13040 |
- No on-chain key enumeration: Soroban has no API to list storage keys, so the contracts maintain their own counters (
get_storage_size) and spender registries for cleanup. - Integer division rounding: Progress calculations use integer division, which floors results. This is by design and documented.
- Sequential module ordering: Modules must be completed in the order they appear in the course's
module_idslist. This is enforced by checking the predecessor in the bitmap. - No automatic expiration: Credentials and allowances do not expire by default unless explicitly set. Expired allowances must be pruned manually or by indexers.
- Internal Review completed by: _________________ Date: __________
- External Audit completed by: __________________ Date: __________
- All critical and high issues resolved
- Audit report published to repository