diff --git a/contracts/certificate/src/errors.rs b/contracts/certificate/src/errors.rs index 11eebe12..2c595091 100644 --- a/contracts/certificate/src/errors.rs +++ b/contracts/certificate/src/errors.rs @@ -17,6 +17,8 @@ pub enum CertificateError { Unauthorized = 101, /// 106 — Soulbound certificate cannot be transferred or delegated. SoulboundNonTransferable = 106, + /// 107 - Certificate transfer or delegation attempted; certificates are non-transferable. + NonTransferable = 107, /// 201 — Certificate record was not found in persistent storage. NotFound = 201, /// 202 — Certificate has already been revoked. diff --git a/contracts/certificate/src/lib.rs b/contracts/certificate/src/lib.rs index c9a2b292..5565c823 100644 --- a/contracts/certificate/src/lib.rs +++ b/contracts/certificate/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Bytes, BytesN, - Env, String, Vec, + contract, contracterror, contractimpl, contracttype, panic_with_error, symbol_short, Address, + Bytes, BytesN, Env, String, Vec, }; #[contracttype] @@ -14,6 +14,17 @@ pub enum CertificateStatus { Expired, } +/// Result of `verify_certificate`. Exactly one state is returned for any +/// certificate, with `Revoked` taking precedence over `Expired`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum VerificationStatus { + Valid, + Expired, + Revoked, + NotFound, +} + #[contracttype] #[derive(Clone, Debug)] pub struct CertificateRecord { @@ -25,12 +36,18 @@ pub struct CertificateRecord { pub status: CertificateStatus, pub revocation_reason: String, pub previous_cert_id: BytesN<32>, + /// Hash of the off-chain metadata document, verifiable via + /// `verify_content_hash`. pub content_hash: BytesN<32>, + /// URI pointing at the off-chain metadata document (e.g. `ipfs://...`). + pub metadata_uri: String, + /// Optional expiration timestamp (ledger seconds). `None` never expires. + pub expires_at: Option, /// SEP-0029 secondary-sale royalty cut, in basis points (1/100 of a /// percent). 250 == 2.5%. Capped at `MAX_ROYALTY_BPS`. pub royalty_bps: u32, /// Certificates are soulbound (non-transferable) by default. This flag - /// is set once at issuance and can never be cleared — it is an + /// is set once at issuance and can never be cleared; it is an /// immutable revocation of transferability, not a toggle. pub soulbound: bool, } @@ -51,6 +68,16 @@ pub struct RevocationAuditLog { pub action: String, } +/// Structured event published whenever a certificate is revoked. +#[contracttype] +#[derive(Clone, Debug)] +pub struct CertificateRevokedEvent { + pub cert_id: BytesN<32>, + pub issuer: Address, + pub reason_code: String, + pub timestamp: u64, +} + #[contracttype] #[derive(Clone, Debug)] pub struct MerkleCohortRoot { @@ -70,12 +97,29 @@ impl CertificateContract { env.storage().instance().set(&DataKey::NextCertId, &0u64); } + /// Registers the institutional ed25519 public key whose signatures + /// authorize revocation via `revoke_certificate_with_institution_sig`. + /// Admin only. + pub fn set_institution_key(env: Env, public_key: BytesN<32>) { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, Error::Unauthorized)); + admin.require_auth(); + env.storage() + .instance() + .set(&DataKey::InstitutionKey, &public_key); + } + pub fn issue_certificate( env: Env, owner: Address, issuer: Address, course_id: String, content_hash: BytesN<32>, + metadata_uri: String, + expires_at: Option, ) -> BytesN<32> { issuer.require_auth(); @@ -98,6 +142,8 @@ impl CertificateContract { revocation_reason: String::from_str(&env, ""), previous_cert_id: BytesN::from_array(&env, &[0u8; 32]), content_hash, + metadata_uri, + expires_at, royalty_bps: 0, // Immutable soulbound flag: certificates are non-transferable // from the moment they are issued. @@ -128,6 +174,7 @@ impl CertificateContract { cert_id_bytes } + /// Revokes a certificate. Requires the issuer's authorization. pub fn revoke_certificate(env: Env, cert_id: BytesN<32>, reason_code: String) { let mut record: CertificateRecord = env .storage() @@ -140,25 +187,48 @@ impl CertificateContract { // Enforce native Soroban authentication: only issuer may revoke record.issuer.require_auth(); - record.status = CertificateStatus::Revoked; - record.revocation_reason = reason_code.clone(); - env.storage() + Self::apply_revocation(&env, &mut record, reason_code); + } + + /// Revokes a certificate authorized by an institutional ed25519 + /// signature instead of issuer auth. The signature must be over the + /// message `cert_id (32 bytes) || reason_code (UTF-8 bytes)` made with + /// the key registered via `set_institution_key`. Traps if the + /// signature is invalid or no institution key is registered. + pub fn revoke_certificate_with_institution_sig( + env: Env, + cert_id: BytesN<32>, + reason_code: String, + signature: BytesN<64>, + ) { + let institution_key: BytesN<32> = env + .storage() + .instance() + .get(&DataKey::InstitutionKey) + .unwrap_or_else(|| panic_with_error!(&env, Error::Unauthorized)); + + let mut record: CertificateRecord = env + .storage() .persistent() - .set(&DataKey::Certificate(cert_id.clone()), &record); + .get(&DataKey::Certificate(cert_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotFound)); + if record.status == CertificateStatus::Revoked { + panic_with_error!(&env, Error::AlreadyRevoked); + } - Self::log_audit( - &env, - &cert_id, - &record.issuer, - String::from_str(&env, "REVOKED"), - reason_code, - ); + let message = Self::revocation_message(&env, &cert_id, &reason_code); + // Traps on verification failure. + env.crypto() + .ed25519_verify(&institution_key, &message, &signature); + + Self::apply_revocation(&env, &mut record, reason_code); } pub fn reissue_certificate( env: Env, old_cert_id: BytesN<32>, new_content_hash: BytesN<32>, + new_metadata_uri: String, ) -> BytesN<32> { let mut old_record: CertificateRecord = env .storage() @@ -190,6 +260,8 @@ impl CertificateContract { revocation_reason: String::from_str(&env, ""), previous_cert_id: old_cert_id.clone(), content_hash: new_content_hash, + metadata_uri: new_metadata_uri, + expires_at: old_record.expires_at, royalty_bps: old_record.royalty_bps, soulbound: true, }; @@ -278,6 +350,48 @@ impl CertificateContract { .unwrap_or_else(|| panic_with_error!(&env, Error::NotFound)) } + /// Single-call verification status. Never panics for unknown IDs. + /// Precedence: `NotFound` > `Revoked` > `Expired` > `Valid`. + /// Certificates in `Reissued` status are treated as `Revoked` because + /// reissuance is only possible after revocation. + pub fn verify_certificate(env: Env, cert_id: BytesN<32>) -> VerificationStatus { + let record: CertificateRecord = match env + .storage() + .persistent() + .get(&DataKey::Certificate(cert_id)) + { + Some(r) => r, + None => return VerificationStatus::NotFound, + }; + + match record.status { + CertificateStatus::Revoked | CertificateStatus::Reissued => { + return VerificationStatus::Revoked; + } + CertificateStatus::Expired => return VerificationStatus::Expired, + CertificateStatus::Active => {} + } + + if let Some(expires_at) = record.expires_at { + if env.ledger().timestamp() >= expires_at { + return VerificationStatus::Expired; + } + } + + VerificationStatus::Valid + } + + /// Returns `true` if `content_hash` matches the hash recorded for the + /// certificate's metadata document. Traps if the certificate is unknown. + pub fn verify_content_hash(env: Env, cert_id: BytesN<32>, content_hash: BytesN<32>) -> bool { + let record: CertificateRecord = env + .storage() + .persistent() + .get(&DataKey::Certificate(cert_id)) + .unwrap_or_else(|| panic_with_error!(&env, Error::NotFound)); + record.content_hash == content_hash + } + pub fn get_merkle_root(env: Env, cohort_id: String) -> MerkleCohortRoot { env.storage() .persistent() @@ -331,18 +445,29 @@ impl CertificateContract { /// standard way to probe (and fail loudly on) transferability instead /// of relying on the absence of a `transfer` function. pub fn transfer_certificate(env: Env, cert_id: BytesN<32>, _to: Address) { - let record: CertificateRecord = env + let _record: CertificateRecord = env .storage() .persistent() .get(&DataKey::Certificate(cert_id)) .unwrap_or_else(|| panic_with_error!(&env, Error::NotFound)); - if record.soulbound { - panic_with_error!(&env, Error::SoulboundNonTransferable); - } - // Non-soulbound certificates are not supported by this contract - // today; reaching here would require a future opt-out mechanism. - panic_with_error!(&env, Error::SoulboundNonTransferable); + panic_with_error!(&env, Error::NonTransferable); + } + + /// Always panics with `NonTransferable`. Certificates cannot be moved. + pub fn transfer(env: Env, _from: Address, _to: Address, _cert_id: BytesN<32>) { + panic_with_error!(&env, Error::NonTransferable); + } + + /// Always panics with `NonTransferable`. Certificates cannot be moved. + pub fn transfer_from( + env: Env, + _spender: Address, + _from: Address, + _to: Address, + _cert_id: BytesN<32>, + ) { + panic_with_error!(&env, Error::NonTransferable); } /// Returns `true` if the certificate is soulbound (non-transferable). @@ -387,11 +512,54 @@ impl CertificateContract { } } +impl CertificateContract { + /// Marks the record revoked, persists it, appends an audit entry and + /// publishes a `CertificateRevokedEvent`. Callers must perform their + /// own authorization and status checks first. + fn apply_revocation(env: &Env, record: &mut CertificateRecord, reason_code: String) { + let cert_id = record.cert_id.clone(); + let issuer = record.issuer.clone(); + + record.status = CertificateStatus::Revoked; + record.revocation_reason = reason_code.clone(); + env.storage() + .persistent() + .set(&DataKey::Certificate(cert_id.clone()), &*record); + + Self::log_audit( + env, + &cert_id, + &issuer, + String::from_str(env, "REVOKED"), + reason_code.clone(), + ); + + env.events().publish( + (symbol_short!("cert_rev"),), + CertificateRevokedEvent { + cert_id, + issuer, + reason_code, + timestamp: env.ledger().timestamp(), + }, + ); + } + + /// Builds the signed message for institutional revocation: + /// `cert_id (32 bytes) || reason_code (UTF-8 bytes)`. + fn revocation_message(env: &Env, cert_id: &BytesN<32>, reason_code: &String) -> Bytes { + let mut message = Bytes::from_slice(env, &cert_id.to_array()); + message.append(&Bytes::from(reason_code.clone())); + message + } +} + #[contracttype] #[derive(Clone, Debug)] pub enum DataKey { Admin, NextCertId, + InstitutionKey, Certificate(BytesN<32>), MerkleRoot(String), AuditLog(BytesN<32>), diff --git a/contracts/certificate/src/test.rs b/contracts/certificate/src/test.rs index ae63cd2c..a75f63ba 100644 --- a/contracts/certificate/src/test.rs +++ b/contracts/certificate/src/test.rs @@ -1,7 +1,10 @@ #![cfg(test)] use super::*; -use soroban_sdk::{testutils::Address as _, Bytes, BytesN, Env, String, Vec}; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Bytes, BytesN, Env, String, Vec, +}; /// Helper harness to set up the Soroban environment, register the contract, /// initialize the admin, and generate mock participants. @@ -62,8 +65,9 @@ fn test_issue_certificate_success() { let (env, client, _admin, issuer, owner) = setup_test(); let course_id = String::from_str(&env, "SOROBAN-101"); let content_hash = BytesN::from_array(&env, &[0xAA; 32]); + let uri = String::from_str(&env, "ipfs://bafy-cert-1"); - let cert_id = client.issue_certificate(&owner, &issuer, &course_id, &content_hash); + let cert_id = client.issue_certificate(&owner, &issuer, &course_id, &content_hash, &uri, &None); let record = client.get_certificate(&cert_id); assert_eq!(record.cert_id, cert_id); @@ -77,6 +81,8 @@ fn test_issue_certificate_success() { BytesN::from_array(&env, &[0u8; 32]) ); assert_eq!(record.content_hash, content_hash); + assert_eq!(record.metadata_uri, uri); + assert_eq!(record.expires_at, None); assert_eq!(record.royalty_bps, 0); assert!(record.soulbound); @@ -93,10 +99,11 @@ fn test_issue_multiple_certificates_duplicate_prevention() { let (env, client, _admin, issuer, owner) = setup_test(); let course_id = String::from_str(&env, "RUST-ADVANCED"); let content_hash = BytesN::from_array(&env, &[0xBB; 32]); + let uri = String::from_str(&env, "ipfs://cid"); - let cert_id_1 = client.issue_certificate(&owner, &issuer, &course_id, &content_hash); - let cert_id_2 = client.issue_certificate(&owner, &issuer, &course_id, &content_hash); - let cert_id_3 = client.issue_certificate(&owner, &issuer, &course_id, &content_hash); + let cert_id_1 = client.issue_certificate(&owner, &issuer, &course_id, &content_hash, &uri, &None); + let cert_id_2 = client.issue_certificate(&owner, &issuer, &course_id, &content_hash, &uri, &None); + let cert_id_3 = client.issue_certificate(&owner, &issuer, &course_id, &content_hash, &uri, &None); // Verify sequential unique IDs are generated for duplicate preventions assert_ne!(cert_id_1, cert_id_2); @@ -121,8 +128,9 @@ fn test_revoke_certificate_success() { let (env, client, _admin, issuer, owner) = setup_test(); let course_id = String::from_str(&env, "WEB3-BOOTCAMP"); let content_hash = BytesN::from_array(&env, &[0x11; 32]); + let uri = String::from_str(&env, "ipfs://cid"); - let cert_id = client.issue_certificate(&owner, &issuer, &course_id, &content_hash); + let cert_id = client.issue_certificate(&owner, &issuer, &course_id, &content_hash, &uri, &None); let reason = String::from_str(&env, "Academic dishonesty violation"); client.revoke_certificate(&cert_id, &reason); @@ -138,14 +146,33 @@ fn test_revoke_certificate_success() { assert_eq!(revoke_log.reason, reason); } +#[test] +fn test_revoke_emits_structured_event() { + let (env, client, _admin, issuer, owner) = setup_test(); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &BytesN::from_array(&env, &[0x12; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, + ); + let before = env.events().all().events().len(); + + client.revoke_certificate(&cert_id, &String::from_str(&env, "R1")); + + assert!(env.events().all().events().len() > before); +} + #[test] #[should_panic] fn test_revoke_already_revoked_fails() { let (env, client, _admin, issuer, owner) = setup_test(); let course_id = String::from_str(&env, "WEB3-BOOTCAMP"); let content_hash = BytesN::from_array(&env, &[0x11; 32]); + let uri = String::from_str(&env, "ipfs://cid"); - let cert_id = client.issue_certificate(&owner, &issuer, &course_id, &content_hash); + let cert_id = client.issue_certificate(&owner, &issuer, &course_id, &content_hash, &uri, &None); let reason = String::from_str(&env, "First revocation"); client.revoke_certificate(&cert_id, &reason); @@ -162,6 +189,52 @@ fn test_revoke_nonexistent_certificate_fails() { client.revoke_certificate(&fake_id, &reason); } +#[test] +fn test_revoke_with_institution_sig_without_key_fails() { + let (env, client, _admin, issuer, owner) = setup_test(); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &BytesN::from_array(&env, &[0x13; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, + ); + let sig = BytesN::from_array(&env, &[0u8; 64]); + + // No institution key registered: must be rejected. + let result = client.try_revoke_certificate_with_institution_sig( + &cert_id, + &String::from_str(&env, "R"), + &sig, + ); + assert!(result.is_err()); + assert_eq!(client.get_certificate(&cert_id).status, CertificateStatus::Active); +} + +#[test] +fn test_revoke_with_invalid_institution_sig_fails() { + let (env, client, _admin, issuer, owner) = setup_test(); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &BytesN::from_array(&env, &[0x14; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, + ); + client.set_institution_key(&BytesN::from_array(&env, &[0x21; 32])); + let sig = BytesN::from_array(&env, &[0u8; 64]); + + let result = client.try_revoke_certificate_with_institution_sig( + &cert_id, + &String::from_str(&env, "R"), + &sig, + ); + assert!(result.is_err()); + assert_eq!(client.get_certificate(&cert_id).status, CertificateStatus::Active); +} + // ============================================================================ // 3. REISSUE WORKFLOW TESTS // ============================================================================ @@ -172,12 +245,14 @@ fn test_reissue_certificate_success() { let course_id = String::from_str(&env, "MATH-201"); let old_hash = BytesN::from_array(&env, &[0x10; 32]); let new_hash = BytesN::from_array(&env, &[0x20; 32]); + let uri = String::from_str(&env, "ipfs://old"); + let new_uri = String::from_str(&env, "ipfs://new"); - let old_cert_id = client.issue_certificate(&owner, &issuer, &course_id, &old_hash); + let old_cert_id = client.issue_certificate(&owner, &issuer, &course_id, &old_hash, &uri, &None); client.set_royalty_bps(&old_cert_id, &300u32); // 3% client.revoke_certificate(&old_cert_id, &String::from_str(&env, "Name misspelled")); - let new_cert_id = client.reissue_certificate(&old_cert_id, &new_hash); + let new_cert_id = client.reissue_certificate(&old_cert_id, &new_hash, &new_uri); let old_record = client.get_certificate(&old_cert_id); assert_eq!(old_record.status, CertificateStatus::Reissued); @@ -188,6 +263,7 @@ fn test_reissue_certificate_success() { assert_eq!(new_record.issuer, issuer); assert_eq!(new_record.course_id, course_id); assert_eq!(new_record.content_hash, new_hash); + assert_eq!(new_record.metadata_uri, new_uri); assert_eq!(new_record.previous_cert_id, old_cert_id); assert_eq!(new_record.royalty_bps, 300); assert!(new_record.soulbound); @@ -206,9 +282,16 @@ fn test_reissue_active_certificate_fails() { let old_hash = BytesN::from_array(&env, &[0x10; 32]); let new_hash = BytesN::from_array(&env, &[0x20; 32]); - let cert_id = client.issue_certificate(&owner, &issuer, &course_id, &old_hash); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &course_id, + &old_hash, + &String::from_str(&env, "ipfs://cid"), + &None, + ); // Reissuing an Active (non-revoked) cert must panic with Error::NotRevoked - client.reissue_certificate(&cert_id, &new_hash); + client.reissue_certificate(&cert_id, &new_hash, &String::from_str(&env, "ipfs://new")); } #[test] @@ -217,7 +300,7 @@ fn test_reissue_nonexistent_certificate_fails() { let (env, client, _admin, _issuer, _owner) = setup_test(); let fake_id = BytesN::from_array(&env, &[0x99; 32]); let new_hash = BytesN::from_array(&env, &[0x20; 32]); - client.reissue_certificate(&fake_id, &new_hash); + client.reissue_certificate(&fake_id, &new_hash, &String::from_str(&env, "ipfs://new")); } // ============================================================================ @@ -315,7 +398,14 @@ fn test_set_royalty_bps_and_calculate_royalty() { let (env, client, _admin, issuer, owner) = setup_test(); let course_id = String::from_str(&env, "ART-101"); let hash = BytesN::from_array(&env, &[0x55; 32]); - let cert_id = client.issue_certificate(&owner, &issuer, &course_id, &hash); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &course_id, + &hash, + &String::from_str(&env, "ipfs://cid"), + &None, + ); // Initial royalty is 0 let (creator, seller) = client.calculate_royalty(&cert_id, &10_000i128); @@ -344,6 +434,8 @@ fn test_calculate_royalty_zero_and_negative_amounts() { &issuer, &String::from_str(&env, "COURSE"), &BytesN::from_array(&env, &[0x01; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, ); client.set_royalty_bps(&cert_id, &500u32); @@ -365,6 +457,8 @@ fn test_set_royalty_bps_above_cap_fails() { &issuer, &String::from_str(&env, "COURSE"), &BytesN::from_array(&env, &[0x01; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, ); // Setting royalty above MAX_ROYALTY_BPS (5000) should panic with Error::RoyaltyTooHigh @@ -399,14 +493,34 @@ fn test_is_soulbound_returns_true() { &issuer, &String::from_str(&env, "COURSE"), &BytesN::from_array(&env, &[0x01; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, ); assert!(client.is_soulbound(&cert_id)); } #[test] -#[should_panic] -fn test_transfer_certificate_always_panics() { +fn test_transfer_certificate_panics_non_transferable() { + let (env, client, _admin, issuer, owner) = setup_test(); + let recipient = Address::generate(&env); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &BytesN::from_array(&env, &[0x01; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, + ); + + match client.try_transfer_certificate(&cert_id, &recipient) { + Err(Ok(e)) => assert_eq!(e, Error::NonTransferable), + _ => panic!("expected NonTransferable"), + } +} + +#[test] +fn test_transfer_and_transfer_from_panic_non_transferable() { let (env, client, _admin, issuer, owner) = setup_test(); let recipient = Address::generate(&env); let cert_id = client.issue_certificate( @@ -414,9 +528,18 @@ fn test_transfer_certificate_always_panics() { &issuer, &String::from_str(&env, "COURSE"), &BytesN::from_array(&env, &[0x01; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, ); - client.transfer_certificate(&cert_id, &recipient); + match client.try_transfer(&owner, &recipient, &cert_id) { + Err(Ok(e)) => assert_eq!(e, Error::NonTransferable), + _ => panic!("expected NonTransferable from transfer"), + } + match client.try_transfer_from(&issuer, &owner, &recipient, &cert_id) { + Err(Ok(e)) => assert_eq!(e, Error::NonTransferable), + _ => panic!("expected NonTransferable from transfer_from"), + } } #[test] @@ -447,3 +570,103 @@ fn test_audit_log_nonexistent_returns_empty_vector() { let logs = client.get_audit_log(&fake_id); assert_eq!(logs.len(), 0); } + +// ============================================================================ +// 8. VERIFICATION, EXPIRATION & METADATA HASH TESTS +// ============================================================================ + +#[test] +fn test_verify_valid_without_expiry() { + let (env, client, _admin, issuer, owner) = setup_test(); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &BytesN::from_array(&env, &[0x31; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, + ); + + env.ledger().set_timestamp(1_000_000); + assert_eq!(client.verify_certificate(&cert_id), VerificationStatus::Valid); +} + +#[test] +fn test_verify_expires_at_boundary() { + let (env, client, _admin, issuer, owner) = setup_test(); + env.ledger().set_timestamp(100); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &BytesN::from_array(&env, &[0x32; 32]), + &String::from_str(&env, "ipfs://cid"), + &Some(200u64), + ); + + env.ledger().set_timestamp(199); + assert_eq!(client.verify_certificate(&cert_id), VerificationStatus::Valid); + + // now >= expires_at means expired + env.ledger().set_timestamp(200); + assert_eq!(client.verify_certificate(&cert_id), VerificationStatus::Expired); + // Expiry does not alter the stored status + assert_eq!(client.get_certificate(&cert_id).status, CertificateStatus::Active); +} + +#[test] +fn test_verify_revoked_takes_precedence_over_expired() { + let (env, client, _admin, issuer, owner) = setup_test(); + env.ledger().set_timestamp(100); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &BytesN::from_array(&env, &[0x33; 32]), + &String::from_str(&env, "ipfs://cid"), + &Some(150u64), + ); + client.revoke_certificate(&cert_id, &String::from_str(&env, "R")); + + env.ledger().set_timestamp(500); + assert_eq!(client.verify_certificate(&cert_id), VerificationStatus::Revoked); +} + +#[test] +fn test_verify_revoked_status() { + let (env, client, _admin, issuer, owner) = setup_test(); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &BytesN::from_array(&env, &[0x34; 32]), + &String::from_str(&env, "ipfs://cid"), + &None, + ); + client.revoke_certificate(&cert_id, &String::from_str(&env, "R")); + assert_eq!(client.verify_certificate(&cert_id), VerificationStatus::Revoked); +} + +#[test] +fn test_verify_not_found_for_unknown_id() { + let (env, client, _admin, _issuer, _owner) = setup_test(); + let fake_id = BytesN::from_array(&env, &[0xDD; 32]); + assert_eq!(client.verify_certificate(&fake_id), VerificationStatus::NotFound); +} + +#[test] +fn test_verify_content_hash_match_and_mismatch() { + let (env, client, _admin, issuer, owner) = setup_test(); + let hash = BytesN::from_array(&env, &[0x41; 32]); + let cert_id = client.issue_certificate( + &owner, + &issuer, + &String::from_str(&env, "COURSE"), + &hash, + &String::from_str(&env, "ipfs://cid"), + &None, + ); + + assert!(client.verify_content_hash(&cert_id, &hash)); + assert!(!client.verify_content_hash(&cert_id, &BytesN::from_array(&env, &[0x42; 32]))); +} diff --git a/contracts/certificate_nft/src/badges.rs b/contracts/certificate_nft/src/badges.rs index 0701f31b..b78cb62c 100644 --- a/contracts/certificate_nft/src/badges.rs +++ b/contracts/certificate_nft/src/badges.rs @@ -116,6 +116,6 @@ impl AchievementBadge { /// Soulbound assertion: transferring this badge is strictly disallowed. pub fn assert_transfer_blocked(env: &Env) -> ! { - panic_with_error!(env, CertError::SoulboundNonTransferable); + panic_with_error!(env, CertError::NonTransferable); } } diff --git a/contracts/certificate_nft/src/lib.rs b/contracts/certificate_nft/src/lib.rs index 1271edf3..644ca00c 100644 --- a/contracts/certificate_nft/src/lib.rs +++ b/contracts/certificate_nft/src/lib.rs @@ -10,7 +10,7 @@ use soroban_sdk::{ pub use badges::{AchievementBadge, ProvenanceRecord}; pub use rarity::RarityTier; -// ── Errors (SC-HARD-20: range 400+) ────────────────────────────────────────── +// ---- Errors (SC-HARD-20: range 400+) ---- /// Typed contract errors for the certificate NFT contract. /// @@ -19,18 +19,20 @@ pub use rarity::RarityTier; #[derive(Copy, Clone, Debug, Eq, PartialEq)] #[repr(u32)] pub enum CertError { - /// `400` — A certificate has already been minted for this student and course. + /// `400` - A certificate has already been minted for this student and course. AlreadyMinted = 400, - /// `401` — Caller is not the admin. + /// `401` - Caller is not the admin. Unauthorized = 401, - /// `402` — Badge is soulbound and cannot be transferred. + /// `402` - Badge is soulbound and cannot be transferred. SoulboundNonTransferable = 402, - /// `403` — Requested tier upgrade is invalid or prerequisites unmet. + /// `403` - Requested tier upgrade is invalid or prerequisites unmet. InvalidTierUpgrade = 403, - /// `404` — Requested badge ID was not found. + /// `404` - Requested badge ID was not found. BadgeNotFound = 404, - /// `405` — Insufficient completed milestones for this tier. + /// `405` - Insufficient completed milestones for this tier. InsufficientMilestones = 405, + /// `406` - Badge transfer attempted; badges are non-transferable. + NonTransferable = 406, } #[contracttype] @@ -114,50 +116,7 @@ impl CertificateNFTContract { (token_id, current_recipient, new_recipient), ); } -} - -#[cfg(test)] -mod tests { - use super::*; - use soroban_sdk::testutils::Address as _; - - #[test] - fn test_e2e_mint_and_royalty_calculation() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(CertificateNFTContract, ()); - let client = CertificateNFTContractClient::new(&env, &contract_id); - - let admin = Address::generate(&env); - let student = Address::generate(&env); - let designer = Address::generate(&env); - let course_id = String::from_str(&env, "CS101"); - let token_id = String::from_str(&env, "badge-cs101"); - - client.mint_certificate( - &admin, - &student, - &course_id, - &String::from_str(&env, "A"), - &String::from_str(&env, "ipfs://badge101"), - ); - - // Configure 5% (500 bps) royalty to designer - client.set_token_royalty(&admin, &token_id, &designer, &500u32); - - // Check royalty on secondary sale of 10,000 stroops/units - // 5% of 10_000 = 500 - let royalty = client.royalty_info(&token_id, &10_000u128); - assert_eq!(royalty.recipient, Some(designer.clone())); - assert_eq!(royalty.royalty_amount, 500u128); - // Test update payout address - let new_designer = Address::generate(&env); - client.update_royalty_recipient(&token_id, &designer, &new_designer); - - let updated_royalty = client.royalty_info(&token_id, &20_000u128); - assert_eq!(updated_royalty.recipient, Some(new_designer)); - assert_eq!(updated_royalty.royalty_amount, 1_000u128); /// Mints a new tiered achievement badge to a student. pub fn mint_badge( env: Env, @@ -238,5 +197,50 @@ mod tests { } } +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + + #[test] + fn test_e2e_mint_and_royalty_calculation() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CertificateNFTContract, ()); + let client = CertificateNFTContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let student = Address::generate(&env); + let designer = Address::generate(&env); + let course_id = String::from_str(&env, "CS101"); + let token_id = String::from_str(&env, "badge-cs101"); + + client.mint_certificate( + &admin, + &student, + &course_id, + &String::from_str(&env, "A"), + &String::from_str(&env, "ipfs://badge101"), + ); + + // Configure 5% (500 bps) royalty to designer + client.set_token_royalty(&admin, &token_id, &designer, &500u32); + + // Check royalty on secondary sale of 10,000 stroops/units + // 5% of 10_000 = 500 + let royalty = client.royalty_info(&token_id, &10_000u128); + assert_eq!(royalty.recipient, Some(designer.clone())); + assert_eq!(royalty.royalty_amount, 500u128); + + // Test update payout address + let new_designer = Address::generate(&env); + client.update_royalty_recipient(&token_id, &designer, &new_designer); + + let updated_royalty = client.royalty_info(&token_id, &20_000u128); + assert_eq!(updated_royalty.recipient, Some(new_designer)); + assert_eq!(updated_royalty.royalty_amount, 1_000u128); + } +} + #[cfg(test)] mod test; diff --git a/contracts/certificate_nft/src/test.rs b/contracts/certificate_nft/src/test.rs index 87b47a05..6b2e7dec 100644 --- a/contracts/certificate_nft/src/test.rs +++ b/contracts/certificate_nft/src/test.rs @@ -39,7 +39,7 @@ fn test_mint_badge_and_soulbound_enforcement() { } #[test] -#[should_panic(expected = "Error(Contract, #402)")] +#[should_panic(expected = "Error(Contract, #406)")] fn test_badge_transfer_fails_soulbound() { let (env, client, admin, student) = setup(); let recipient = Address::generate(&env); @@ -57,10 +57,28 @@ fn test_badge_transfer_fails_soulbound() { &milestone1, ); - // Transfer must panic with SoulboundNonTransferable (402) + // Transfer must panic with NonTransferable (406) client.transfer(&student, &recipient, &badge_id); } +#[test] +#[should_panic(expected = "Error(Contract, #406)")] +fn test_badge_transfer_from_fails_non_transferable() { + let (env, client, admin, student) = setup(); + let recipient = Address::generate(&env); + + let badge_id = client.mint_badge( + &admin, + &student, + &String::from_str(&env, "SORO-101"), + &RarityTier::Bronze, + &String::from_str(&env, "ipfs://cid1"), + &String::from_str(&env, "Milestone 1"), + ); + + client.transfer_from(&admin, &student, &recipient, &badge_id); +} + #[test] fn test_badge_tier_upgrade_and_provenance_history() { let (env, client, admin, student) = setup();