diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 1e762782..0ea16cc3 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -3,6 +3,7 @@ resolver = "2" members = [ "./asset-maintenance", "assetsup", + "opsce", "contrib", "multisig-wallet", "multisig_transfer", diff --git a/contracts/assetsup/Cargo.toml b/contracts/assetsup/Cargo.toml index 6ea6dc4d..5066a1d8 100644 --- a/contracts/assetsup/Cargo.toml +++ b/contracts/assetsup/Cargo.toml @@ -9,6 +9,7 @@ doctest = false [dependencies] soroban-sdk = { workspace = true } +opsce = { path = "../opsce" } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index df4d5d23..91274ddb 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -758,24 +758,72 @@ impl AssetUpContract { ) } - /// Add address to whitelist - pub fn add_to_whitelist(env: Env, asset_id: u64, address: Address) -> Result<(), Error> { - transfer_restrictions::add_to_whitelist(&env, asset_id, address) + /// Add address to whitelist (admin only) + pub fn add_to_whitelist( + env: Env, + asset_id: u64, + admin: Address, + address: Address, + ) -> Result<(), Error> { + admin.require_auth(); + let token = tokenization::get_tokenized_asset(&env, asset_id)?; + if token.tokenizer != admin { + return Err(Error::Unauthorized); + } + + opsce::whitelist::add_to_whitelist(&env, asset_id, address.clone()); + env.events() + .publish(("transfer", "whitelist_added"), (asset_id, address)); + Ok(()) } - /// Remove address from whitelist - pub fn remove_from_whitelist(env: Env, asset_id: u64, address: Address) -> Result<(), Error> { - transfer_restrictions::remove_from_whitelist(&env, asset_id, address) + /// Remove address from whitelist (admin only) + pub fn remove_from_whitelist( + env: Env, + asset_id: u64, + admin: Address, + address: Address, + ) -> Result<(), Error> { + admin.require_auth(); + let token = tokenization::get_tokenized_asset(&env, asset_id)?; + if token.tokenizer != admin { + return Err(Error::Unauthorized); + } + + opsce::whitelist::remove_from_whitelist(&env, asset_id, address.clone()); + env.events() + .publish(("transfer", "whitelist_removed"), (asset_id, address)); + Ok(()) } /// Check if address is whitelisted pub fn is_whitelisted(env: Env, asset_id: u64, address: Address) -> Result { - transfer_restrictions::is_whitelisted(&env, asset_id, address) + Ok(opsce::whitelist::is_whitelisted(&env, asset_id, address)) } /// Get whitelist pub fn get_whitelist(env: Env, asset_id: u64) -> Result, Error> { - transfer_restrictions::get_whitelist(&env, asset_id) + Ok(opsce::whitelist::get_whitelist(&env, asset_id)) + } + + /// Enable or disable whitelist enforcement for an asset (admin only) + pub fn set_whitelist_enabled( + env: Env, + asset_id: u64, + admin: Address, + enabled: bool, + ) -> Result<(), Error> { + admin.require_auth(); + let token = tokenization::get_tokenized_asset(&env, asset_id)?; + if token.tokenizer != admin { + return Err(Error::Unauthorized); + } + + opsce::whitelist::set_whitelist_enabled(&env, asset_id, enabled); + env.events() + .publish(("transfer", "whitelist_enabled"), (asset_id, enabled)); + + Ok(()) } // ===================== diff --git a/contracts/assetsup/src/tests/detokenization.rs b/contracts/assetsup/src/tests/detokenization.rs index 06576037..eca984a1 100644 --- a/contracts/assetsup/src/tests/detokenization.rs +++ b/contracts/assetsup/src/tests/detokenization.rs @@ -221,7 +221,7 @@ fn test_detokenization_clears_all_data() { // Set up some data client.transfer_tokens(&1u64, &user1, &user2, &600000i128); - client.add_to_whitelist(&1u64, &user2); + client.add_to_whitelist(&1u64, &user1, &user2); client.enable_revenue_sharing(&1u64); // Propose and execute detokenization diff --git a/contracts/assetsup/src/tests/integration_full.rs b/contracts/assetsup/src/tests/integration_full.rs index a3527da9..47524e82 100644 --- a/contracts/assetsup/src/tests/integration_full.rs +++ b/contracts/assetsup/src/tests/integration_full.rs @@ -120,8 +120,8 @@ fn test_transfer_restrictions_workflow() { // Set transfer restrictions client.set_transfer_restriction(&asset_id, &true); - // Add investor1 to whitelist - client.add_to_whitelist(&asset_id, &investor1); + // Add investor1 to whitelist (tokenizer is `owner`) + client.add_to_whitelist(&asset_id, &owner, &investor1); // Transfer to whitelisted address should succeed client.transfer_tokens(&asset_id, &owner, &investor1, &100000i128); diff --git a/contracts/assetsup/src/tests/transfer_restrictions.rs b/contracts/assetsup/src/tests/transfer_restrictions.rs index 96fd0523..fcdec0a8 100644 --- a/contracts/assetsup/src/tests/transfer_restrictions.rs +++ b/contracts/assetsup/src/tests/transfer_restrictions.rs @@ -25,8 +25,8 @@ fn test_add_to_whitelist() { // Initially not whitelisted assert!(!client.is_whitelisted(&1u64, &user2)); - // Add to whitelist - client.add_to_whitelist(&1u64, &user2); + // Add to whitelist (tokenizer is `user1`) + client.add_to_whitelist(&1u64, &user1, &user2); assert!(client.is_whitelisted(&1u64, &user2)); } @@ -51,12 +51,12 @@ fn test_remove_from_whitelist() { &AssetType::Physical, ); - // Add to whitelist - client.add_to_whitelist(&1u64, &user2); + // Add to whitelist (tokenizer is `user1`) + client.add_to_whitelist(&1u64, &user1, &user2); assert!(client.is_whitelisted(&1u64, &user2)); // Remove from whitelist - client.remove_from_whitelist(&1u64, &user2); + client.remove_from_whitelist(&1u64, &user1, &user2); assert!(!client.is_whitelisted(&1u64, &user2)); } @@ -80,9 +80,9 @@ fn test_get_whitelist() { &AssetType::Physical, ); - // Add multiple addresses to whitelist - client.add_to_whitelist(&1u64, &user2); - client.add_to_whitelist(&1u64, &user3); + // Add multiple addresses to whitelist (tokenizer is `user1`) + client.add_to_whitelist(&1u64, &user1, &user2); + client.add_to_whitelist(&1u64, &user1, &user3); let whitelist = client.get_whitelist(&1u64); assert_eq!(whitelist.len(), 2); @@ -109,8 +109,8 @@ fn test_add_duplicate_to_whitelist() { ); // Add to whitelist twice - client.add_to_whitelist(&1u64, &user2); - client.add_to_whitelist(&1u64, &user2); + client.add_to_whitelist(&1u64, &user1, &user2); + client.add_to_whitelist(&1u64, &user1, &user2); // Should still only have one entry let whitelist = client.get_whitelist(&1u64); @@ -163,8 +163,8 @@ fn test_transfer_with_whitelist() { &AssetType::Physical, ); - // Add user2 to whitelist - client.add_to_whitelist(&1u64, &user2); + // Add user2 to whitelist (tokenizer is `user1`) + client.add_to_whitelist(&1u64, &user1, &user2); // Transfer should succeed client.transfer_tokens(&1u64, &user1, &user2, &100000i128); @@ -220,8 +220,8 @@ fn test_transfer_to_non_whitelisted_fails() { &AssetType::Physical, ); - // Only user2 is whitelisted - client.add_to_whitelist(&2u64, &user2); + // Only user2 is whitelisted (tokenizer is `user1`) + client.add_to_whitelist(&2u64, &user1, &user2); // Transfer to user3 (not whitelisted) should panic with TransferRestricted client.transfer_tokens(&2u64, &user1, &user3, &100000i128); @@ -251,3 +251,43 @@ fn test_empty_whitelist_allows_transfer() { client.transfer_tokens(&3u64, &user1, &user2, &100000i128); assert_eq!(client.get_token_balance(&3u64, &user2), 100000); } + +#[test] +fn test_whitelist_enforcement_toggle() { + let env = create_env(); + let (admin, user1, user2, _) = create_mock_addresses(&env); + let client = initialize_contract(&env, &admin); + + env.mock_all_auths(); + + client.tokenize_asset( + &1u64, + &String::from_str(&env, "TST"), + &1000000i128, + &6u32, + &100i128, + &user1, + &String::from_str(&env, "Test Token"), + &String::from_str(&env, "A test tokenized asset"), + &AssetType::Physical, + ); + + // Enable whitelist enforcement + client.set_whitelist_enabled(&1u64, &user1, &true); + + // Add only recipient + client.add_to_whitelist(&1u64, &user1, &user2); + + // Transfer should fail because sender (user1) is not whitelisted + let res = std::panic::catch_unwind(|| { + client.transfer_tokens(&1u64, &user1, &user2, &100000i128); + }); + assert!(res.is_err()); + + // Now whitelist sender as well + client.add_to_whitelist(&1u64, &user1, &user1); + + // Transfer should now succeed + client.transfer_tokens(&1u64, &user1, &user2, &100000i128); + assert_eq!(client.get_token_balance(&1u64, &user2), 100000); +} diff --git a/contracts/assetsup/src/tests/transfer_restrictions_new.rs b/contracts/assetsup/src/tests/transfer_restrictions_new.rs index c7baed2d..e286afab 100644 --- a/contracts/assetsup/src/tests/transfer_restrictions_new.rs +++ b/contracts/assetsup/src/tests/transfer_restrictions_new.rs @@ -7,6 +7,7 @@ use soroban_sdk::{Address, Env, String}; use crate::tokenization; use crate::transfer_restrictions; +use crate::AssetUpContractClient; use crate::types::{AssetType, TransferRestriction}; use crate::AssetUpContract; @@ -68,20 +69,28 @@ fn test_whitelist_operations() { let (is_wl_after_add, list_len, is_wl_after_remove) = env.as_contract(&contract_id, || { setup_tokenized_asset(&env, asset_id, &tokenizer); - - // Add to whitelist - transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); - - let is_wl_add = - transfer_restrictions::is_whitelisted(&env, asset_id, whitelisted.clone()).unwrap(); - let whitelist = transfer_restrictions::get_whitelist(&env, asset_id).unwrap(); + env.mock_all_auths(); + let client = AssetUpContractClient::new(&env, &contract_id); + + // Add to whitelist (admin/tokenizer) + client + .add_to_whitelist(&asset_id, &tokenizer, &whitelisted) + .unwrap(); + + let is_wl_add = client + .is_whitelisted(&asset_id, &whitelisted) + .unwrap(); + let whitelist = client.get_whitelist(&asset_id).unwrap(); let len = whitelist.len(); // Remove from whitelist - transfer_restrictions::remove_from_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); + client + .remove_from_whitelist(&asset_id, &tokenizer, &whitelisted) + .unwrap(); - let is_wl_rem = - transfer_restrictions::is_whitelisted(&env, asset_id, whitelisted.clone()).unwrap(); + let is_wl_rem = client + .is_whitelisted(&asset_id, &whitelisted) + .unwrap(); (is_wl_add, len, is_wl_rem) }); @@ -100,15 +109,19 @@ fn test_whitelist_duplicate_prevention() { let list_len = env.as_contract(&contract_id, || { setup_tokenized_asset(&env, asset_id, &tokenizer); + env.mock_all_auths(); + let client = AssetUpContractClient::new(&env, &contract_id); // Add to whitelist twice - transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); - transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); + client + .add_to_whitelist(&asset_id, &tokenizer, &whitelisted) + .unwrap(); + client + .add_to_whitelist(&asset_id, &tokenizer, &whitelisted) + .unwrap(); // Should still have only 1 entry - transfer_restrictions::get_whitelist(&env, asset_id) - .unwrap() - .len() + client.get_whitelist(&asset_id).unwrap().len() }); assert_eq!(list_len, 1); @@ -178,9 +191,13 @@ fn test_validate_transfer_blocked_when_not_whitelisted() { let (allowed_result, blocked_result) = env.as_contract(&contract_id, || { setup_tokenized_asset(&env, asset_id, &tokenizer); + env.mock_all_auths(); + let client = AssetUpContractClient::new(&env, &contract_id); // Add only `whitelisted` to the whitelist - transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); + client + .add_to_whitelist(&asset_id, &tokenizer, &whitelisted) + .unwrap(); // Transfer to whitelisted address should be allowed let allowed = transfer_restrictions::validate_transfer( @@ -247,7 +264,11 @@ fn test_validate_transfer_accredited_required_uses_whitelist() { geographic_allowed: soroban_sdk::Vec::new(&env), }; transfer_restrictions::set_transfer_restriction(&env, asset_id, restriction).unwrap(); - transfer_restrictions::add_to_whitelist(&env, asset_id, accredited.clone()).unwrap(); + env.mock_all_auths(); + let client = AssetUpContractClient::new(&env, &contract_id); + client + .add_to_whitelist(&asset_id, &tokenizer, &accredited) + .unwrap(); let ok = transfer_restrictions::validate_transfer( &env, diff --git a/contracts/assetsup/src/transfer_restrictions.rs b/contracts/assetsup/src/transfer_restrictions.rs index 16a89d5e..dff964a3 100644 --- a/contracts/assetsup/src/transfer_restrictions.rs +++ b/contracts/assetsup/src/transfer_restrictions.rs @@ -1,6 +1,7 @@ use crate::error::Error; use crate::types::{TokenDataKey, TransferRestriction}; use soroban_sdk::{Address, Env, Vec}; +use opsce::whitelist as ops_whitelist; /// Set transfer restrictions for an asset pub fn set_transfer_restriction( @@ -25,18 +26,8 @@ pub fn set_transfer_restriction( /// Add an address to the whitelist pub fn add_to_whitelist(env: &Env, asset_id: u64, address: Address) -> Result<(), Error> { - let store = env.storage().persistent(); - - let key = TokenDataKey::Whitelist(asset_id); - let mut whitelist: Vec
= store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); - - // Check if already in whitelist - if whitelist.iter().any(|a| a == address) { - return Ok(()); - } - - whitelist.push_back(address.clone()); - store.set(&key, &whitelist); + // Delegate storage to opsce whitelist module + ops_whitelist::add_to_whitelist(env, asset_id, address.clone()); // Emit event: (asset_id, address) env.events() @@ -47,61 +38,41 @@ pub fn add_to_whitelist(env: &Env, asset_id: u64, address: Address) -> Result<() /// Remove an address from the whitelist pub fn remove_from_whitelist(env: &Env, asset_id: u64, address: Address) -> Result<(), Error> { - let store = env.storage().persistent(); - - let key = TokenDataKey::Whitelist(asset_id); - let mut whitelist: Vec
= store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); - - // Find and remove address - if let Some(index) = whitelist.iter().position(|a| a == address) { - whitelist.remove(index as u32); - store.set(&key, &whitelist); + ops_whitelist::remove_from_whitelist(env, asset_id, address.clone()); - // Emit event: (asset_id, address) - env.events() - .publish(("transfer", "whitelist_removed"), (asset_id, address)); - } + // Emit event: (asset_id, address) + env.events() + .publish(("transfer", "whitelist_removed"), (asset_id, address)); Ok(()) } /// Check if an address is whitelisted pub fn is_whitelisted(env: &Env, asset_id: u64, address: Address) -> Result { - let store = env.storage().persistent(); - - let key = TokenDataKey::Whitelist(asset_id); - let whitelist: Vec
= store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); - - Ok(whitelist.iter().any(|a| a == address)) + Ok(ops_whitelist::is_whitelisted(env, asset_id, address)) } /// Get whitelist for an asset pub fn get_whitelist(env: &Env, asset_id: u64) -> Result, Error> { - let store = env.storage().persistent(); - - let key = TokenDataKey::Whitelist(asset_id); - Ok(store.get(&key).flatten().unwrap_or_else(|| Vec::new(env))) + Ok(ops_whitelist::get_whitelist(env, asset_id)) } /// Validate if a transfer is allowed based on restrictions pub fn validate_transfer( env: &Env, asset_id: u64, - _from: Address, + from: Address, to: Address, ) -> Result { let store = env.storage().persistent(); - - // Check whitelist: if non-empty, `to` must be whitelisted - let whitelist_key = TokenDataKey::Whitelist(asset_id); - let whitelist: Vec
= store - .get(&whitelist_key) - .flatten() - .unwrap_or_else(|| Vec::new(env)); - - if !whitelist.is_empty() { - let is_listed = whitelist.iter().any(|a| a == to); - if !is_listed { + // If whitelist enforcement is enabled for this asset, require both sender and recipient be whitelisted + if ops_whitelist::is_whitelist_enabled(env, asset_id) { + // Check recipient + if !ops_whitelist::is_whitelisted(env, asset_id, to.clone()) { + return Err(Error::TransferRestrictionFailed); + } + // Check sender + if !ops_whitelist::is_whitelisted(env, asset_id, from.clone()) { return Err(Error::TransferRestrictionFailed); } } @@ -118,8 +89,7 @@ pub fn validate_transfer( // If accredited investor required, check whitelist as MVP proxy if restriction.require_accredited { - let is_listed = whitelist.iter().any(|a| a == to); - if !is_listed { + if !ops_whitelist::is_whitelisted(env, asset_id, to.clone()) { return Err(Error::AccreditedInvestorRequired); } } diff --git a/contracts/contrib/src/tests/tokenization.rs b/contracts/contrib/src/tests/tokenization.rs index b470b59e..7fe320b5 100644 --- a/contracts/contrib/src/tests/tokenization.rs +++ b/contracts/contrib/src/tests/tokenization.rs @@ -98,7 +98,8 @@ fn test_transfer_tokens_blacklisted_recipient() { tokenize(&client, &env, 1, &from); // Add only `allowed` to whitelist — `blocked` is not listed - client.add_to_whitelist(&1u64, &allowed); + // The tokenizer (from) is the admin for whitelist management + client.add_to_whitelist(&1u64, &from, &allowed); // Transfer to non-whitelisted address should panic with TransferRestrictionFailed (#17) client.transfer_tokens(&1u64, &from, &blocked, &100_000i128); diff --git a/contracts/opsce/src/lib.rs b/contracts/opsce/src/lib.rs index 2ea4133e..6818eff8 100644 --- a/contracts/opsce/src/lib.rs +++ b/contracts/opsce/src/lib.rs @@ -1,4 +1,9 @@ #![no_std] +use soroban_sdk::Env; + +pub mod whitelist; + +pub use whitelist::*; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; diff --git a/contracts/opsce/src/whitelist.rs b/contracts/opsce/src/whitelist.rs new file mode 100644 index 00000000..31c44e7a --- /dev/null +++ b/contracts/opsce/src/whitelist.rs @@ -0,0 +1,70 @@ +use soroban_sdk::{Address, Env, String, Vec}; + +// Use a tuple key `(namespace, kind, asset_id)` to avoid serialization coupling +fn whitelist_key<'a>(env: &'a Env, asset_id: u64) -> (String, String, u64) { + ( + String::from_str(env, "opsce"), + String::from_str(env, "whitelist"), + asset_id, + ) +} + +fn enabled_key<'a>(env: &'a Env, asset_id: u64) -> (String, String, u64) { + ( + String::from_str(env, "opsce"), + String::from_str(env, "whitelist_enabled"), + asset_id, + ) +} + +/// Add an address to the whitelist for `asset_id`. +/// Note: authorization should be handled by the caller (contract wrapper). +pub fn add_to_whitelist(env: &Env, asset_id: u64, address: Address) { + let store = env.storage().persistent(); + + let key = whitelist_key(env, asset_id); + let mut list: Vec
= store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); + + // Prevent duplicates + if !list.iter().any(|a| a == address) { + list.push_back(address.clone()); + store.set(&key, &list); + } +} + +pub fn remove_from_whitelist(env: &Env, asset_id: u64, address: Address) { + let store = env.storage().persistent(); + + let key = whitelist_key(env, asset_id); + let mut list: Vec
= store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); + + if let Some(pos) = list.iter().position(|a| a == address) { + list.remove(pos as u32); + store.set(&key, &list); + } +} + +pub fn is_whitelisted(env: &Env, asset_id: u64, address: Address) -> bool { + let store = env.storage().persistent(); + let key = whitelist_key(env, asset_id); + let list: Vec
= store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)); + list.iter().any(|a| a == address) +} + +pub fn set_whitelist_enabled(env: &Env, asset_id: u64, enabled: bool) { + let store = env.storage().persistent(); + let key = enabled_key(env, asset_id); + store.set(&key, &enabled); +} + +pub fn is_whitelist_enabled(env: &Env, asset_id: u64) -> bool { + let store = env.storage().persistent(); + let key = enabled_key(env, asset_id); + store.get(&key).unwrap_or(false) +} + +pub fn get_whitelist(env: &Env, asset_id: u64) -> Vec
{ + let store = env.storage().persistent(); + let key = whitelist_key(env, asset_id); + store.get(&key).flatten().unwrap_or_else(|| Vec::new(env)) +}