diff --git a/contracts/assetsup/src/detokenization.rs b/contracts/assetsup/src/detokenization.rs new file mode 100644 index 0000000..888aea1 --- /dev/null +++ b/contracts/assetsup/src/detokenization.rs @@ -0,0 +1,162 @@ +use crate::error::Error; +use crate::types::{ContractEvent, DetokenizationProposal, TokenDataKey, TokenizedAsset}; +use crate::voting; +use soroban_sdk::{Address, BigInt, Env}; + +/// Propose detokenization (requires voting) +pub fn propose_detokenization( + env: &Env, + asset_id: u64, + proposer: Address, +) -> Result { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Check if proposal already exists + let proposal_key = TokenDataKey::DetokenizationProposal(asset_id); + if store.has(&proposal_key) { + if let Some(Some(DetokenizationProposal::Active { .. })) = store.get(&proposal_key) { + return Err(Error::DetokenizationAlreadyProposed); + } + } + + // Create proposal + let proposal_id = asset_id; // Use asset_id as proposal_id for simplicity + let timestamp = env.ledger().timestamp(); + + let proposal = DetokenizationProposal::Active { + proposal_id, + proposer, + created_at: timestamp, + }; + + store.set(&proposal_key, &proposal); + + Ok(proposal_id) +} + +/// Execute detokenization if vote passed +pub fn execute_detokenization( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result<(), Error> { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Check if proposal is active + let proposal_key = TokenDataKey::DetokenizationProposal(asset_id); + match store.get(&proposal_key) { + Some(Some(DetokenizationProposal::Active { .. })) => { + // Continue + } + _ => { + return Err(Error::InvalidProposal); + } + } + + // Check if proposal passed (>50% votes) + let passed = voting::proposal_passed(env, asset_id, proposal_id)?; + if !passed { + return Err(Error::DetokenizationNotApproved); + } + + // Update proposal to executed + let timestamp = env.ledger().timestamp(); + let executed_proposal = DetokenizationProposal::Executed { + proposal_id, + executed_at: timestamp, + }; + store.set(&proposal_key, &executed_proposal); + + // Clear all votes + voting::clear_proposal_votes(env, asset_id, proposal_id)?; + + // Emit event + env.events().publish( + ("detokenization", "asset_detokenized"), + ContractEvent::AssetDetokenized { + asset_id, + proposal_id, + }, + ); + + Ok(()) +} + +/// Reject detokenization proposal +pub fn reject_detokenization(env: &Env, asset_id: u64) -> Result<(), Error> { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Get proposal + let proposal_key = TokenDataKey::DetokenizationProposal(asset_id); + let proposal: DetokenizationProposal = store + .get(&proposal_key) + .ok_or(Error::InvalidProposal)? + .ok_or(Error::InvalidProposal)?; + + match proposal { + DetokenizationProposal::Active { + proposal_id, + .. + } => { + // Mark as rejected + let timestamp = env.ledger().timestamp(); + let rejected_proposal = DetokenizationProposal::Rejected { + proposal_id, + rejected_at: timestamp, + }; + store.set(&proposal_key, &rejected_proposal); + + // Clear votes + voting::clear_proposal_votes(env, asset_id, proposal_id)?; + + Ok(()) + } + _ => Err(Error::InvalidProposal), + } +} + +/// Get detokenization proposal status +pub fn get_detokenization_proposal( + env: &Env, + asset_id: u64, +) -> Result { + let store = env.storage().persistent(); + + let key = TokenDataKey::DetokenizationProposal(asset_id); + store + .get(&key) + .ok_or(Error::InvalidProposal)? + .ok_or(Error::InvalidProposal) +} + +/// Check if detokenization is in progress +pub fn is_detokenization_active(env: &Env, asset_id: u64) -> Result { + let store = env.storage().persistent(); + + let key = TokenDataKey::DetokenizationProposal(asset_id); + match store.get(&key) { + Some(Some(DetokenizationProposal::Active { .. })) => Ok(true), + _ => Ok(false), + } +} diff --git a/contracts/assetsup/src/dividends.rs b/contracts/assetsup/src/dividends.rs index 2a221c3..4e8d3aa 100644 --- a/contracts/assetsup/src/dividends.rs +++ b/contracts/assetsup/src/dividends.rs @@ -1,86 +1,164 @@ -use soroban_sdk::{contractimpl, Address, BigInt, Env}; - -use crate::types::{OwnershipRecord, TokenizedAsset}; - -pub struct DividendContract; - -#[contractimpl] -impl DividendContract { - /// Distribute dividends proportionally to all holders - /// amount: total dividend to distribute - pub fn distribute_dividend(env: Env, asset_id: u64, amount: BigInt) { - // Get tokenized asset - let tokenized_asset: TokenizedAsset = env - .storage() - .get((b"asset", asset_id)) - .expect("Asset not found") - .unwrap(); - - // Iterate all ownership records (minimal V1: assume keys stored separately) - // This assumes we have a helper to enumerate owners; otherwise we can store owners list - let owners: Vec
= env - .storage() - .get((b"owners_list", asset_id)) - .unwrap_or(Some(Vec::new())) - .unwrap(); - - for owner in owners.iter() { - let mut ownership: OwnershipRecord = env - .storage() - .get((b"ownership", asset_id, owner)) - .unwrap() - .unwrap(); - - // proportion = owner balance / total supply - let proportion = &ownership.balance * &amount / &tokenized_asset.total_supply; - - // Update unclaimed dividend - ownership.unclaimed_dividends = - &ownership.unclaimed_dividends + &proportion; - - env.storage() - .set((b"ownership", asset_id, owner), &ownership); - } +use crate::error::Error; +use crate::types::{ContractEvent, OwnershipRecord, TokenDataKey, TokenizedAsset}; +use soroban_sdk::{Address, BigInt, Env}; + +/// Distribute dividends proportionally to all token holders +pub fn distribute_dividends( + env: &Env, + asset_id: u64, + total_amount: BigInt, +) -> Result<(), Error> { + if total_amount <= BigInt::from_i128(env, 0) { + return Err(Error::InvalidDividendAmount); + } + + let store = env.storage().persistent(); + + // Get tokenized asset + let key = TokenDataKey::TokenizedAsset(asset_id); + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + if !tokenized_asset.revenue_sharing_enabled { + return Err(Error::InvalidDividendAmount); + } + + // Get all token holders + let holders_key = TokenDataKey::TokenHoldersList(asset_id); + let holders = store + .get(&holders_key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Distribute proportionally to each holder + for holder in holders.iter() { + let holder_key = TokenDataKey::TokenHolder(asset_id, holder.clone()); + let mut ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; + + // Calculate proportional dividend: (balance / total_supply) * total_amount + let proportion = (&ownership.balance * &total_amount) / &tokenized_asset.total_supply; + + // Add to unclaimed dividends + ownership.unclaimed_dividends = &ownership.unclaimed_dividends + &proportion; + + store.set(&holder_key, &ownership); + } + + // Emit event + env.events().publish( + ("dividend", "distributed"), + ContractEvent::DividendDistributed { + asset_id, + total_amount, + holder_count: holders.len() as u32, + }, + ); + + Ok(()) +} + +/// Claim unclaimed dividends +pub fn claim_dividends( + env: &Env, + asset_id: u64, + holder: Address, +) -> Result { + let store = env.storage().persistent(); + + // Get tokenized asset + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Get holder's ownership record + let holder_key = TokenDataKey::TokenHolder(asset_id, holder.clone()); + let mut ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; + + // Get unclaimed amount + let unclaimed = ownership.unclaimed_dividends.clone(); + + if unclaimed <= BigInt::from_i128(env, 0) { + return Err(Error::NoDividendsToClaim); } - /// Cast vote on an asset proposal - /// proposal_id is a u64 identifier for the proposal - pub fn cast_vote(env: Env, asset_id: u64, proposal_id: u64, voter: Address) { - // Get ownership - let ownership: OwnershipRecord = env - .storage() - .get((b"ownership", asset_id, &voter)) - .unwrap() - .unwrap(); - - // Minimal threshold: voter must have >0 tokens - if ownership.balance <= BigInt::from_i128(&env, 0) { - panic!("Not enough tokens to vote"); - } - - // Check if voter already voted - let mut votes: Vec
= env - .storage() - .get((b"votes", asset_id, proposal_id)) - .unwrap_or(Some(Vec::new())) - .unwrap(); - - if votes.contains(&voter) { - panic!("Voter already voted"); - } - - votes.push(voter.clone()); - env.storage().set((b"votes", asset_id, proposal_id), &votes); - - // Store voting power weighted by token balance - let mut tally: BigInt = env - .storage() - .get((b"vote_tally", asset_id, proposal_id)) - .unwrap_or(Some(BigInt::from_i128(&env, 0))) - .unwrap(); - - tally = tally + &ownership.balance; - env.storage() - .set((b"vote_tally", asset_id, proposal_id), &tally); + // Clear unclaimed dividends + ownership.unclaimed_dividends = BigInt::from_i128(env, 0); + store.set(&holder_key, &ownership); + + // Emit event + env.events().publish( + ("dividend", "claimed"), + ContractEvent::DividendClaimed { + asset_id, + holder, + amount: unclaimed.clone(), + }, + ); + + Ok(unclaimed) +} + +/// Get unclaimed dividends for a holder +pub fn get_unclaimed_dividends( + env: &Env, + asset_id: u64, + holder: Address, +) -> Result { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Get holder's ownership record + let holder_key = TokenDataKey::TokenHolder(asset_id, holder); + match store.get(&holder_key) { + Some(Some(ownership)) => Ok(ownership.unclaimed_dividends), + _ => Ok(BigInt::from_i128(env, 0)), } } + +/// Enable revenue sharing for an asset +pub fn enable_revenue_sharing(env: &Env, asset_id: u64) -> Result<(), Error> { + let store = env.storage().persistent(); + + let key = TokenDataKey::TokenizedAsset(asset_id); + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + tokenized_asset.revenue_sharing_enabled = true; + store.set(&key, &tokenized_asset); + + Ok(()) +} + +/// Disable revenue sharing for an asset +pub fn disable_revenue_sharing(env: &Env, asset_id: u64) -> Result<(), Error> { + let store = env.storage().persistent(); + + let key = TokenDataKey::TokenizedAsset(asset_id); + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + tokenized_asset.revenue_sharing_enabled = false; + store.set(&key, &tokenized_asset); + + Ok(()) +} diff --git a/contracts/assetsup/src/error.rs b/contracts/assetsup/src/error.rs index 40e3508..6c9bbc0 100644 --- a/contracts/assetsup/src/error.rs +++ b/contracts/assetsup/src/error.rs @@ -13,12 +13,37 @@ pub enum Error { SubscriptionAlreadyExists = 7, Unauthorized = 8, InvalidPayment = 9, - ContractPaused = 10, - ContractNotInitialized = 11, - InvalidAssetName = 12, - InvalidPurchaseValue = 13, - InvalidMetadataUri = 14, - InvalidOwnerAddress = 15, + // Tokenization errors + AssetAlreadyTokenized = 10, + AssetNotTokenized = 11, + InvalidTokenSupply = 12, + InvalidTokenDecimals = 13, + InsufficientBalance = 14, + InsufficientLockedTokens = 15, + TokensAreLocked = 16, + TransferRestrictionFailed = 17, + NotWhitelisted = 18, + AccreditedInvestorRequired = 19, + GeographicRestriction = 20, + // Voting errors + InsufficientVotingPower = 21, + AlreadyVoted = 22, + ProposalNotFound = 23, + InvalidProposal = 24, + VotingPeriodEnded = 25, + // Dividend errors + NoDividendsToClaim = 26, + InvalidDividendAmount = 27, + // Detokenization errors + DetokenizationNotApproved = 28, + DetokenizationAlreadyProposed = 29, + // Valuation errors + InvalidValuation = 30, + // Holder enumeration errors + HolderNotFound = 31, + // Math errors + MathOverflow = 32, + MathUnderflow = 33, } pub fn handle_error(env: &Env, error: Error) -> ! { diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index 91d52f6..f52767a 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use crate::error::{Error, handle_error}; -use soroban_sdk::{Address, BytesN, Env, String, Vec, contract, contractimpl, contracttype, symbol_short}; +use soroban_sdk::{Address, BigInt, BytesN, Env, String, Vec, contract, contractimpl, contracttype, symbol_short}; pub(crate) mod asset; pub(crate) mod audit; @@ -420,6 +420,304 @@ impl AssetUpContract { Ok(()) } + pub fn get_asset_audit_logs( + env: Env, + asset_id: BytesN<32>, + ) -> Result, Error> { + Ok(audit::get_asset_log(&env, &asset_id)) + } + + // ===================== + // Tokenization Functions + // ===================== + + /// Tokenize an asset with full supply to tokenizer + pub fn tokenize_asset( + env: Env, + asset_id: u64, + symbol: String, + total_supply: BigInt, + decimals: u32, + min_voting_threshold: BigInt, + tokenizer: Address, + name: String, + description: String, + asset_type: AssetType, + ) -> Result { + tokenizer.require_auth(); + + let metadata = TokenMetadata { + name, + description, + asset_type, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: Vec::new(&env), + }; + + tokenization::tokenize_asset( + &env, + asset_id, + symbol, + total_supply, + decimals, + min_voting_threshold, + tokenizer, + metadata, + ) + } + + /// Mint additional tokens (only tokenizer can call) + pub fn mint_tokens( + env: Env, + asset_id: u64, + amount: BigInt, + minter: Address, + ) -> Result { + minter.require_auth(); + tokenization::mint_tokens(&env, asset_id, amount, minter) + } + + /// Burn tokens (only tokenizer can call) + pub fn burn_tokens( + env: Env, + asset_id: u64, + amount: BigInt, + burner: Address, + ) -> Result { + burner.require_auth(); + tokenization::burn_tokens(&env, asset_id, amount, burner) + } + + /// Transfer tokens from one address to another + pub fn transfer_tokens( + env: Env, + asset_id: u64, + from: Address, + to: Address, + amount: BigInt, + ) -> Result<(), Error> { + from.require_auth(); + + // Validate transfer restrictions + transfer_restrictions::validate_transfer(&env, asset_id, from.clone(), to.clone())?; + + tokenization::transfer_tokens(&env, asset_id, from, to, amount) + } + + /// Get token balance for an address + pub fn get_token_balance(env: Env, asset_id: u64, holder: Address) -> Result { + tokenization::get_token_balance(&env, asset_id, holder) + } + + /// Get all token holders for an asset + pub fn get_token_holders(env: Env, asset_id: u64) -> Result, Error> { + tokenization::get_token_holders(&env, asset_id) + } + + /// Lock tokens until timestamp + pub fn lock_tokens( + env: Env, + asset_id: u64, + holder: Address, + until_timestamp: u64, + ) -> Result<(), Error> { + tokenization::lock_tokens(&env, asset_id, holder, until_timestamp) + } + + /// Unlock tokens + pub fn unlock_tokens(env: Env, asset_id: u64, holder: Address) -> Result<(), Error> { + tokenization::unlock_tokens(&env, asset_id, holder) + } + + /// Get ownership percentage for a holder (in basis points) + pub fn get_ownership_percentage( + env: Env, + asset_id: u64, + holder: Address, + ) -> Result { + tokenization::calculate_ownership_percentage(&env, asset_id, holder) + } + + /// Get tokenized asset details + pub fn get_tokenized_asset(env: Env, asset_id: u64) -> Result { + tokenization::get_tokenized_asset(&env, asset_id) + } + + /// Update asset valuation + pub fn update_valuation( + env: Env, + asset_id: u64, + new_valuation: BigInt, + ) -> Result<(), Error> { + tokenization::update_valuation(&env, asset_id, new_valuation) + } + + // ===================== + // Dividend Functions + // ===================== + + /// Distribute dividends proportionally to all holders + pub fn distribute_dividends( + env: Env, + asset_id: u64, + total_amount: BigInt, + ) -> Result<(), Error> { + dividends::distribute_dividends(&env, asset_id, total_amount) + } + + /// Claim unclaimed dividends + pub fn claim_dividends(env: Env, asset_id: u64, holder: Address) -> Result { + holder.require_auth(); + dividends::claim_dividends(&env, asset_id, holder) + } + + /// Get unclaimed dividends for a holder + pub fn get_unclaimed_dividends( + env: Env, + asset_id: u64, + holder: Address, + ) -> Result { + dividends::get_unclaimed_dividends(&env, asset_id, holder) + } + + /// Enable revenue sharing for an asset + pub fn enable_revenue_sharing(env: Env, asset_id: u64) -> Result<(), Error> { + dividends::enable_revenue_sharing(&env, asset_id) + } + + /// Disable revenue sharing for an asset + pub fn disable_revenue_sharing(env: Env, asset_id: u64) -> Result<(), Error> { + dividends::disable_revenue_sharing(&env, asset_id) + } + + // ===================== + // Voting Functions + // ===================== + + /// Cast a vote on a proposal + pub fn cast_vote( + env: Env, + asset_id: u64, + proposal_id: u64, + voter: Address, + ) -> Result<(), Error> { + voter.require_auth(); + voting::cast_vote(&env, asset_id, proposal_id, voter) + } + + /// Get vote tally for a proposal + pub fn get_vote_tally( + env: Env, + asset_id: u64, + proposal_id: u64, + ) -> Result { + voting::get_vote_tally(&env, asset_id, proposal_id) + } + + /// Check if an address has voted + pub fn has_voted( + env: Env, + asset_id: u64, + proposal_id: u64, + voter: Address, + ) -> Result { + voting::has_voted(&env, asset_id, proposal_id, voter) + } + + /// Check if proposal passed + pub fn proposal_passed( + env: Env, + asset_id: u64, + proposal_id: u64, + ) -> Result { + voting::proposal_passed(&env, asset_id, proposal_id) + } + + // ===================== + // Transfer Restrictions + // ===================== + + /// Set transfer restrictions + pub fn set_transfer_restriction( + env: Env, + asset_id: u64, + require_accredited: bool, + ) -> Result<(), Error> { + transfer_restrictions::set_transfer_restriction( + &env, + asset_id, + TransferRestriction { + require_accredited, + geographic_allowed: Vec::new(&env), + }, + ) + } + + /// 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) + } + + /// 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) + } + + /// 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) + } + + /// Get whitelist + pub fn get_whitelist(env: Env, asset_id: u64) -> Result, Error> { + transfer_restrictions::get_whitelist(&env, asset_id) + } + + // ===================== + // Detokenization + // ===================== + + /// Propose detokenization + pub fn propose_detokenization( + env: Env, + asset_id: u64, + proposer: Address, + ) -> Result { + proposer.require_auth(); + detokenization::propose_detokenization(&env, asset_id, proposer) + } + + /// Execute detokenization (if vote passed) + pub fn execute_detokenization( + env: Env, + asset_id: u64, + proposal_id: u64, + ) -> Result<(), Error> { + detokenization::execute_detokenization(&env, asset_id, proposal_id) + } + + /// Get detokenization proposal status + pub fn get_detokenization_proposal( + env: Env, + asset_id: u64, + ) -> Result { + detokenization::get_detokenization_proposal(&env, asset_id) + } + + /// Check if detokenization is active + pub fn is_detokenization_active(env: Env, asset_id: u64) -> Result { + detokenization::is_detokenization_active(&env, asset_id) + } +} + +mod tests; pub fn unpause_contract(env: Env) -> Result<(), Error> { let admin = Self::get_admin(env.clone())?; admin.require_auth(); diff --git a/contracts/assetsup/src/tests/detokenization_new.rs b/contracts/assetsup/src/tests/detokenization_new.rs new file mode 100644 index 0000000..17714c8 --- /dev/null +++ b/contracts/assetsup/src/tests/detokenization_new.rs @@ -0,0 +1,150 @@ +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{Address, BigInt, Env, String}; + +use crate::tokenization; +use crate::types::AssetType; +use crate::voting; +use crate::detokenization; + +fn setup_tokenized_asset(env: &Env, tokenizer: &Address) -> u64 { + let asset_id = 1000u64; + let _ = tokenization::tokenize_asset( + env, + asset_id, + String::from_str(env, "DETON"), + BigInt::from_i128(env, 1000), + 2, + BigInt::from_i128(env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(env, "Detokenization Test"), + description: String::from_str(env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(env), + }, + ); + asset_id +} + +#[test] +fn test_propose_detokenization() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); + + // Verify proposal exists + let proposal = detokenization::get_detokenization_proposal(&env, asset_id).ok(); + assert!(proposal.is_some()); +} + +#[test] +fn test_duplicate_proposal_prevention() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Propose once + detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); + + // Try to propose again + let result = detokenization::propose_detokenization(&env, asset_id, proposer); + assert!(result.is_err()); +} + +#[test] +fn test_detokenization_active_check() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Should not be active initially + let is_active = detokenization::is_detokenization_active(&env, asset_id).unwrap(); + assert!(!is_active); + + // Propose + detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); + + // Should be active now + let is_active = detokenization::is_detokenization_active(&env, asset_id).unwrap(); + assert!(is_active); +} + +#[test] +fn test_execute_detokenization_without_majority() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Propose + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer.clone()).unwrap(); + + // Try to execute without votes + let result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(result.is_err()); // Should fail - no majority +} + +#[test] +fn test_execute_detokenization_with_majority() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Propose + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); + + // Get >50% votes + // Tokenizer has 1000 tokens (100%), cast vote + voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); + + // Now execute - should succeed + let result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(result.is_ok()); + + // Should no longer be active + let is_active = detokenization::is_detokenization_active(&env, asset_id).unwrap(); + assert!(!is_active); +} + +#[test] +fn test_detokenization_majority_threshold() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let proposer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Transfer 400 to holder2 (40% < 50% threshold) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 400)) + .unwrap(); + + // Propose + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); + + // Only holder2 votes (40%) + voting::cast_vote(&env, asset_id, proposal_id, holder2).unwrap(); + + // Should fail execution + let result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(result.is_err()); + + // Now tokenizer also votes (100% total) + voting::cast_vote(&env, asset_id, proposal_id, tokenizer).unwrap(); + + // Should succeed + let result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(result.is_ok()); +} diff --git a/contracts/assetsup/src/tests/dividends_new.rs b/contracts/assetsup/src/tests/dividends_new.rs new file mode 100644 index 0000000..421d9d5 --- /dev/null +++ b/contracts/assetsup/src/tests/dividends_new.rs @@ -0,0 +1,138 @@ +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{Address, BigInt, Env, String}; + +use crate::tokenization; +use crate::types::AssetType; +use crate::dividends; + +fn setup_tokenized_asset(env: &Env, tokenizer: &Address) -> u64 { + let asset_id = 800u64; + let _ = tokenization::tokenize_asset( + env, + asset_id, + String::from_str(env, "DIV"), + BigInt::from_i128(env, 1000), + 2, + BigInt::from_i128(env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(env, "Dividend Test"), + description: String::from_str(env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(env), + }, + ); + asset_id +} + +#[test] +fn test_distribute_dividends_no_revenue_sharing() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Try to distribute without enabling revenue sharing + let result = dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 1000)); + assert!(result.is_err()); +} + +#[test] +fn test_distribute_dividends() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Enable revenue sharing + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + + // Transfer tokens to holder2 (500 out of 1000) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 500)) + .unwrap(); + + // Distribute 1000 tokens as dividend + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 1000)).unwrap(); + + // Tokenizer should have 500 unclaimed (50% of 1000) + let tokenizer_dividend = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + assert_eq!(tokenizer_dividend, BigInt::from_i128(&env, 500)); + + // Holder2 should have 500 unclaimed (50% of 1000) + let holder2_dividend = dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); + assert_eq!(holder2_dividend, BigInt::from_i128(&env, 500)); +} + +#[test] +fn test_claim_dividends() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Enable revenue sharing and distribute + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 500)).unwrap(); + + // Claim dividends + let claimed = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + + // Should have claimed full 500 + assert_eq!(claimed, BigInt::from_i128(&env, 500)); + + // Should have 0 unclaimed now + let remaining = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer).unwrap(); + assert_eq!(remaining, BigInt::from_i128(&env, 0)); +} + +#[test] +fn test_claim_dividends_insufficient() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Enable revenue sharing but don't distribute + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + + // Try to claim (should have 0) + let result = dividends::claim_dividends(&env, asset_id, tokenizer); + assert!(result.is_err()); +} + +#[test] +fn test_proportional_dividend_distribution() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let holder3 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Enable revenue sharing + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + + // Transfer: tokenizer 400, holder2 300, holder3 300 (total 1000) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 300)) + .unwrap(); + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), BigInt::from_i128(&env, 300)) + .unwrap(); + + // Distribute 1000 as dividend + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 1000)).unwrap(); + + // Verify proportional distribution + let t_div = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer).unwrap(); + let h2_div = dividends::get_unclaimed_dividends(&env, asset_id, holder2).unwrap(); + let h3_div = dividends::get_unclaimed_dividends(&env, asset_id, holder3).unwrap(); + + // Tokenizer: 400/1000 * 1000 = 400 + // Holder2: 300/1000 * 1000 = 300 + // Holder3: 300/1000 * 1000 = 300 + assert_eq!(t_div, BigInt::from_i128(&env, 400)); + assert_eq!(h2_div, BigInt::from_i128(&env, 300)); + assert_eq!(h3_div, BigInt::from_i128(&env, 300)); +} diff --git a/contracts/assetsup/src/tests/integration.rs b/contracts/assetsup/src/tests/integration.rs new file mode 100644 index 0000000..f111674 --- /dev/null +++ b/contracts/assetsup/src/tests/integration.rs @@ -0,0 +1,230 @@ +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{Address, BigInt, Env, String}; + +use crate::tokenization; +use crate::types::AssetType; +use crate::voting; +use crate::detokenization; +use crate::dividends; +use crate::transfer_restrictions; + +/// Integration test: Full tokenization workflow +#[test] +fn test_full_tokenization_workflow() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let holder3 = Address::random(&env); + + let asset_id = 5000u64; + + // Step 1: Tokenize asset + let tokenized = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "INTEGRATION"), + BigInt::from_i128(&env, 1000), + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Integration Test"), + description: String::from_str(&env, "Full workflow test"), + asset_type: AssetType::Physical, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + assert_eq!(tokenized.total_supply, BigInt::from_i128(&env, 1000)); + + // Step 2: Transfer tokens to other holders + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 400)) + .unwrap(); + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder3.clone(), BigInt::from_i128(&env, 200)) + .unwrap(); + + // Verify balances + let tokenizer_balance = tokenization::get_token_balance(&env, asset_id, tokenizer.clone()).unwrap(); + let holder2_balance = tokenization::get_token_balance(&env, asset_id, holder2.clone()).unwrap(); + let holder3_balance = tokenization::get_token_balance(&env, asset_id, holder3.clone()).unwrap(); + + assert_eq!(tokenizer_balance, BigInt::from_i128(&env, 400)); // 1000 - 400 - 200 + assert_eq!(holder2_balance, BigInt::from_i128(&env, 400)); + assert_eq!(holder3_balance, BigInt::from_i128(&env, 200)); + + // Step 3: Calculate ownership percentages + let tokenizer_pct = tokenization::calculate_ownership_percentage(&env, asset_id, tokenizer.clone()).unwrap(); + let holder2_pct = tokenization::calculate_ownership_percentage(&env, asset_id, holder2.clone()).unwrap(); + let holder3_pct = tokenization::calculate_ownership_percentage(&env, asset_id, holder3.clone()).unwrap(); + + // Percentages in basis points: 40% = 4000, 40% = 4000, 20% = 2000 + assert_eq!(tokenizer_pct, BigInt::from_i128(&env, 4000)); + assert_eq!(holder2_pct, BigInt::from_i128(&env, 4000)); + assert_eq!(holder3_pct, BigInt::from_i128(&env, 2000)); + + // Step 4: Set transfer restrictions + let restriction = crate::types::TransferRestriction { + require_accredited: false, + geographic_allowed: soroban_sdk::Vec::new(&env), + }; + transfer_restrictions::set_transfer_restriction(&env, asset_id, restriction).unwrap(); + + // Step 5: Enable dividends and distribute + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 1000)).unwrap(); + + // Verify dividend distribution + let tokenizer_div = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + let holder2_div = dividends::get_unclaimed_dividends(&env, asset_id, holder2.clone()).unwrap(); + let holder3_div = dividends::get_unclaimed_dividends(&env, asset_id, holder3.clone()).unwrap(); + + // Should be proportional to ownership + assert_eq!(tokenizer_div, BigInt::from_i128(&env, 400)); + assert_eq!(holder2_div, BigInt::from_i128(&env, 400)); + assert_eq!(holder3_div, BigInt::from_i128(&env, 200)); + + // Step 6: Claim dividends + let claimed = dividends::claim_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + assert_eq!(claimed, BigInt::from_i128(&env, 400)); + + // Step 7: Propose detokenization + let proposer = Address::random(&env); + let proposal_id = detokenization::propose_detokenization(&env, asset_id, proposer).unwrap(); + + // Step 8: Vote on detokenization + voting::cast_vote(&env, asset_id, proposal_id, tokenizer.clone()).unwrap(); + voting::cast_vote(&env, asset_id, proposal_id, holder2.clone()).unwrap(); + + // Step 9: Check vote tally + let tally = voting::get_vote_tally(&env, asset_id, proposal_id).unwrap(); + // 400 + 400 = 800 (> 500 which is 50%) + assert_eq!(tally, BigInt::from_i128(&env, 800)); + + // Step 10: Check if passed and execute + let passed = voting::proposal_passed(&env, asset_id, proposal_id).unwrap(); + assert!(passed); + + let execute_result = detokenization::execute_detokenization(&env, asset_id, proposal_id); + assert!(execute_result.is_ok()); +} + +/// Test: Multiple dividend distributions +#[test] +fn test_multiple_dividend_distributions() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = 5001u64; + + // Setup + tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "MULTIDIV"), + BigInt::from_i128(&env, 1000), + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Multiple Dividends"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Transfer 500 to holder2 + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 500)) + .unwrap(); + + // Enable dividends + dividends::enable_revenue_sharing(&env, asset_id).unwrap(); + + // First distribution + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 500)).unwrap(); + + // Second distribution + dividends::distribute_dividends(&env, asset_id, BigInt::from_i128(&env, 500)).unwrap(); + + // Should accumulate + let unclaimed = dividends::get_unclaimed_dividends(&env, asset_id, tokenizer.clone()).unwrap(); + assert_eq!(unclaimed, BigInt::from_i128(&env, 500)); // 250 + 250 + + let unclaimed2 = dividends::get_unclaimed_dividends(&env, asset_id, holder2).unwrap(); + assert_eq!(unclaimed2, BigInt::from_i128(&env, 500)); // 250 + 250 +} + +/// Test: Token locking and voting +#[test] +fn test_locked_tokens_with_voting() { + let env = Env::default(); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = 5002u64; + + // Setup + tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "LOCKV"), + BigInt::from_i128(&env, 1000), + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Locked Voting"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Transfer to holder2 + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 600)) + .unwrap(); + + // Lock holder2's tokens until timestamp 5000 + tokenization::lock_tokens(&env, asset_id, holder2.clone(), 5000).unwrap(); + + // Try to transfer (should fail) + let transfer_result = + tokenization::transfer_tokens(&env, asset_id, holder2.clone(), tokenizer.clone(), BigInt::from_i128(&env, 100)); + assert!(transfer_result.is_err()); + + // But can still vote + let vote_result = voting::cast_vote(&env, asset_id, 1, holder2.clone()); + assert!(vote_result.is_ok()); // Locked tokens still count for voting + + // Advance time past lock + env.ledger().with_mut(|li| { + li.timestamp = 6000; + }); + + // Unlock and try transfer again + tokenization::unlock_tokens(&env, asset_id, holder2.clone()).unwrap(); + let transfer_result = + tokenization::transfer_tokens(&env, asset_id, holder2.clone(), tokenizer.clone(), BigInt::from_i128(&env, 100)); + assert!(transfer_result.is_ok()); +} diff --git a/contracts/assetsup/src/tests/mod.rs b/contracts/assetsup/src/tests/mod.rs new file mode 100644 index 0000000..b419134 --- /dev/null +++ b/contracts/assetsup/src/tests/mod.rs @@ -0,0 +1,13 @@ +mod access_control; +mod asset; +mod branch; +mod initialize; +mod tokenize; +mod transfer; +mod types; +mod tokenization_new; +mod voting_new; +mod dividends_new; +mod transfer_restrictions_new; +mod detokenization_new; +mod integration; diff --git a/contracts/assetsup/src/tests/tokenization_new.rs b/contracts/assetsup/src/tests/tokenization_new.rs new file mode 100644 index 0000000..6d1ec94 --- /dev/null +++ b/contracts/assetsup/src/tests/tokenization_new.rs @@ -0,0 +1,298 @@ +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{Address, BigInt, Env, String}; + +use crate::types::{AssetType, TokenizedAsset}; +use crate::tokenization; + +fn make_asset_id(seed: u64) -> u64 { + seed +} + +#[test] +fn test_tokenize_asset() { + let env = Env::default(); + let tokenizer = Address::random(&env); + + let asset_id = make_asset_id(100); + let symbol = String::from_str(&env, "ASSET100"); + let total_supply = BigInt::from_i128(&env, 1000); + let decimals = 2u32; + let name = String::from_str(&env, "Test Asset"); + let description = String::from_str(&env, "Testing tokenization"); + let asset_type = AssetType::Digital; + let min_voting_threshold = BigInt::from_i128(&env, 100); + + let metadata = crate::types::TokenMetadata { + name, + description, + asset_type, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }; + + let tokenized_asset = tokenization::tokenize_asset( + &env, + asset_id, + symbol.clone(), + total_supply.clone(), + decimals, + min_voting_threshold, + tokenizer.clone(), + metadata, + ) + .unwrap(); + + assert_eq!(tokenized_asset.asset_id, asset_id); + assert_eq!(tokenized_asset.symbol, symbol); + assert_eq!(tokenized_asset.total_supply, total_supply); + assert_eq!(tokenized_asset.decimals, decimals); + assert_eq!(tokenized_asset.tokenizer, tokenizer); + assert_eq!(tokenized_asset.token_holders_count, 1); +} + +#[test] +fn test_tokenize_asset_invalid_supply() { + let env = Env::default(); + let tokenizer = Address::random(&env); + + let result = tokenization::tokenize_asset( + &env, + 100, + String::from_str(&env, "ASSET100"), + BigInt::from_i128(&env, 0), // Invalid supply + 2, + BigInt::from_i128(&env, 100), + tokenizer, + crate::types::TokenMetadata { + name: String::from_str(&env, "Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ); + + assert!(result.is_err()); +} + +#[test] +fn test_mint_tokens() { + let env = Env::default(); + let tokenizer = Address::random(&env); + + let asset_id = make_asset_id(200); + let initial_supply = BigInt::from_i128(&env, 500); + let mint_amount = BigInt::from_i128(&env, 200); + + // Tokenize first + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST200"), + initial_supply, + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Mint Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Mint tokens + let updated_asset = tokenization::mint_tokens(&env, asset_id, mint_amount.clone(), tokenizer.clone()).unwrap(); + + // Verify supply increased + assert_eq!(updated_asset.total_supply, &initial_supply + &mint_amount); + + // Verify tokenizer's balance updated + let balance = tokenization::get_token_balance(&env, asset_id, tokenizer).unwrap(); + assert_eq!(balance, &initial_supply + &mint_amount); +} + +#[test] +fn test_burn_tokens() { + let env = Env::default(); + let tokenizer = Address::random(&env); + + let asset_id = make_asset_id(300); + let initial_supply = BigInt::from_i128(&env, 1000); + let burn_amount = BigInt::from_i128(&env, 400); + + // Tokenize + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST300"), + initial_supply, + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Burn Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Burn tokens + let updated_asset = tokenization::burn_tokens(&env, asset_id, burn_amount.clone(), tokenizer.clone()).unwrap(); + + // Verify supply decreased + assert_eq!(updated_asset.total_supply, &BigInt::from_i128(&env, 1000) - &burn_amount); +} + +#[test] +fn test_transfer_tokens() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let recipient = Address::random(&env); + + let asset_id = make_asset_id(400); + let total_supply = BigInt::from_i128(&env, 1000); + let transfer_amount = BigInt::from_i128(&env, 300); + + // Tokenize + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST400"), + total_supply, + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Transfer Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Transfer + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), recipient.clone(), transfer_amount.clone()) + .unwrap(); + + // Verify balances + let tokenizer_balance = tokenization::get_token_balance(&env, asset_id, tokenizer).unwrap(); + let recipient_balance = tokenization::get_token_balance(&env, asset_id, recipient).unwrap(); + + assert_eq!(tokenizer_balance, &BigInt::from_i128(&env, 1000) - &transfer_amount); + assert_eq!(recipient_balance, transfer_amount); +} + +#[test] +fn test_lock_tokens() { + let env = Env::default(); + env.ledger().with_mut(|li| { + li.timestamp = 1000; + }); + + let tokenizer = Address::random(&env); + let asset_id = make_asset_id(500); + + // Tokenize + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST500"), + BigInt::from_i128(&env, 1000), + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Lock Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Lock tokens until timestamp 5000 + tokenization::lock_tokens(&env, asset_id, tokenizer.clone(), 5000).unwrap(); + + // Try to transfer (should fail) + let recipient = Address::random(&env); + let result = + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), recipient.clone(), BigInt::from_i128(&env, 100)); + + assert!(result.is_err()); + + // Advance time past lock period + env.ledger().with_mut(|li| { + li.timestamp = 6000; + }); + + // Transfer should now succeed + let result = tokenization::transfer_tokens(&env, asset_id, tokenizer, recipient, BigInt::from_i128(&env, 100)); + assert!(result.is_ok()); +} + +#[test] +fn test_ownership_percentage() { + let env = Env::default(); + let tokenizer = Address::random(&env); + + let asset_id = make_asset_id(600); + let total_supply = BigInt::from_i128(&env, 1000); + + // Tokenize + let _ = tokenization::tokenize_asset( + &env, + asset_id, + String::from_str(&env, "AST600"), + total_supply, + 2, + BigInt::from_i128(&env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(&env, "Percentage Test"), + description: String::from_str(&env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(&env), + }, + ) + .unwrap(); + + // Tokenizer should have 100% ownership + let percentage = tokenization::calculate_ownership_percentage(&env, asset_id, tokenizer).unwrap(); + + // 100% = 10000 basis points + assert_eq!(percentage, BigInt::from_i128(&env, 10000)); +} diff --git a/contracts/assetsup/src/tests/transfer_restrictions_new.rs b/contracts/assetsup/src/tests/transfer_restrictions_new.rs new file mode 100644 index 0000000..8516970 --- /dev/null +++ b/contracts/assetsup/src/tests/transfer_restrictions_new.rs @@ -0,0 +1,128 @@ +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{Address, BigInt, Env, String}; + +use crate::tokenization; +use crate::types::{AssetType, TransferRestriction}; +use crate::transfer_restrictions; + +fn setup_tokenized_asset(env: &Env, tokenizer: &Address) -> u64 { + let asset_id = 900u64; + let _ = tokenization::tokenize_asset( + env, + asset_id, + String::from_str(env, "RESTR"), + BigInt::from_i128(env, 1000), + 2, + BigInt::from_i128(env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(env, "Restriction Test"), + description: String::from_str(env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(env), + }, + ); + asset_id +} + +#[test] +fn test_set_transfer_restriction() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + let restriction = TransferRestriction { + require_accredited: true, + geographic_allowed: soroban_sdk::Vec::new(&env), + }; + + let result = transfer_restrictions::set_transfer_restriction(&env, asset_id, restriction); + assert!(result.is_ok()); + + // Verify restriction was set + let has_restrictions = transfer_restrictions::has_transfer_restrictions(&env, asset_id).unwrap(); + assert!(has_restrictions); +} + +#[test] +fn test_whitelist_operations() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let whitelisted = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Add to whitelist + transfer_restrictions::add_to_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); + + // Check if whitelisted + let is_wl = transfer_restrictions::is_whitelisted(&env, asset_id, whitelisted.clone()).unwrap(); + assert!(is_wl); + + // Get whitelist + let whitelist = transfer_restrictions::get_whitelist(&env, asset_id).unwrap(); + assert_eq!(whitelist.len(), 1); + + // Remove from whitelist + transfer_restrictions::remove_from_whitelist(&env, asset_id, whitelisted.clone()).unwrap(); + + // Verify removed + let is_wl = transfer_restrictions::is_whitelisted(&env, asset_id, whitelisted).unwrap(); + assert!(!is_wl); +} + +#[test] +fn test_whitelist_duplicate_prevention() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let whitelisted = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // 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(); + + // Should still have only 1 entry + let whitelist = transfer_restrictions::get_whitelist(&env, asset_id).unwrap(); + assert_eq!(whitelist.len(), 1); +} + +#[test] +fn test_validate_transfer_no_restrictions() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let recipient = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Validate transfer when no restrictions exist + let valid = transfer_restrictions::validate_transfer(&env, asset_id, tokenizer, recipient).unwrap(); + assert!(valid); +} + +#[test] +fn test_get_transfer_restriction() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Should fail initially (no restriction) + let restriction = transfer_restrictions::get_transfer_restriction(&env, asset_id); + assert!(restriction.is_err()); + + // Set restriction + let new_restriction = TransferRestriction { + require_accredited: true, + geographic_allowed: soroban_sdk::Vec::new(&env), + }; + transfer_restrictions::set_transfer_restriction(&env, asset_id, new_restriction.clone()).unwrap(); + + // Should now exist + let restriction = transfer_restrictions::get_transfer_restriction(&env, asset_id).unwrap(); + assert_eq!(restriction.require_accredited, true); +} diff --git a/contracts/assetsup/src/tests/voting_new.rs b/contracts/assetsup/src/tests/voting_new.rs new file mode 100644 index 0000000..1af2088 --- /dev/null +++ b/contracts/assetsup/src/tests/voting_new.rs @@ -0,0 +1,139 @@ +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{Address, BigInt, Env, String}; + +use crate::tokenization; +use crate::types::AssetType; +use crate::voting; + +fn setup_tokenized_asset(env: &Env, tokenizer: &Address) -> u64 { + let asset_id = 700u64; + let _ = tokenization::tokenize_asset( + env, + asset_id, + String::from_str(env, "VOTE"), + BigInt::from_i128(env, 1000), + 2, + BigInt::from_i128(env, 100), + tokenizer.clone(), + crate::types::TokenMetadata { + name: String::from_str(env, "Voting Test"), + description: String::from_str(env, "Test"), + asset_type: AssetType::Digital, + ipfs_uri: None, + legal_docs_hash: None, + valuation_report_hash: None, + accredited_investor_required: false, + geographic_restrictions: soroban_sdk::Vec::new(env), + }, + ); + asset_id +} + +#[test] +fn test_cast_vote() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Cast vote + let result = voting::cast_vote(&env, asset_id, 1, tokenizer.clone()); + assert!(result.is_ok()); + + // Verify vote was recorded + let has_voted = voting::has_voted(&env, asset_id, 1, tokenizer).unwrap(); + assert!(has_voted); +} + +#[test] +fn test_double_vote_prevention() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Cast first vote + voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); + + // Try to vote again + let result = voting::cast_vote(&env, asset_id, 1, tokenizer); + assert!(result.is_err()); +} + +#[test] +fn test_vote_tally() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Transfer some tokens to second holder + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 300)) + .unwrap(); + + // Cast votes + voting::cast_vote(&env, asset_id, 1, tokenizer.clone()).unwrap(); + voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); + + // Check tally + let tally = voting::get_vote_tally(&env, asset_id, 1).unwrap(); + + // Tokenizer has 700, holder2 has 300 = 1000 total + assert_eq!(tally, BigInt::from_i128(&env, 1000)); +} + +#[test] +fn test_proposal_passed() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Transfer 600 tokens to holder2 (>50% of 1000) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 600)) + .unwrap(); + + // Holder2 votes (600 votes) + voting::cast_vote(&env, asset_id, 1, holder2).unwrap(); + + // Check if proposal passed + let passed = voting::proposal_passed(&env, asset_id, 1).unwrap(); + assert!(passed); +} + +#[test] +fn test_proposal_failed() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let holder2 = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Transfer 400 tokens to holder2 (<50% of 1000) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), holder2.clone(), BigInt::from_i128(&env, 400)) + .unwrap(); + + // Both vote (600 + 400 = 1000, but holder2 has only 40%) + voting::cast_vote(&env, asset_id, 1, holder2.clone()).unwrap(); + + // Check if proposal failed (single voter with <50%) + let passed = voting::proposal_passed(&env, asset_id, 1).unwrap(); + // With only 400/1000 votes, should not pass 50% threshold + assert!(!passed); +} + +#[test] +fn test_insufficient_voting_power() { + let env = Env::default(); + let tokenizer = Address::random(&env); + let new_holder = Address::random(&env); + let asset_id = setup_tokenized_asset(&env, &tokenizer); + + // Transfer tokens down to below voting threshold (100) + tokenization::transfer_tokens(&env, asset_id, tokenizer.clone(), new_holder.clone(), BigInt::from_i128(&env, 950)) + .unwrap(); + + // New holder has 50 tokens (below 100 threshold), should not be able to vote + let result = voting::cast_vote(&env, asset_id, 1, new_holder); + assert!(result.is_err()); +} diff --git a/contracts/assetsup/src/tokenization.rs b/contracts/assetsup/src/tokenization.rs new file mode 100644 index 0000000..1a3285c --- /dev/null +++ b/contracts/assetsup/src/tokenization.rs @@ -0,0 +1,502 @@ +use crate::error::Error; +use crate::types::{ + ContractEvent, OwnershipRecord, TokenDataKey, TokenMetadata, TokenizedAsset, +}; +use soroban_sdk::{Address, BigInt, Env, String, Vec}; + +/// Initialize tokenization by creating tokenized asset +/// Only contract admin or asset owner can tokenize +pub fn tokenize_asset( + env: &Env, + asset_id: u64, + symbol: String, + total_supply: BigInt, + decimals: u32, + min_voting_threshold: BigInt, + tokenizer: Address, + metadata: TokenMetadata, +) -> Result { + // Validate inputs + if total_supply <= BigInt::from_i128(env, 0) { + return Err(Error::InvalidTokenSupply); + } + + // Check if asset is already tokenized + let store = env.storage().persistent(); + let key = TokenDataKey::TokenizedAsset(asset_id); + if store.has(&key) { + return Err(Error::AssetAlreadyTokenized); + } + + // Create tokenized asset + let timestamp = env.ledger().timestamp(); + let tokenized_asset = TokenizedAsset { + asset_id, + total_supply: total_supply.clone(), + symbol: symbol.clone(), + decimals, + locked_tokens: BigInt::from_i128(env, 0), + tokenizer: tokenizer.clone(), + valuation: total_supply.clone(), + token_holders_count: 1, + tokens_in_circulation: total_supply.clone(), + min_voting_threshold, + revenue_sharing_enabled: false, + tokenization_timestamp: timestamp, + detokenization_required_threshold: 50, // 50% majority + }; + + // Store tokenized asset + store.set(&key, &tokenized_asset); + + // Store metadata + let metadata_key = TokenDataKey::TokenizedAsset(asset_id); + store.set(&(b"token_metadata", asset_id), &metadata); + + // Initialize tokenizer as first holder with full supply + let ownership = OwnershipRecord { + owner: tokenizer.clone(), + balance: total_supply.clone(), + acquisition_timestamp: timestamp, + average_purchase_price: BigInt::from_i128(env, 1), + voting_power: total_supply.clone(), + dividend_entitlement: total_supply.clone(), + unclaimed_dividends: BigInt::from_i128(env, 0), + ownership_percentage: BigInt::from_i128(env, 10000), // 100% in basis points + }; + + let holder_key = TokenDataKey::TokenHolder(asset_id, tokenizer.clone()); + store.set(&holder_key, &ownership); + + // Initialize token holders list + let mut holders: Vec
= Vec::new(env); + holders.push_back(tokenizer.clone()); + let holders_list_key = TokenDataKey::TokenHoldersList(asset_id); + store.set(&holders_list_key, &holders); + + // Emit event + env.events().publish( + ("token", "asset_tokenized"), + ContractEvent::AssetTokenized { + asset_id, + supply: total_supply, + symbol, + decimals, + tokenizer, + }, + ); + + Ok(tokenized_asset) +} + +/// Mint additional tokens +/// Only tokenizer can mint +pub fn mint_tokens( + env: &Env, + asset_id: u64, + amount: BigInt, + minter: Address, +) -> Result { + if amount <= BigInt::from_i128(env, 0) { + return Err(Error::InvalidTokenSupply); + } + + let store = env.storage().persistent(); + let key = TokenDataKey::TokenizedAsset(asset_id); + + // Get tokenized asset + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Only tokenizer can mint + if tokenized_asset.tokenizer != minter { + return Err(Error::Unauthorized); + } + + // Update total supply + tokenized_asset.total_supply = &tokenized_asset.total_supply + &amount; + tokenized_asset.tokens_in_circulation = &tokenized_asset.tokens_in_circulation + &amount; + + // Update tokenizer's ownership + let holder_key = TokenDataKey::TokenHolder(asset_id, minter.clone()); + let mut ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; + + ownership.balance = &ownership.balance + &amount; + ownership.voting_power = ownership.balance.clone(); + ownership.dividend_entitlement = ownership.balance.clone(); + + // Recalculate ownership percentage + ownership.ownership_percentage = + (&ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply; + + store.set(&holder_key, &ownership); + store.set(&key, &tokenized_asset.clone()); + + // Emit event + env.events().publish( + ("token", "tokens_minted"), + ContractEvent::TokensMinted { + asset_id, + amount, + new_supply: tokenized_asset.total_supply.clone(), + }, + ); + + Ok(tokenized_asset) +} + +/// Burn tokens +/// Only tokenizer can burn, and only from their own account +pub fn burn_tokens( + env: &Env, + asset_id: u64, + amount: BigInt, + burner: Address, +) -> Result { + if amount <= BigInt::from_i128(env, 0) { + return Err(Error::InvalidTokenSupply); + } + + let store = env.storage().persistent(); + let key = TokenDataKey::TokenizedAsset(asset_id); + + // Get tokenized asset + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Only tokenizer can burn + if tokenized_asset.tokenizer != burner { + return Err(Error::Unauthorized); + } + + // Get burner's balance + let holder_key = TokenDataKey::TokenHolder(asset_id, burner.clone()); + let mut ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; + + if ownership.balance < amount { + return Err(Error::InsufficientBalance); + } + + // Update balances + ownership.balance = &ownership.balance - &amount; + ownership.voting_power = ownership.balance.clone(); + ownership.dividend_entitlement = ownership.balance.clone(); + + // Recalculate ownership percentage + ownership.ownership_percentage = + (&ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply; + + tokenized_asset.total_supply = &tokenized_asset.total_supply - &amount; + tokenized_asset.tokens_in_circulation = &tokenized_asset.tokens_in_circulation - &amount; + + store.set(&holder_key, &ownership); + store.set(&key, &tokenized_asset.clone()); + + // Emit event + env.events().publish( + ("token", "tokens_burned"), + ContractEvent::TokensBurned { + asset_id, + amount, + new_supply: tokenized_asset.total_supply.clone(), + }, + ); + + Ok(tokenized_asset) +} + +/// Transfer tokens from one address to another +pub fn transfer_tokens( + env: &Env, + asset_id: u64, + from: Address, + to: Address, + amount: BigInt, +) -> Result<(), Error> { + if amount <= BigInt::from_i128(env, 0) { + return Err(Error::InvalidTokenSupply); + } + + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Check if from address has locked tokens + let lock_key = TokenDataKey::TokenLockedUntil(asset_id, from.clone()); + if let Some(Some(lock_time)) = store.get::<_, u64>(&lock_key) { + if env.ledger().timestamp() < lock_time { + return Err(Error::TokensAreLocked); + } + } + + // Get from balance + let from_holder_key = TokenDataKey::TokenHolder(asset_id, from.clone()); + let mut from_ownership: OwnershipRecord = store + .get(&from_holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; + + if from_ownership.balance < amount { + return Err(Error::InsufficientBalance); + } + + // Get to balance (or create new holder) + let to_holder_key = TokenDataKey::TokenHolder(asset_id, to.clone()); + let mut to_ownership: OwnershipRecord = match store.get(&to_holder_key) { + Some(Some(ownership)) => ownership, + _ => { + // Create new holder + let timestamp = env.ledger().timestamp(); + OwnershipRecord { + owner: to.clone(), + balance: BigInt::from_i128(env, 0), + acquisition_timestamp: timestamp, + average_purchase_price: BigInt::from_i128(env, 1), + voting_power: BigInt::from_i128(env, 0), + dividend_entitlement: BigInt::from_i128(env, 0), + unclaimed_dividends: BigInt::from_i128(env, 0), + ownership_percentage: BigInt::from_i128(env, 0), + } + } + }; + + // Update balances + from_ownership.balance = &from_ownership.balance - &amount; + from_ownership.voting_power = from_ownership.balance.clone(); + from_ownership.dividend_entitlement = from_ownership.balance.clone(); + from_ownership.ownership_percentage = + (&from_ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply; + + to_ownership.balance = &to_ownership.balance + &amount; + to_ownership.voting_power = to_ownership.balance.clone(); + to_ownership.dividend_entitlement = to_ownership.balance.clone(); + to_ownership.ownership_percentage = + (&to_ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply; + + store.set(&from_holder_key, &from_ownership); + store.set(&to_holder_key, &to_ownership); + + // Add to holder list if new + let holders_list_key = TokenDataKey::TokenHoldersList(asset_id); + let mut holders: Vec
= store + .get(&holders_list_key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + let is_new_holder = !holders.iter().any(|h| h == to); + if is_new_holder { + holders.push_back(to.clone()); + store.set(&holders_list_key, &holders); + } + + // Emit event + env.events().publish( + ("token", "tokens_transferred"), + ContractEvent::TokensTransferred { + asset_id, + from: from.clone(), + to: to.clone(), + amount, + }, + ); + + Ok(()) +} + +/// Get token balance for an address +pub fn get_token_balance( + env: &Env, + asset_id: u64, + holder: Address, +) -> Result { + let store = env.storage().persistent(); + let key = TokenDataKey::TokenHolder(asset_id, holder); + + match store.get(&key) { + Some(Some(ownership)) => Ok(ownership.balance), + _ => Ok(BigInt::from_i128(env, 0)), + } +} + +/// Get all token holders for an asset +pub fn get_token_holders(env: &Env, asset_id: u64) -> Result, Error> { + let store = env.storage().persistent(); + let key = TokenDataKey::TokenHoldersList(asset_id); + + store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized) +} + +/// Lock tokens until a specific timestamp +pub fn lock_tokens( + env: &Env, + asset_id: u64, + holder: Address, + until_timestamp: u64, +) -> Result<(), Error> { + // Verify asset is tokenized + let store = env.storage().persistent(); + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Only tokenizer can lock + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Note: In production, would check authorization + // For now, assuming called from trusted context + + let lock_key = TokenDataKey::TokenLockedUntil(asset_id, holder.clone()); + store.set(&lock_key, &until_timestamp); + + env.events().publish( + ("token", "tokens_locked"), + ContractEvent::TokensLocked { + asset_id, + holder, + until_timestamp, + }, + ); + + Ok(()) +} + +/// Unlock tokens (remove lock) +pub fn unlock_tokens(env: &Env, asset_id: u64, holder: Address) -> Result<(), Error> { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + let lock_key = TokenDataKey::TokenLockedUntil(asset_id, holder.clone()); + + // Remove lock record + if store.has(&lock_key) { + store.remove(&lock_key); + } + + env.events().publish( + ("token", "tokens_unlocked"), + ContractEvent::TokensUnlocked { + asset_id, + holder, + }, + ); + + Ok(()) +} + +/// Calculate ownership percentage for a holder (in basis points) +pub fn calculate_ownership_percentage( + env: &Env, + asset_id: u64, + holder: Address, +) -> Result { + let store = env.storage().persistent(); + + // Get asset + let key = TokenDataKey::TokenizedAsset(asset_id); + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Get holder balance + let holder_key = TokenDataKey::TokenHolder(asset_id, holder); + let ownership: OwnershipRecord = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; + + // Calculate percentage: (balance / total_supply) * 10000 + if tokenized_asset.total_supply <= BigInt::from_i128(env, 0) { + return Ok(BigInt::from_i128(env, 0)); + } + + Ok((&ownership.balance * BigInt::from_i128(env, 10000)) / &tokenized_asset.total_supply) +} + +/// Get tokenized asset details +pub fn get_tokenized_asset( + env: &Env, + asset_id: u64, +) -> Result { + let store = env.storage().persistent(); + let key = TokenDataKey::TokenizedAsset(asset_id); + + store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized) +} + +/// Get token metadata +pub fn get_token_metadata( + env: &Env, + asset_id: u64, +) -> Result { + let store = env.storage().persistent(); + + store + .get(&(b"token_metadata", asset_id)) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized) +} + +/// Update asset valuation +pub fn update_valuation( + env: &Env, + asset_id: u64, + new_valuation: BigInt, +) -> Result<(), Error> { + if new_valuation <= BigInt::from_i128(env, 0) { + return Err(Error::InvalidValuation); + } + + let store = env.storage().persistent(); + let key = TokenDataKey::TokenizedAsset(asset_id); + + let mut tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + tokenized_asset.valuation = new_valuation.clone(); + store.set(&key, &tokenized_asset); + + env.events().publish( + ("token", "valuation_updated"), + ContractEvent::ValuationUpdated { + asset_id, + new_valuation, + }, + ); + + Ok(()) +} diff --git a/contracts/assetsup/src/transfer_restrictions.rs b/contracts/assetsup/src/transfer_restrictions.rs new file mode 100644 index 0000000..e5218df --- /dev/null +++ b/contracts/assetsup/src/transfer_restrictions.rs @@ -0,0 +1,174 @@ +use crate::error::Error; +use crate::types::{ContractEvent, TokenDataKey, TransferRestriction}; +use soroban_sdk::{Address, Env, String, Vec}; + +/// Set transfer restrictions for an asset +pub fn set_transfer_restriction( + env: &Env, + asset_id: u64, + restriction: TransferRestriction, +) -> Result<(), Error> { + let store = env.storage().persistent(); + + // Store the restriction + let key = TokenDataKey::TransferRestriction(asset_id); + store.set(&key, &restriction); + + env.events().publish( + ("transfer", "restriction_set"), + ContractEvent::TransferRestrictionSet { + asset_id, + require_accredited: restriction.require_accredited, + }, + ); + + Ok(()) +} + +/// 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); + + env.events().publish( + ("transfer", "whitelist_added"), + ContractEvent::WhitelistAddressAdded { asset_id, address }, + ); + + Ok(()) +} + +/// 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); + + env.events().publish( + ("transfer", "whitelist_removed"), + ContractEvent::WhitelistAddressRemoved { 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)) +} + +/// 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))) +} + +/// Validate if a transfer is allowed based on restrictions +pub fn validate_transfer( + env: &Env, + asset_id: u64, + from: Address, + to: Address, +) -> Result { + let store = env.storage().persistent(); + + let restriction_key = TokenDataKey::TransferRestriction(asset_id); + + // If no restrictions, allow transfer + let restriction: TransferRestriction = match store.get(&restriction_key) { + Some(Some(r)) => r, + _ => { + return Ok(true); // No restrictions + } + }; + + // Check if accredited investor is required + if restriction.require_accredited { + // In production, would check external oracle or data + // For now, we assume this is checked at authorization level + // This is a placeholder that would integrate with identity/KYC service + } + + // Check geographic restrictions + if !restriction.geographic_allowed.is_empty() { + // In production, would check sender and receiver locations + // For now, we assume this is checked at authorization level + // This is a placeholder that would integrate with location service + } + + Ok(true) +} + +/// Check if transfer restrictions are enabled for an asset +pub fn has_transfer_restrictions(env: &Env, asset_id: u64) -> Result { + let store = env.storage().persistent(); + + let restriction_key = TokenDataKey::TransferRestriction(asset_id); + Ok(store.has(&restriction_key)) +} + +/// Get transfer restrictions for an asset +pub fn get_transfer_restriction( + env: &Env, + asset_id: u64, +) -> Result { + let store = env.storage().persistent(); + + let key = TokenDataKey::TransferRestriction(asset_id); + store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized) +} + +/// Clear transfer restrictions +pub fn clear_transfer_restrictions(env: &Env, asset_id: u64) -> Result<(), Error> { + let store = env.storage().persistent(); + + let key = TokenDataKey::TransferRestriction(asset_id); + if store.has(&key) { + store.remove(&key); + } + + Ok(()) +} diff --git a/contracts/assetsup/src/types.rs b/contracts/assetsup/src/types.rs index 4fb864e..0fda180 100644 --- a/contracts/assetsup/src/types.rs +++ b/contracts/assetsup/src/types.rs @@ -64,11 +64,110 @@ pub struct CustomAttribute { } // ===================== -// Tokenization / Fractional Ownership Types (V1) +// Tokenization / Fractional Ownership Types (V1 & V2) // ===================== -use soroban_sdk::Address; -use soroban_sdk::BigInt; +use soroban_sdk::{Address, BigInt, BytesN, String, Vec}; + +/// Data keys for contract storage +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TokenDataKey { + /// Stores TokenizedAsset + TokenizedAsset(u64), + /// Stores OwnershipRecord for (asset_id, holder_address) + TokenHolder(u64, Address), + /// Stores Vec
of all token holders for an asset + TokenHoldersList(u64), + /// Stores lock timestamp for (asset_id, holder_address) + TokenLockedUntil(u64, Address), + /// Stores vote record for (asset_id, proposal_id, voter_address) + VoteRecord(u64, u64, Address), + /// Stores vote tally (BigInt) for (asset_id, proposal_id) + VoteTally(u64, u64), + /// Stores TransferRestriction for asset_id + TransferRestriction(u64), + /// Stores Vec
whitelist for asset_id + Whitelist(u64), + /// Stores unclaimed dividend for (asset_id, holder_address) + UnclaimedDividend(u64, Address), + /// Stores detokenization proposal status + DetokenizationProposal(u64), +} + +/// Events emitted by the contract +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ContractEvent { + AssetTokenized { + asset_id: u64, + supply: BigInt, + symbol: String, + decimals: u32, + tokenizer: Address, + }, + TokensMinted { + asset_id: u64, + amount: BigInt, + new_supply: BigInt, + }, + TokensBurned { + asset_id: u64, + amount: BigInt, + new_supply: BigInt, + }, + TokensTransferred { + asset_id: u64, + from: Address, + to: Address, + amount: BigInt, + }, + TokensLocked { + asset_id: u64, + holder: Address, + until_timestamp: u64, + }, + TokensUnlocked { + asset_id: u64, + holder: Address, + }, + DividendDistributed { + asset_id: u64, + total_amount: BigInt, + holder_count: u32, + }, + DividendClaimed { + asset_id: u64, + holder: Address, + amount: BigInt, + }, + VoteCast { + asset_id: u64, + proposal_id: u64, + voter: Address, + weight: BigInt, + }, + AssetDetokenized { + asset_id: u64, + proposal_id: u64, + }, + ValuationUpdated { + asset_id: u64, + new_valuation: BigInt, + }, + TransferRestrictionSet { + asset_id: u64, + require_accredited: bool, + }, + WhitelistAddressAdded { + asset_id: u64, + address: Address, + }, + WhitelistAddressRemoved { + asset_id: u64, + address: Address, + }, +} /// Represents a tokenized asset on-chain #[contracttype] @@ -88,6 +187,18 @@ pub struct TokenizedAsset { pub tokenizer: Address, /// Asset valuation (in stroops) pub valuation: BigInt, + /// Number of unique token holders + pub token_holders_count: u32, + /// Tokens currently in circulation (not burned) + pub tokens_in_circulation: BigInt, + /// Minimum tokens required to vote + pub min_voting_threshold: BigInt, + /// Revenue sharing enabled flag + pub revenue_sharing_enabled: bool, + /// Timestamp when asset was tokenized + pub tokenization_timestamp: u64, + /// Percentage required for detokenization (basis points, e.g., 5000 = 50%) + pub detokenization_required_threshold: u32, } /// Metadata associated with a tokenized asset @@ -97,6 +208,16 @@ pub struct TokenMetadata { pub name: String, pub description: String, pub asset_type: super::AssetType, + /// IPFS URI for extended metadata + pub ipfs_uri: Option, + /// Hash of legal documentation + pub legal_docs_hash: Option>, + /// Hash of valuation report + pub valuation_report_hash: Option>, + /// Whether accredited investor status is required + pub accredited_investor_required: bool, + /// Geographic restrictions (ISO country codes) + pub geographic_restrictions: Vec, } /// Represents ownership record of a token holder @@ -104,5 +225,47 @@ pub struct TokenMetadata { #[derive(Clone, Debug, Eq, PartialEq)] pub struct OwnershipRecord { pub owner: Address, + /// Current token balance pub balance: BigInt, + /// Timestamp of first acquisition + pub acquisition_timestamp: u64, + /// Average price per token at acquisition + pub average_purchase_price: BigInt, + /// Voting power (weighted by balance) + pub voting_power: BigInt, + /// Entitlement to dividends + pub dividend_entitlement: BigInt, + /// Unclaimed dividends pending + pub unclaimed_dividends: BigInt, + /// Ownership percentage in basis points (e.g., 5000 = 50%) + pub ownership_percentage: BigInt, +} + +/// Transfer restrictions for tokens +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TransferRestriction { + /// Whether accredited investor status is required + pub require_accredited: bool, + /// Geographic restrictions (ISO country codes) + pub geographic_allowed: Vec, +} + +/// Detokenization proposal +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum DetokenizationProposal { + Active { + proposal_id: u64, + proposer: Address, + created_at: u64, + }, + Executed { + proposal_id: u64, + executed_at: u64, + }, + Rejected { + proposal_id: u64, + rejected_at: u64, + }, } diff --git a/contracts/assetsup/src/voting.rs b/contracts/assetsup/src/voting.rs new file mode 100644 index 0000000..ce4ff17 --- /dev/null +++ b/contracts/assetsup/src/voting.rs @@ -0,0 +1,211 @@ +use crate::error::Error; +use crate::types::{ContractEvent, TokenDataKey, TokenizedAsset}; +use soroban_sdk::{Address, BigInt, Env, Vec}; + +/// Cast a vote on a proposal +pub fn cast_vote( + env: &Env, + asset_id: u64, + proposal_id: u64, + voter: Address, +) -> Result<(), Error> { + let store = env.storage().persistent(); + + // Get tokenized asset + let key = TokenDataKey::TokenizedAsset(asset_id); + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Get voter's balance + let holder_key = TokenDataKey::TokenHolder(asset_id, voter.clone()); + let ownership = store + .get(&holder_key) + .ok_or(Error::HolderNotFound)? + .ok_or(Error::HolderNotFound)?; + + // Check if voter has sufficient voting power + if ownership.balance < tokenized_asset.min_voting_threshold { + return Err(Error::InsufficientVotingPower); + } + + // Check if voter already voted + let vote_key = TokenDataKey::VoteRecord(asset_id, proposal_id, voter.clone()); + if store.has(&vote_key) { + return Err(Error::AlreadyVoted); + } + + // Record vote + store.set(&vote_key, &true); + + // Update vote tally + let tally_key = TokenDataKey::VoteTally(asset_id, proposal_id); + let current_tally: BigInt = store + .get(&tally_key) + .flatten() + .unwrap_or_else(|| BigInt::from_i128(env, 0)); + + let new_tally = ¤t_tally + &ownership.balance; + store.set(&tally_key, &new_tally); + + // Emit event + env.events().publish( + ("voting", "vote_cast"), + ContractEvent::VoteCast { + asset_id, + proposal_id, + voter, + weight: ownership.balance, + }, + ); + + Ok(()) +} + +/// Get vote tally for a proposal +pub fn get_vote_tally( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + let tally_key = TokenDataKey::VoteTally(asset_id, proposal_id); + + Ok(store + .get(&tally_key) + .flatten() + .unwrap_or_else(|| BigInt::from_i128(env, 0))) +} + +/// Check if an address has voted on a proposal +pub fn has_voted( + env: &Env, + asset_id: u64, + proposal_id: u64, + voter: Address, +) -> Result { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + let vote_key = TokenDataKey::VoteRecord(asset_id, proposal_id, voter); + + Ok(store.has(&vote_key)) +} + +/// Check if a proposal passed (vote tally > 50% of total supply) +pub fn proposal_passed( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result { + let store = env.storage().persistent(); + + // Get tokenized asset + let key = TokenDataKey::TokenizedAsset(asset_id); + let tokenized_asset: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Get vote tally + let tally_key = TokenDataKey::VoteTally(asset_id, proposal_id); + let tally: BigInt = store + .get(&tally_key) + .flatten() + .unwrap_or_else(|| BigInt::from_i128(env, 0)); + + // Calculate required threshold (50% + 1) + let threshold = + (&tokenized_asset.total_supply * BigInt::from_i128(env, tokenized_asset.detokenization_required_threshold as i128)) + / BigInt::from_i128(env, 100); + + Ok(tally > threshold) +} + +/// Get list of voters who participated in a proposal +pub fn get_proposal_voters( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result, Error> { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Get all token holders + let holders_key = TokenDataKey::TokenHoldersList(asset_id); + let holders: Vec
= store + .get(&holders_key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Filter those who voted + let mut voters = Vec::new(env); + for holder in holders.iter() { + let vote_key = TokenDataKey::VoteRecord(asset_id, proposal_id, holder.clone()); + if store.has(&vote_key) { + voters.push_back(holder); + } + } + + Ok(voters) +} + +/// Clear all voting records for a proposal (after execution or rejection) +pub fn clear_proposal_votes( + env: &Env, + asset_id: u64, + proposal_id: u64, +) -> Result<(), Error> { + let store = env.storage().persistent(); + + // Verify asset is tokenized + let key = TokenDataKey::TokenizedAsset(asset_id); + let _: TokenizedAsset = store + .get(&key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Get all token holders + let holders_key = TokenDataKey::TokenHoldersList(asset_id); + let holders: Vec
= store + .get(&holders_key) + .ok_or(Error::AssetNotTokenized)? + .ok_or(Error::AssetNotTokenized)?; + + // Remove all vote records + for holder in holders.iter() { + let vote_key = TokenDataKey::VoteRecord(asset_id, proposal_id, holder); + if store.has(&vote_key) { + store.remove(&vote_key); + } + } + + // Clear tally + let tally_key = TokenDataKey::VoteTally(asset_id, proposal_id); + if store.has(&tally_key) { + store.remove(&tally_key); + } + + Ok(()) +}