diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 1e762782..221fd314 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -6,6 +6,7 @@ members = [ "contrib", "multisig-wallet", "multisig_transfer", + "./opsce", "opsce", ] diff --git a/contracts/assetsup/src/error.rs b/contracts/assetsup/src/error.rs index 2d8008a8..0c9caad1 100644 --- a/contracts/assetsup/src/error.rs +++ b/contracts/assetsup/src/error.rs @@ -19,6 +19,8 @@ pub enum Error { InvalidTokenSupply = 12, InvalidTokenDecimals = 13, InsufficientBalance = 14, + MaxSupplyExceeded = 15, + InvalidTokenTransfer = 16, InsufficientLockedTokens = 15, TokensAreLocked = 16, TransferRestrictionFailed = 17, diff --git a/contracts/assetsup/src/tests/mod.rs b/contracts/assetsup/src/tests/mod.rs index a44a81fd..50ab6005 100644 --- a/contracts/assetsup/src/tests/mod.rs +++ b/contracts/assetsup/src/tests/mod.rs @@ -11,6 +11,8 @@ mod initialization; mod detokenization; mod dividends; mod tokenization; +#[path = "../../../opsce/src/tokenization_tests.rs"] +mod tokenization_tests; mod transfer_restrictions; mod voting; diff --git a/contracts/assetsup/src/tokenization.rs b/contracts/assetsup/src/tokenization.rs index 7402eb8d..b3c26e24 100644 --- a/contracts/assetsup/src/tokenization.rs +++ b/contracts/assetsup/src/tokenization.rs @@ -125,9 +125,15 @@ pub fn mint_tokens( return Err(Error::Unauthorized); } - // Update total supply - tokenized_asset.total_supply += amount; - tokenized_asset.tokens_in_circulation += amount; + let new_total_supply = tokenized_asset + .total_supply + .checked_add(amount) + .ok_or(Error::MaxSupplyExceeded)?; + tokenized_asset.total_supply = new_total_supply; + tokenized_asset.tokens_in_circulation = tokenized_asset + .tokens_in_circulation + .checked_add(amount) + .ok_or(Error::MaxSupplyExceeded)?; // Update tokenizer's ownership let holder_key = TokenDataKey::TokenHolder(asset_id, minter.clone()); @@ -244,6 +250,10 @@ pub fn transfer_tokens( let key = TokenDataKey::TokenizedAsset(asset_id); let tokenized_asset: TokenizedAsset = store.get(&key).ok_or(Error::AssetNotTokenized)?; + if from == to { + return Err(Error::InvalidTokenTransfer); + } + // Check if from address has locked tokens let lock_key = TokenDataKey::TokenLockedUntil(asset_id, from.clone()); if let Some(lock_time) = store.get::<_, u64>(&lock_key) { 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/errors.rs b/contracts/multisig_transfer/src/errors.rs index 6e06e761..40fdf23d 100644 --- a/contracts/multisig_transfer/src/errors.rs +++ b/contracts/multisig_transfer/src/errors.rs @@ -27,5 +27,7 @@ pub enum MultiSigError { NotEnoughApprovals = 16, ExecuteTooEarly = 17, - RegistryCallFailed = 18, + AssetsUpContractNotConfigured = 18, + AssetTransferFailed = 19, + RegistryCallFailed = 20, } diff --git a/contracts/multisig_transfer/src/lib.rs b/contracts/multisig_transfer/src/lib.rs index 5cb3fc17..7b22c443 100644 --- a/contracts/multisig_transfer/src/lib.rs +++ b/contracts/multisig_transfer/src/lib.rs @@ -13,6 +13,7 @@ mod utils; use approvals::*; use errors::MultiSigError; +use opsce::AssetsUpClient; use types::{ApprovalRule, RequestStatus, TransferRequest}; #[contract] @@ -49,6 +50,16 @@ impl MultiSigTransferContract { Ok(()) } + pub fn configure_assetsup_contract( + e: Env, + caller: Address, + assetsup_contract_id: Address, + ) -> Result<(), MultiSigError> { + utils::require_admin(&e, &caller)?; + storage::set_assetsup_contract(&e, &assetsup_contract_id); + Ok(()) + } + // ---------------------------- // Create transfer request // ---------------------------- @@ -58,6 +69,8 @@ impl MultiSigTransferContract { caller: Address, asset_id: BytesN<32>, asset_category: BytesN<32>, + token_id: u64, + amount: i128, new_owner: Address, notes_hash: BytesN<32>, expires_at: u64, @@ -104,6 +117,8 @@ impl MultiSigTransferContract { request_id, asset_id: asset_id.clone(), asset_category: asset_category.clone(), + token_id, + amount, current_owner: caller.clone(), // placeholder until registry owner wired new_owner: new_owner.clone(), initiator: caller.clone(), @@ -267,6 +282,14 @@ impl MultiSigTransferContract { } } + let assetsup_contract = storage::get_assetsup_contract(&e) + .ok_or(MultiSigError::AssetsUpContractNotConfigured)?; + + let assetsup_client = AssetsUpClient::new(&e, &assetsup_contract); + assetsup_client + .transfer_tokens(req.token_id, req.current_owner.clone(), req.new_owner.clone(), req.amount) + .map_err(|_| MultiSigError::AssetTransferFailed)?; + // registry transfer ownership registry::transfer_owner(&e, ®istry_addr, &req.asset_id, &req.new_owner)?; @@ -361,3 +384,6 @@ impl MultiSigTransferContract { Ok(rule.approvers) } } + +#[cfg(test)] +mod tests; diff --git a/contracts/multisig_transfer/src/storage.rs b/contracts/multisig_transfer/src/storage.rs index 9cb5cad2..119e1b28 100644 --- a/contracts/multisig_transfer/src/storage.rs +++ b/contracts/multisig_transfer/src/storage.rs @@ -6,6 +6,7 @@ use crate::types::{ApprovalRule, TransferRequest}; pub enum DataKey { Admin, // Address AssetRegistry, // Address + AssetsUpContract, // Address NextRequestId, // u64 Requests, // Map Rules, // Map, ApprovalRule> @@ -34,6 +35,16 @@ pub fn set_registry(e: &Env, registry: &Address) { .set(&DataKey::AssetRegistry, registry); } +pub fn get_assetsup_contract(e: &Env) -> Option
{ + e.storage().persistent().get(&DataKey::AssetsUpContract) +} + +pub fn set_assetsup_contract(e: &Env, contract_id: &Address) { + e.storage() + .persistent() + .set(&DataKey::AssetsUpContract, contract_id); +} + pub fn next_request_id(e: &Env) -> u64 { e.storage() .persistent() diff --git a/contracts/multisig_transfer/src/tests.rs b/contracts/multisig_transfer/src/tests.rs new file mode 100644 index 00000000..b7f57c8d --- /dev/null +++ b/contracts/multisig_transfer/src/tests.rs @@ -0,0 +1,139 @@ +use super::*; +use soroban_sdk::{contract, contracterror, contractimpl, symbol_short, testutils::Address as _, Address, BytesN, Env, Vec}; + +#[contract] +pub struct FakeAssetsUpContract; + +#[contractimpl] +impl FakeAssetsUpContract { + pub fn transfer_tokens( + env: Env, + asset_id: u64, + from: Address, + to: Address, + amount: i128, + ) -> Result<(), FakeAssetsUpError> { + from.require_auth(); + env.events().publish((symbol_short!("asset_xfer"),), (asset_id, from.clone(), to, amount)); + Ok(()) + } +} + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum FakeAssetsUpError { + TransferFailed = 1, +} + +#[contract] +pub struct FailingAssetsUpContract; + +#[contractimpl] +impl FailingAssetsUpContract { + pub fn transfer_tokens( + _env: Env, + _asset_id: u64, + _from: Address, + _to: Address, + _amount: i128, + ) -> Result<(), FakeAssetsUpError> { + Err(FakeAssetsUpError::TransferFailed) + } +} + +fn setup_multisig_and_contracts(env: &Env) -> (MultisigTransferContractClient<'_>, Address) { + env.mock_all_auths(); + let contract_id = env.register(MultiSigTransferContract, ()); + let client = MultisigTransferContractClient::new(env, &contract_id); + let admin = Address::generate(env); + let asset_registry = Address::generate(env); + + client.initialize(&admin, &asset_registry); + (client, admin) +} + +#[test] +fn test_execute_transfer_invokes_assetsup_transfer_tokens() { + let env = Env::default(); + let (client, admin) = setup_multisig_and_contracts(&env); + let fake_assetsup_id = env.register(FakeAssetsUpContract, ()); + + client.configure_assetsup_contract(&admin, &fake_assetsup_id); + let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let asset_id = BytesN::from_array(&env, &[1u8; 32]); + let asset_category = BytesN::from_array(&env, &[2u8; 32]); + let notes_hash = BytesN::from_array(&env, &[3u8; 32]); + let token_id = 1u64; + let amount = 1000i128; + + let rule = ApprovalRule { + category: asset_category.clone(), + required_approvals: 0, + approvers: Vec::new(&env), + approval_timeout_secs: 3600, + auto_approve: true, + priority: 0, + }; + client.configure_approval_rule(&admin, rule); + + let request_id = client + .create_transfer_request( + &owner, + &asset_id, + &asset_category, + &token_id, + &amount, + &new_owner, + ¬es_hash, + &(env.ledger().timestamp() + 3600), + &None, + ); + + client.execute_transfer(&owner, &request_id); + + let executed_request = client.get_request(&request_id).unwrap(); + assert_eq!(executed_request.status, RequestStatus::Executed); +} + +#[test] +fn test_execute_transfer_reverts_when_assetsup_transfer_fails() { + let env = Env::default(); + let (client, admin) = setup_multisig_and_contracts(&env); + let fake_assetsup_id = env.register(FailingAssetsUpContract, ()); + + client.configure_assetsup_contract(&admin, &fake_assetsup_id); + let owner = Address::generate(&env); + let new_owner = Address::generate(&env); + let asset_id = BytesN::from_array(&env, &[4u8; 32]); + let asset_category = BytesN::from_array(&env, &[5u8; 32]); + let notes_hash = BytesN::from_array(&env, &[6u8; 32]); + let token_id = 1u64; + let amount = 500i128; + + let rule = ApprovalRule { + category: asset_category.clone(), + required_approvals: 0, + approvers: Vec::new(&env), + approval_timeout_secs: 3600, + auto_approve: true, + priority: 0, + }; + client.configure_approval_rule(&admin, rule); + + let request_id = client + .create_transfer_request( + &owner, + &asset_id, + &asset_category, + &token_id, + &amount, + &new_owner, + ¬es_hash, + &(env.ledger().timestamp() + 3600), + &None, + ); + + let execution_result = client.execute_transfer(&owner, &request_id); + assert!(execution_result.is_err()); +} diff --git a/contracts/multisig_transfer/src/types.rs b/contracts/multisig_transfer/src/types.rs index ff46442e..766f6546 100644 --- a/contracts/multisig_transfer/src/types.rs +++ b/contracts/multisig_transfer/src/types.rs @@ -17,6 +17,8 @@ pub struct TransferRequest { pub asset_id: BytesN<32>, pub asset_category: BytesN<32>, + pub token_id: u64, + pub amount: i128, pub current_owner: Address, pub new_owner: Address, diff --git a/contracts/opsce/src/cross_contract.rs b/contracts/opsce/src/cross_contract.rs new file mode 100644 index 00000000..63b5c337 --- /dev/null +++ b/contracts/opsce/src/cross_contract.rs @@ -0,0 +1,14 @@ +use soroban_sdk::{contractclient, Address, RawVal}; + +#[contractclient] +pub struct AssetsUpClient; + +impl AssetsUpClient { + pub fn transfer_tokens( + &self, + asset_id: u64, + from: Address, + to: Address, + amount: i128, + ) -> Result<(), RawVal>; +} diff --git a/contracts/opsce/src/lib.rs b/contracts/opsce/src/lib.rs index 2ea4133e..b60febf6 100644 --- a/contracts/opsce/src/lib.rs +++ b/contracts/opsce/src/lib.rs @@ -1,5 +1,8 @@ #![no_std] +pub mod cross_contract; + +pub use cross_contract::AssetsUpClient; use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; pub mod error; diff --git a/contracts/opsce/src/tokenization_tests.rs b/contracts/opsce/src/tokenization_tests.rs new file mode 100644 index 00000000..6b53c6d9 --- /dev/null +++ b/contracts/opsce/src/tokenization_tests.rs @@ -0,0 +1,162 @@ +use crate::{AssetUpContract, AssetUpContractClient, AssetType, Error, TokenMetadata}; +use soroban_sdk::{testutils::Address as _, Address, Env, String, Vec}; + +fn make_metadata(env: &Env) -> TokenMetadata { + TokenMetadata { + name: String::from_str(env, "AssetToken"), + description: String::from_str(env, "Tokenized asset for testing"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: Vec::new(env), + } +} + +#[test] +fn test_tokenize_asset_success_and_retokenization_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AssetUpContract, ()); + let client = AssetUpContractClient::new(&env, &contract_id); + let admin = Address::random(&env); + let tokenizer = Address::random(&env); + + client.initialize(&admin); + + let asset_id = 1u64; + let symbol = String::from_str(&env, "TEST"); + let metadata = make_metadata(&env); + + let tokenized = client + .tokenize_asset(&asset_id, &symbol, &1_000i128, &8u32, &10i128, &tokenizer, &metadata) + .unwrap(); + + assert_eq!(tokenized.asset_id, asset_id); + assert_eq!(tokenized.total_supply, 1_000i128); + assert_eq!(tokenized.tokenizer, tokenizer); + + let retry = client.tokenize_asset(&asset_id, &symbol, &1_000i128, &8u32, &10i128, &tokenizer, &metadata); + assert_eq!(retry, Err(Error::AssetAlreadyTokenized)); +} + +#[test] +fn test_mint_tokens_success_and_max_supply_exceeded_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AssetUpContract, ()); + let client = AssetUpContractClient::new(&env, &contract_id); + let admin = Address::random(&env); + let tokenizer = Address::random(&env); + + client.initialize(&admin); + + let asset_id = 2u64; + let symbol = String::from_str(&env, "MINT"); + let metadata = make_metadata(&env); + + client + .tokenize_asset(&asset_id, &symbol, &1_000i128, &8u32, &10i128, &tokenizer, &metadata) + .unwrap(); + + let minted = client.mint_tokens(&asset_id, &500i128, &tokenizer).unwrap(); + assert_eq!(minted.total_supply, 1_500i128); + + let overflow_amount = i128::MAX - 1_500i128 + 1; + let result = client.mint_tokens(&asset_id, &overflow_amount, &tokenizer); + assert_eq!(result, Err(Error::MaxSupplyExceeded)); +} + +#[test] +fn test_burn_tokens_success_and_insufficient_balance_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AssetUpContract, ()); + let client = AssetUpContractClient::new(&env, &contract_id); + let admin = Address::random(&env); + let tokenizer = Address::random(&env); + + client.initialize(&admin); + + let asset_id = 3u64; + let symbol = String::from_str(&env, "BURN"); + let metadata = make_metadata(&env); + + client + .tokenize_asset(&asset_id, &symbol, &1_000i128, &8u32, &10i128, &tokenizer, &metadata) + .unwrap(); + + let burned = client.burn_tokens(&asset_id, &200i128, &tokenizer).unwrap(); + assert_eq!(burned.total_supply, 800i128); + + let result = client.burn_tokens(&asset_id, &1_000i128, &tokenizer); + assert_eq!(result, Err(Error::InsufficientBalance)); +} + +#[test] +fn test_transfer_tokens_success_insufficient_balance_and_self_transfer_prevented() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AssetUpContract, ()); + let client = AssetUpContractClient::new(&env, &contract_id); + let admin = Address::random(&env); + let tokenizer = Address::random(&env); + let holder = Address::random(&env); + + client.initialize(&admin); + + let asset_id = 4u64; + let symbol = String::from_str(&env, "XFER"); + let metadata = make_metadata(&env); + + client + .tokenize_asset(&asset_id, &symbol, &1_000i128, &8u32, &10i128, &tokenizer, &metadata) + .unwrap(); + + client + .transfer_tokens(&asset_id, &tokenizer, &holder, &300i128) + .unwrap(); + + let balance = client.get_token_balance(&asset_id, &holder).unwrap(); + assert_eq!(balance, 300i128); + + let fail_balance = client.transfer_tokens(&asset_id, &holder, &tokenizer, &500i128); + assert_eq!(fail_balance, Err(Error::InsufficientBalance)); + + let self_transfer = client.transfer_tokens(&asset_id, &holder, &holder, &100i128); + assert_eq!(self_transfer, Err(Error::InvalidTokenTransfer)); +} + +#[test] +fn test_get_token_balance_returns_correct_value_after_mints_and_transfers() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(AssetUpContract, ()); + let client = AssetUpContractClient::new(&env, &contract_id); + let admin = Address::random(&env); + let tokenizer = Address::random(&env); + let recipient = Address::random(&env); + + client.initialize(&admin); + + let asset_id = 5u64; + let symbol = String::from_str(&env, "BAL"); + let metadata = make_metadata(&env); + + client + .tokenize_asset(&asset_id, &symbol, &1_000i128, &8u32, &10i128, &tokenizer, &metadata) + .unwrap(); + + client.mint_tokens(&asset_id, &200i128, &tokenizer).unwrap(); + client.transfer_tokens(&asset_id, &tokenizer, &recipient, &250i128).unwrap(); + client.transfer_tokens(&asset_id, &recipient, &tokenizer, &50i128).unwrap(); + + assert_eq!(client.get_token_balance(&asset_id, &tokenizer).unwrap(), 1_000i128 - 250i128 + 50i128 + 200i128); + assert_eq!(client.get_token_balance(&asset_id, &recipient).unwrap(), 200i128); +}