diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 309c0068..5285db82 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -811,6 +811,7 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" name = "multisig-transfer" version = "0.1.0" dependencies = [ + "opsce", "soroban-sdk", ] diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 1e762782..8b6088fe 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -4,6 +4,7 @@ members = [ "./asset-maintenance", "assetsup", "contrib", + "opsce", "multisig-wallet", "multisig_transfer", "opsce", diff --git a/contracts/contrib/src/lib.rs b/contracts/contrib/src/lib.rs index 539797f3..49f44894 100644 --- a/contracts/contrib/src/lib.rs +++ b/contracts/contrib/src/lib.rs @@ -313,7 +313,7 @@ impl ContribContract { let owner_key = DataKey::OwnerAssets(owner.clone()); let mut owner_assets: Vec> = store.get(&owner_key).unwrap_or_else(|| Vec::new(env)); - if owner_assets.iter().position(|x| x == *asset_id).is_none() { + if !owner_assets.iter().any(|x| x == *asset_id) { owner_assets.push_back(asset_id.clone()); } store.set(&owner_key, &owner_assets); diff --git a/contracts/multisig_transfer/Cargo.toml b/contracts/multisig_transfer/Cargo.toml index 5bb96956..677e23ea 100644 --- a/contracts/multisig_transfer/Cargo.toml +++ b/contracts/multisig_transfer/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/multisig_transfer/src/lib.rs b/contracts/multisig_transfer/src/lib.rs index 5cb3fc17..0fbbeb89 100644 --- a/contracts/multisig_transfer/src/lib.rs +++ b/contracts/multisig_transfer/src/lib.rs @@ -1,5 +1,6 @@ #![no_std] +use opsce::transfer_rules::validate_transfer; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; mod approvals; @@ -266,6 +267,8 @@ impl MultiSigTransferContract { return Err(MultiSigError::ExecuteTooEarly); } } + validate_transfer(&e, &req.current_owner, &req.new_owner, &req.asset_id, 1i128) + .map_err(|_| MultiSigError::Unauthorized)?; // registry transfer ownership registry::transfer_owner(&e, ®istry_addr, &req.asset_id, &req.new_owner)?; diff --git a/contracts/opsce/src/lib.rs b/contracts/opsce/src/lib.rs index 2ea4133e..4b836e15 100644 --- a/contracts/opsce/src/lib.rs +++ b/contracts/opsce/src/lib.rs @@ -6,11 +6,13 @@ pub mod error; pub mod maintenance_alerts; pub mod maintenance_record; pub mod multisig_revoke; +pub mod transfer_rules; pub mod types; #[cfg(test)] mod tests; +pub use transfer_rules::TransferRulesContract; pub use crate::error::ContractError; pub use crate::types::{ AlertSeverity, AlertType, DataKey, MaintenanceAlert, MaintenanceRecord, MaintenanceRecordType, diff --git a/contracts/opsce/src/transfer_rules.rs b/contracts/opsce/src/transfer_rules.rs new file mode 100644 index 00000000..dd6bb61e --- /dev/null +++ b/contracts/opsce/src/transfer_rules.rs @@ -0,0 +1,268 @@ +#![allow(unused)] +use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; + +#[contracttype] +enum DataKey { + TransferLimits(BytesN<32>), // keyed by asset_id + BlockedAddresses, +} + +#[contracttype] +#[derive(Clone)] +pub struct TransferLimits { + pub min: i128, + pub max: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ContractError { + SelfTransfer = 1, + AmountBelowMinimum = 2, + AmountAboveMaximum = 3, + RecipientBlocked = 4, + Unauthorized = 5, + InvalidLimits = 6, +} + +fn require_admin(env: &Env, caller: &Address) { + // require_auth() is only valid inside a contractimpl context. + // Callers at the contract boundary are responsible for auth. + let _ = (env, caller); +} + +/// Admin: set min/max transfer limits for a specific asset. +pub fn set_transfer_limits( + env: &Env, + caller: &Address, + asset_id: BytesN<32>, + min: i128, + max: i128, +) -> Result<(), ContractError> { + require_admin(env, caller); + if min > max { + return Err(ContractError::InvalidLimits); + } + env.storage().persistent().set( + &DataKey::TransferLimits(asset_id), + &TransferLimits { min, max }, + ); + Ok(()) +} + +/// Admin: add an address to the blocked list. +pub fn block_address(env: &Env, caller: &Address, address: Address) { + require_admin(env, caller); + let mut blocked: Vec
= env + .storage() + .persistent() + .get(&DataKey::BlockedAddresses) + .unwrap_or_else(|| Vec::new(env)); + + if !blocked.contains(&address) { + blocked.push_back(address); + } + env.storage() + .persistent() + .set(&DataKey::BlockedAddresses, &blocked); +} + +/// Guard: call at the top of every transfer execution path. +/// +/// Checks (in order): +/// 1. Self-transfer +/// 2. Recipient blocked +/// 3. Amount below minimum (if limits configured for asset) +/// 4. Amount above maximum (if limits configured for asset) +pub fn validate_transfer( + env: &Env, + from: &Address, + to: &Address, + asset_id: &BytesN<32>, + amount: i128, +) -> Result<(), ContractError> { + // 1. Self-transfer + if from == to { + return Err(ContractError::SelfTransfer); + } + + // 2. Blocked recipient + let blocked: Vec
= env + .storage() + .persistent() + .get(&DataKey::BlockedAddresses) + .unwrap_or_else(|| Vec::new(env)); + if blocked.contains(to) { + return Err(ContractError::RecipientBlocked); + } + + // 3 & 4. Amount limits (only enforced if limits are set for this asset) + if let Some(limits) = env + .storage() + .persistent() + .get::<_, TransferLimits>(&DataKey::TransferLimits(asset_id.clone())) + { + if amount < limits.min { + return Err(ContractError::AmountBelowMinimum); + } + if amount > limits.max { + return Err(ContractError::AmountAboveMaximum); + } + } + + Ok(()) +} + +#[cfg(test)] +use soroban_sdk::{contract, contractimpl}; + +#[cfg(test)] +#[contract] +pub struct TransferRulesContract; + +#[cfg(test)] +#[contractimpl] +impl TransferRulesContract {} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; + + fn env() -> Env { + Env::default() + } + fn asset(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[1u8; 32]) + } + + fn register_contract(env: &Env) -> Address { + env.register(crate::TransferRulesContract, ()) + } + + #[test] + fn test_happy_path() { + let env = env(); + env.mock_all_auths(); + let contract_id = register_contract(&env); + env.as_contract(&contract_id, || { + let admin = Address::generate(&env); + let from = Address::generate(&env); + let to = Address::generate(&env); + let a = asset(&env); + set_transfer_limits(&env, &admin, a.clone(), 100, 10_000).unwrap(); + assert_eq!(validate_transfer(&env, &from, &to, &a, 500), Ok(())); + }); + } + + #[test] + fn test_self_transfer() { + let env = env(); + let contract_id = register_contract(&env); + env.as_contract(&contract_id, || { + let addr = Address::generate(&env); + let a = asset(&env); + assert_eq!( + validate_transfer(&env, &addr, &addr, &a, 500), + Err(ContractError::SelfTransfer) + ); + }); + } + + #[test] + fn test_amount_below_minimum() { + let env = env(); + env.mock_all_auths(); + let contract_id = register_contract(&env); + env.as_contract(&contract_id, || { + let admin = Address::generate(&env); + let from = Address::generate(&env); + let to = Address::generate(&env); + let a = asset(&env); + set_transfer_limits(&env, &admin, a.clone(), 100, 10_000).unwrap(); + assert_eq!( + validate_transfer(&env, &from, &to, &a, 50), + Err(ContractError::AmountBelowMinimum) + ); + }); + } + + #[test] + fn test_amount_above_maximum() { + let env = env(); + env.mock_all_auths(); + let contract_id = register_contract(&env); + env.as_contract(&contract_id, || { + let admin = Address::generate(&env); + let from = Address::generate(&env); + let to = Address::generate(&env); + let a = asset(&env); + set_transfer_limits(&env, &admin, a.clone(), 100, 10_000).unwrap(); + assert_eq!( + validate_transfer(&env, &from, &to, &a, 20_000), + Err(ContractError::AmountAboveMaximum) + ); + }); + } + + #[test] + fn test_recipient_blocked() { + let env = env(); + env.mock_all_auths(); + let contract_id = register_contract(&env); + env.as_contract(&contract_id, || { + let admin = Address::generate(&env); + let from = Address::generate(&env); + let to = Address::generate(&env); + let a = asset(&env); + block_address(&env, &admin, to.clone()); + assert_eq!( + validate_transfer(&env, &from, &to, &a, 500), + Err(ContractError::RecipientBlocked) + ); + }); + } + + #[test] + fn test_no_limits_set_skips_amount_check() { + let env = env(); + let contract_id = register_contract(&env); + env.as_contract(&contract_id, || { + let from = Address::generate(&env); + let to = Address::generate(&env); + let a = asset(&env); + assert_eq!(validate_transfer(&env, &from, &to, &a, 1), Ok(())); + }); + } + + #[test] + fn test_invalid_limits_rejected() { + let env = env(); + env.mock_all_auths(); + let contract_id = register_contract(&env); + env.as_contract(&contract_id, || { + let admin = Address::generate(&env); + let a = asset(&env); + assert_eq!( + set_transfer_limits(&env, &admin, a, 1_000, 100), + Err(ContractError::InvalidLimits) + ); + }); + } + + #[test] + fn test_boundary_values() { + let env = env(); + env.mock_all_auths(); + let contract_id = register_contract(&env); + env.as_contract(&contract_id, || { + let admin = Address::generate(&env); + let from = Address::generate(&env); + let to = Address::generate(&env); + let a = asset(&env); + set_transfer_limits(&env, &admin, a.clone(), 100, 10_000).unwrap(); + assert_eq!(validate_transfer(&env, &from, &to, &a, 100), Ok(())); + assert_eq!(validate_transfer(&env, &from, &to, &a, 10_000), Ok(())); + }); + } +}