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
115 changes: 85 additions & 30 deletions contracts/credential-nft/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ pub trait ProgressTrackerInterface {
#[repr(u32)]
pub enum ContractError {
AlreadyInitialized = 0,
/// Returned by `transfer` for every call: credentials are soulbound and
/// permanently bound to the learner who earned them, so no transfer is
/// ever permitted, regardless of caller or state (#242).
Soulbound = 1,
}

/// NFT credential contract for ChainLearn course certificates.
Expand All @@ -49,22 +53,38 @@ impl CredentialNft {
if env.storage().persistent().has(&CredentialDataKey::Admin) {
return Err(ContractError::AlreadyInitialized);
}
env.storage()
.persistent()
.set(&CredentialDataKey::Admin, &admin);
env.storage()
.persistent()
.set(&CredentialDataKey::ProgressTracker, &progress_tracker);
env.storage()
.persistent()
.set(&CredentialDataKey::CredentialCounter, &0u64);
env.storage().persistent().set(
metadata::write_entry(&env, &CredentialDataKey::Admin, &admin);
metadata::write_entry(&env, &CredentialDataKey::ProgressTracker, &progress_tracker);
metadata::write_entry(&env, &CredentialDataKey::CredentialCounter, &0u64);
metadata::write_entry(
&env,
&CredentialDataKey::Metadata,
&ContractMetadata::new(&env, "credential-nft"),
);
Ok(())
}

/// Returns whether the contract has been initialized (#240).
///
/// Read-only: performs a single storage existence check and never
/// mutates state. Lets deployment scripts confirm `initialize()` has
/// already run before calling admin-only setup steps, instead of
/// discovering an uninitialized contract only when some other call
/// panics with "not initialized".
pub fn is_initialized(env: Env) -> bool {
env.storage().persistent().has(&CredentialDataKey::Admin)
}

/// Returns the number of persistent storage entries this contract has
/// written (#239).
///
/// Maintained as a running counter updated on every persistent write and
/// removal, since Soroban has no API to enumerate or count a contract's
/// storage entries at runtime. Read-only and O(1): reads one counter entry.
pub fn get_storage_size(env: Env) -> u64 {
metadata::get_storage_size(&env)
}

/// Get the contract's on-chain name and version (#107).
///
/// Lets external tools (indexers, block explorers, upgrade tooling)
Expand Down Expand Up @@ -388,7 +408,10 @@ impl CredentialNft {
// ── Emergency Pause (#189) ────────────────────────────────────────────

fn is_paused(env: &Env) -> bool {
env.storage().persistent().get(&CredentialDataKey::Paused).unwrap_or(false)
env.storage()
.persistent()
.get(&CredentialDataKey::Paused)
.unwrap_or(false)
}

fn require_not_paused(env: &Env) {
Expand All @@ -399,17 +422,25 @@ impl CredentialNft {

/// Pause all state-changing operations. Admin only.
pub fn emergency_pause(env: Env) {
let admin: Address = env.storage().persistent().get(&CredentialDataKey::Admin).expect("not initialized");
let admin: Address = env
.storage()
.persistent()
.get(&CredentialDataKey::Admin)
.expect("not initialized");
admin.require_auth();
env.storage().persistent().set(&CredentialDataKey::Paused, &true);
metadata::write_entry(&env, &CredentialDataKey::Paused, &true);
// Event would ideally be emitted here, but we will omit it for simplicity if it wasn't added to events.rs
}

/// Unpause state-changing operations. Admin only.
pub fn unpause(env: Env) {
let admin: Address = env.storage().persistent().get(&CredentialDataKey::Admin).expect("not initialized");
let admin: Address = env
.storage()
.persistent()
.get(&CredentialDataKey::Admin)
.expect("not initialized");
admin.require_auth();
env.storage().persistent().set(&CredentialDataKey::Paused, &false);
metadata::write_entry(&env, &CredentialDataKey::Paused, &false);
}

/// Returns the admin address.
Expand Down Expand Up @@ -456,19 +487,32 @@ impl CredentialNft {
/// Reject transfer of a credential.
///
/// Credentials are soulbound (non-transferable) and permanently bound to the
/// learner who earned them. This function enforces that policy by rejecting
/// all transfer attempts.
/// learner who earned them: a credential attests that a specific learner,
/// and no one else, met a course's completion criteria, so allowing it to
/// change hands would let it be sold, gifted, or otherwise separated from
/// the achievement it certifies. This function enforces that policy by
/// explicitly rejecting every transfer attempt with a typed error rather
/// than panicking, so callers get a clear, documented reason instead of a
/// raw host trap, and can handle the rejection programmatically.
///
/// No storage is read or written: the rejection is unconditional and does
/// not depend on `from`, `to`, `credential_id`, or any on-chain state, so
/// there is nothing to authorize and no state to leave unchanged.
///
/// # Arguments
/// * `from` - The current holder (must authorize)
/// * `to` - The intended recipient (not used, transfer rejected)
/// * `credential_id` - The credential being transferred (not used, transfer rejected)
/// * `from` - The current holder (unused; transfer is always rejected)
/// * `to` - The intended recipient (unused; transfer is always rejected)
/// * `credential_id` - The credential being transferred (unused; transfer is always rejected)
///
/// # Panics
/// Always panics with a message explaining credentials are non-transferable.
pub fn transfer(_env: Env, from: Address, _to: Address, _credential_id: u64) {
from.require_auth();
panic!("credentials are soulbound and non-transferable");
/// # Returns
/// Always `Err(ContractError::Soulbound)`. Never `Ok`.
pub fn transfer(
_env: Env,
_from: Address,
_to: Address,
_credential_id: u64,
) -> Result<(), ContractError> {
Err(ContractError::Soulbound)
}

/// Generate a course completion certificate URI for a learner and course (#223).
Expand Down Expand Up @@ -507,19 +551,27 @@ impl CredentialNft {
}

let cert_uri = Symbol::new(&env, "cert_uri");
env.storage().persistent().set(&cert_key, &cert_uri);
metadata::write_entry(&env, &cert_key, &cert_uri);

// If credential already minted, update metadata_uri in CredentialInfo
if let Some(cred_id) = env.storage().persistent().get::<_, u64>(&dup_key) {
let cred_key = CredentialDataKey::Credential(cred_id);
if let Some(mut info) = env.storage().persistent().get::<_, CredentialInfo>(&cred_key) {
if let Some(mut info) = env
.storage()
.persistent()
.get::<_, CredentialInfo>(&cred_key)
{
info.metadata_uri = cert_uri.clone();
env.storage().persistent().set(&cred_key, &info);
metadata::write_entry(&env, &cred_key, &info);
}
}

env.events().publish(
(Symbol::new(&env, "certificate_generated"), learner.clone(), course_id.clone()),
(
Symbol::new(&env, "certificate_generated"),
learner.clone(),
course_id.clone(),
),
(cert_uri.clone(),),
);

Expand Down Expand Up @@ -1296,7 +1348,10 @@ mod tests {
assert_eq!(client.get_certificate_uri(&learner, &course), None);

let cert_uri = client.generate_certificate(&learner, &course);
assert_eq!(client.get_certificate_uri(&learner, &course), Some(cert_uri.clone()));
assert_eq!(
client.get_certificate_uri(&learner, &course),
Some(cert_uri.clone())
);

// Mint credential and check that metadata_uri gets updated with generated certificate URI
let cred_id = client.mint_credential(&learner, &course, &85, &cert_uri);
Expand Down
70 changes: 69 additions & 1 deletion contracts/credential-nft/src/metadata.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use soroban_sdk::{contracttype, Address, Symbol};
use soroban_sdk::{contracttype, Address, Env, IntoVal, Symbol, Val};

/// On-chain metadata for a minted credential NFT.
#[contracttype]
Expand Down Expand Up @@ -45,6 +45,74 @@ pub enum CredentialDataKey {
CertificateURI(Address, Symbol),
/// Emergency pause state (#189).
Paused,
/// Running count of persistent storage entries this contract has
/// written, excluding this counter entry itself (#239).
StorageSize,
}

// ── Storage Size Tracking (#239) ─────────────────────────────────────────────
//
// Soroban has no API to enumerate or count a contract's storage entries at
// runtime, so the count is maintained as an ordinary persistent counter,
// kept in sync by routing every persistent write and removal through
// `write_entry`/`remove_entry` below instead of calling
// `env.storage().persistent().set/remove` directly. Both check whether the
// key already exists before mutating, so overwriting an existing key does
// not double-count it, and removing a key that was never set does not
// underflow the counter.

/// Get the current persistent-entry count (#239).
///
/// O(1): reads a single counter entry, never scans storage.
pub fn get_storage_size(env: &Env) -> u64 {
env.storage()
.persistent()
.get(&CredentialDataKey::StorageSize)
.unwrap_or(0)
}

fn bump_storage_size(env: &Env, delta: i64) {
let current = get_storage_size(env);
let next = if delta >= 0 {
current.saturating_add(delta as u64)
} else {
current.saturating_sub((-delta) as u64)
};
env.storage()
.persistent()
.set(&CredentialDataKey::StorageSize, &next);
}

/// Write `value` to persistent storage at `key`, incrementing
/// [`get_storage_size`] iff `key` did not already exist. Use this (instead
/// of `env.storage().persistent().set` directly) for every persistent write
/// so the counter stays accurate.
pub fn write_entry<K, V>(env: &Env, key: &K, value: &V)
where
K: IntoVal<Env, Val>,
V: IntoVal<Env, Val>,
{
let is_new = !env.storage().persistent().has(key);
env.storage().persistent().set(key, value);
if is_new {
bump_storage_size(env, 1);
}
}

/// Remove `key` from persistent storage, decrementing [`get_storage_size`]
/// iff `key` existed. Use this (instead of
/// `env.storage().persistent().remove` directly) for every persistent
/// removal so the counter stays accurate.
#[allow(dead_code)]
pub fn remove_entry<K>(env: &Env, key: &K)
where
K: IntoVal<Env, Val>,
{
let existed = env.storage().persistent().has(key);
env.storage().persistent().remove(key);
if existed {
bump_storage_size(env, -1);
}
}

/// Display properties for a credential NFT (#244).
Expand Down
16 changes: 7 additions & 9 deletions contracts/credential-nft/src/mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,7 @@ pub fn mint_credential(
Some(id) => id,
None => panic!("credential ID counter overflow"),
};
env.storage()
.persistent()
.set(&CredentialDataKey::CredentialCounter, &credential_id);
crate::metadata::write_entry(env, &CredentialDataKey::CredentialCounter, &credential_id);

// Build credential info
let info = CredentialInfo {
Expand All @@ -142,9 +140,7 @@ pub fn mint_credential(

// Store credential data. The owner is available as `info.learner`, so no
// separate owner key is kept (#116).
env.storage()
.persistent()
.set(&CredentialDataKey::Credential(credential_id), &info);
crate::metadata::write_entry(env, &CredentialDataKey::Credential(credential_id), &info);

// Track credentials per learner
let mut learner_creds: soroban_sdk::Vec<u64> = env
Expand All @@ -153,13 +149,14 @@ pub fn mint_credential(
.get(&CredentialDataKey::LearnerCredentials(to.clone()))
.unwrap_or(soroban_sdk::Vec::new(env));
learner_creds.push_back(credential_id);
env.storage().persistent().set(
crate::metadata::write_entry(
env,
&CredentialDataKey::LearnerCredentials(to.clone()),
&learner_creds,
);

// Store the course-credential mapping to prevent duplicates
env.storage().persistent().set(&dup_key, &credential_id);
crate::metadata::write_entry(env, &dup_key, &credential_id);

// Index credentials by course for reverse lookup (#105)
let mut course_creds: soroban_sdk::Vec<u64> = env
Expand All @@ -168,7 +165,8 @@ pub fn mint_credential(
.get(&CredentialDataKey::CourseCredentials(course_id.clone()))
.unwrap_or(soroban_sdk::Vec::new(env));
course_creds.push_back(credential_id);
env.storage().persistent().set(
crate::metadata::write_entry(
env,
&CredentialDataKey::CourseCredentials(course_id.clone()),
&course_creds,
);
Expand Down
Loading