From daff08f77002f479fd34a2e59c570b8d0ee45371 Mon Sep 17 00:00:00 2001 From: Anonfedora Date: Mon, 30 Mar 2026 00:28:37 +0100 Subject: [PATCH] feature(contract): Ownable Patter nof RsTokenContract --- contracts/src/lib.rs | 23 ++--- contracts/src/prop_tests.rs | 3 +- contracts/src/tests.rs | 19 ++--- contracts/src/token.rs | 165 ++++++++++++++++++++++++++++-------- 4 files changed, 154 insertions(+), 56 deletions(-) diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index 3aa61f1..b40a2d9 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -493,8 +493,10 @@ impl CertificateContract { .get(&DataKey::MintCap) .unwrap_or(DEFAULT_MINT_CAP); env.storage().instance().set(&DataKey::MintCap, &new_cap); - env.events() - .publish((Symbol::new(&env, "v1_mint_cap_updated"),), (old_cap, new_cap)); + env.events().publish( + (Symbol::new(&env, "v1_mint_cap_updated"),), + (old_cap, new_cap), + ); } PendingAdminAction::Upgrade(new_wasm_hash) => { // Upgrade risks (summary): malicious WASM can steal funds, brick storage layout, @@ -593,12 +595,12 @@ impl CertificateContract { let total_certificates = symbols.len(); let available = Self::check_and_update_mint_tracking(&env); - if (total_certificates as u32) > available { + if total_certificates > available { Self::release_lock(&env); panic_with_error!(&env, CertError::MintCapExceeded); } - Self::record_mint(&env, total_certificates as u32); + Self::record_mint(&env, total_certificates); let issue_date = env.ledger().timestamp(); let mut issued: Vec = Vec::new(&env); @@ -629,7 +631,10 @@ impl CertificateContract { // Batch event emission (emit one event per certificate for transparency) env.events().publish( - (Symbol::new(&env, "v1_batch_cert_issued"), course_symbol.clone()), + ( + Symbol::new(&env, "v1_batch_cert_issued"), + course_symbol.clone(), + ), (student.clone(), course.clone()), ); @@ -639,18 +644,14 @@ impl CertificateContract { // Emit summary event for the entire batch operation env.events().publish( (Symbol::new(&env, "v1_batch_issue_completed"),), - ( - instructor.clone(), - total_certificates as u32, - course.clone(), - ), + (instructor.clone(), total_certificates, course.clone()), ); env.events().publish( (Symbol::new(&env, "v1_mint_period_update"),), ( env.ledger().sequence() / LEDGERS_PER_PERIOD, - total_certificates as u32, + total_certificates, ), ); diff --git a/contracts/src/prop_tests.rs b/contracts/src/prop_tests.rs index 859a3b0..8276083 100644 --- a/contracts/src/prop_tests.rs +++ b/contracts/src/prop_tests.rs @@ -34,8 +34,7 @@ mod tests { sum += bal; } assert_eq!( - sum, - net[token_id as usize], + sum, net[token_id as usize], "supply invariant broken: token_id={token_id} sum={sum} expected={}", net[token_id as usize] ); diff --git a/contracts/src/tests.rs b/contracts/src/tests.rs index 8a10d7a..f5ee362 100644 --- a/contracts/src/tests.rs +++ b/contracts/src/tests.rs @@ -142,7 +142,8 @@ fn verifies_event_emitted_per_student() { let mut cert_issued_count = 0u32; for (addr, topics, _) in all_events.iter() { if addr == client.address - && Symbol::from_val(&env, &topics.get(0).unwrap()) == Symbol::new(&env, "v1_cert_issued") + && Symbol::from_val(&env, &topics.get(0).unwrap()) + == Symbol::new(&env, "v1_cert_issued") { cert_issued_count += 1; } @@ -740,11 +741,7 @@ fn batch_issue_emits_events() { let (env, instructor, _, _, client) = setup(); let symbols = vec![&env, symbol_short!("EVENT"), symbol_short!("EVENT2")]; - let students = vec![ - &env, - Address::generate(&env), - Address::generate(&env), - ]; + let students = vec![&env, Address::generate(&env), Address::generate(&env)]; let course_name = String::from_str(&env, "Event Test"); client.batch_issue(&instructor, &symbols, &students, &course_name); @@ -774,11 +771,13 @@ fn batch_issue_gas_efficiency() { let mut students = Vec::new(&env); // Create 10 different symbols without using format macro - let symbol_names = ["BATCH0", "BATCH1", "BATCH2", "BATCH3", "BATCH4", - "BATCH5", "BATCH6", "BATCH7", "BATCH8", "BATCH9"]; + let symbol_names = [ + "BATCH0", "BATCH1", "BATCH2", "BATCH3", "BATCH4", "BATCH5", "BATCH6", "BATCH7", "BATCH8", + "BATCH9", + ]; - for i in 0..10 { - symbols.push_back(Symbol::new(&env, symbol_names[i])); + for name in &symbol_names { + symbols.push_back(Symbol::new(&env, name)); students.push_back(Address::generate(&env)); } diff --git a/contracts/src/token.rs b/contracts/src/token.rs index bd16bfe..13ab469 100644 --- a/contracts/src/token.rs +++ b/contracts/src/token.rs @@ -1,5 +1,6 @@ use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, Vec, String, Symbol, + contract, contracterror, contractimpl, contracttype, panic_with_error, Address, Env, String, + Vec, }; use crate::CertificateContractClient; @@ -52,9 +53,7 @@ impl RsTokenContract { env.storage() .instance() .set(&DataKey::CertificateContract, &certificate_contract); - env.storage() - .instance() - .set(&DataKey::MintPaused, &false); + env.storage().instance().set(&DataKey::MintPaused, &false); env.storage() .instance() .set(&DataKey::Owner, &certificate_contract); @@ -98,6 +97,49 @@ impl RsTokenContract { env.storage().instance().set(&DataKey::Locked, &false); } + fn only_owner(env: &Env, caller: &Address) { + caller.require_auth(); + Self::check_owner(env, caller); + } + + fn check_owner(env: &Env, caller: &Address) { + let owner: Address = env.storage().instance().get(&DataKey::Owner).unwrap(); + + if caller != &owner { + panic_with_error!(env, TokenError::NotAuthorized); + } + } + + /// Transfers ownership of the contract to a new address. + /// Only the current owner can call this. + pub fn transfer_ownership(env: Env, caller: Address, new_owner: Address) { + Self::only_owner(&env, &caller); + + env.storage().instance().set(&DataKey::Owner, &new_owner); + + // Emit OwnershipTransferred event + env.events().publish( + ("OwnershipTransferred", "previous_owner", "new_owner"), + (caller, new_owner), + ); + } + + /// Updates the certificate contract address allowed to mint RS-Tokens. + /// Only the contract owner can call this. + pub fn set_certificate_contract(env: Env, caller: Address, new_certificate_contract: Address) { + Self::only_owner(&env, &caller); + + env.storage() + .instance() + .set(&DataKey::CertificateContract, &new_certificate_contract); + + // Emit CertificateContractUpdated event + env.events().publish( + ("CertificateContractUpdated", "new_certificate_contract"), + (new_certificate_contract,), + ); + } + /// Only the certificate contract may pause minting (invoked when the cert contract pauses). pub fn set_mint_pause(env: Env, caller: Address, paused: bool) { caller.require_auth(); @@ -190,14 +232,8 @@ impl RsTokenContract { } // Check authorization: only owner or the student themselves can burn - let owner: Address = env - .storage() - .instance() - .get(&DataKey::Owner) - .unwrap(); - - if caller != owner && caller != student { - panic_with_error!(&env, TokenError::NotAuthorized); + if caller != student { + Self::check_owner(&env, &caller); } let balance_key = DataKey::Balance(student.clone(), token_id); @@ -254,11 +290,15 @@ impl RsTokenContract { // Remove balance entry if zero to save storage env.storage().instance().remove(&from_balance_key); } else { - env.storage().instance().set(&from_balance_key, &new_from_balance); + env.storage() + .instance() + .set(&from_balance_key, &new_from_balance); } // Update recipient balance - env.storage().instance().set(&to_balance_key, &new_to_balance); + env.storage() + .instance() + .set(&to_balance_key, &new_to_balance); // Emit the Transferred event env.events().publish( @@ -300,18 +340,7 @@ impl RsTokenContract { /// Update token metadata URI. Only contract owner can call this. /// Admin function to update the off-chain JSON description URI. pub fn update_uri(env: Env, caller: Address, new_uri: String) { - caller.require_auth(); - - // Check authorization: only owner can update metadata - let owner: Address = env - .storage() - .instance() - .get(&DataKey::Owner) - .unwrap(); - - if caller != owner { - panic_with_error!(&env, TokenError::NotAuthorized); - } + Self::only_owner(&env, &caller); // Get existing metadata let mut metadata: TokenMetadata = env @@ -332,17 +361,15 @@ impl RsTokenContract { .set(&DataKey::TokenMetadata, &metadata); // Emit event for URI update - env.events().publish( - ("uri_updated", "old_uri", "new_uri"), - (old_uri, new_uri), - ); + env.events() + .publish(("uri_updated", "old_uri", "new_uri"), (old_uri, new_uri)); } } #[cfg(test)] mod tests { use super::*; - use soroban_sdk::{testutils::{Address as _, Events}, vec, Address, Env}; + use soroban_sdk::{testutils::Address as _, vec, Address, Env}; #[test] fn mints_balance_for_student_when_called_by_certificate_contract() { @@ -698,7 +725,10 @@ mod tests { assert_eq!(metadata.name, String::from_str(&env, "RS-Token")); assert_eq!(metadata.symbol, String::from_str(&env, "RST")); assert_eq!(metadata.decimals, 0u32); - assert_eq!(metadata.uri, String::from_str(&env, "https://metadata.web3-student-lab.com/token/{id}")); + assert_eq!( + metadata.uri, + String::from_str(&env, "https://metadata.web3-student-lab.com/token/{id}") + ); } #[test] @@ -753,7 +783,6 @@ mod tests { // Verify all required fields are present and have correct types assert!(!metadata.name.is_empty()); assert!(!metadata.symbol.is_empty()); - assert!(metadata.decimals >= 0); assert!(!metadata.uri.is_empty()); // Verify symbol is reasonable length (common token symbols are 3-5 chars) @@ -780,4 +809,74 @@ mod tests { let updated_metadata = client.get_metadata(); assert_eq!(updated_metadata.uri, new_uri); } + + #[test] + fn test_ownership_transfer() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(RsTokenContract, ()); + let client = RsTokenContractClient::new(&env, &contract_id); + + let certificate_contract = Address::generate(&env); + let new_owner = Address::generate(&env); + + client.init(&certificate_contract); + + // Initial owner is certificate_contract + client.transfer_ownership(&certificate_contract, &new_owner); + + // New owner can update URI + let new_uri = String::from_str(&env, "https://new-owner.example.com"); + client.update_uri(&new_owner, &new_uri); + assert_eq!(client.get_metadata().uri, new_uri); + + // Old owner cannot update URI anymore + let res = client.try_update_uri(&certificate_contract, &String::from_str(&env, "fail")); + assert!(res.is_err()); + } + + #[test] + fn test_set_certificate_contract() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(RsTokenContract, ()); + let client = RsTokenContractClient::new(&env, &contract_id); + + let initial_cert = Address::generate(&env); + let new_cert = Address::generate(&env); + let student = Address::generate(&env); + + client.init(&initial_cert); + + // Owner (initial_cert) updates certificate contract + client.set_certificate_contract(&initial_cert, &new_cert); + + // New cert contract can mint + client.mint(&new_cert, &student, &1, &100); + assert_eq!(client.get_balance(&student, &1), 100); + + // Old cert contract cannot mint anymore + let res = client.try_mint(&initial_cert, &student, &1, &100); + assert!(res.is_err()); + } + + #[test] + #[should_panic] + fn test_unauthorized_ownership_transfer() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(RsTokenContract, ()); + let client = RsTokenContractClient::new(&env, &contract_id); + + let certificate_contract = Address::generate(&env); + let unauthorized = Address::generate(&env); + let new_owner = Address::generate(&env); + + client.init(&certificate_contract); + + client.transfer_ownership(&unauthorized, &new_owner); + } }