diff --git a/contracts/property-token/src/errors.rs b/contracts/property-token/src/errors.rs index 1f33b839d..58cd200ff 100644 --- a/contracts/property-token/src/errors.rs +++ b/contracts/property-token/src/errors.rs @@ -57,6 +57,30 @@ pub enum Error { AskNotFound, /// Input batch exceeds maximum allowed size BatchSizeExceeded, + + // KYC-based transfer restriction errors + /// Sender is not KYC verified + SenderNotVerified, + /// Recipient is not KYC verified + RecipientNotVerified, + /// Sender verification level insufficient + VerificationLevelInsufficient, + /// Transfer amount exceeds quota + TransferQuotaExceeded, + /// Account is blacklisted + AccountBlacklisted, + /// Account is not whitelisted + AccountNotWhitelisted, + /// Transfer hold period not met + HoldPeriodNotMet, + /// Sender risk level too high + SenderRiskLevelTooHigh, + /// Recipient risk level too high + RecipientRiskLevelTooHigh, + + /// Token IDs and amounts vectors have different lengths + LengthMismatch, + /// No stake found for this account and token StakeNotFound, /// Stake lock period has not yet expired @@ -103,6 +127,19 @@ impl core::fmt::Display for Error { Error::ProposalClosed => write!(f, "Proposal is closed"), Error::AskNotFound => write!(f, "Ask not found"), Error::BatchSizeExceeded => write!(f, "Input batch exceeds maximum allowed size"), + + Error::SenderNotVerified => write!(f, "Sender is not KYC verified"), + Error::RecipientNotVerified => write!(f, "Recipient is not KYC verified"), + Error::VerificationLevelInsufficient => write!(f, "Verification level is insufficient"), + Error::TransferQuotaExceeded => write!(f, "Transfer amount exceeds quota"), + Error::AccountBlacklisted => write!(f, "Account is blacklisted"), + Error::AccountNotWhitelisted => write!(f, "Account is not whitelisted"), + Error::HoldPeriodNotMet => write!(f, "Transfer hold period has not been met"), + Error::SenderRiskLevelTooHigh => write!(f, "Sender risk level is too high"), + Error::RecipientRiskLevelTooHigh => write!(f, "Recipient risk level is too high"), + + Error::LengthMismatch => write!(f, "Token IDs and amounts length mismatch"), + Error::StakeNotFound => write!(f, "Stake not found"), Error::LockActive => write!(f, "Stake lock period is still active"), Error::NoRewards => write!(f, "No staking rewards available"), @@ -142,6 +179,19 @@ impl ContractError for Error { Error::ProposalClosed => property_token_codes::PROPOSAL_CLOSED, Error::AskNotFound => property_token_codes::ASK_NOT_FOUND, Error::BatchSizeExceeded => property_token_codes::BATCH_SIZE_EXCEEDED, + + Error::SenderNotVerified => property_token_codes::SENDER_NOT_VERIFIED, + Error::RecipientNotVerified => property_token_codes::RECIPIENT_NOT_VERIFIED, + Error::VerificationLevelInsufficient => property_token_codes::VERIFICATION_LEVEL_INSUFFICIENT, + Error::TransferQuotaExceeded => property_token_codes::TRANSFER_QUOTA_EXCEEDED, + Error::AccountBlacklisted => property_token_codes::ACCOUNT_BLACKLISTED, + Error::AccountNotWhitelisted => property_token_codes::ACCOUNT_NOT_WHITELISTED, + Error::HoldPeriodNotMet => property_token_codes::HOLD_PERIOD_NOT_MET, + Error::SenderRiskLevelTooHigh => property_token_codes::SENDER_RISK_LEVEL_TOO_HIGH, + Error::RecipientRiskLevelTooHigh => property_token_codes::RECIPIENT_RISK_LEVEL_TOO_HIGH, + + Error::LengthMismatch => property_token_codes::BATCH_SIZE_EXCEEDED, + Error::StakeNotFound => property_token_codes::STAKE_NOT_FOUND, Error::LockActive => property_token_codes::LOCK_ACTIVE, Error::NoRewards => property_token_codes::NO_REWARDS, @@ -188,6 +238,15 @@ impl ContractError for Error { Error::BatchSizeExceeded => { "The input batch exceeds the maximum allowed size" } + Error::SenderNotVerified => "Sender account is not KYC verified", + Error::RecipientNotVerified => "Recipient account is not KYC verified", + Error::VerificationLevelInsufficient => "Account KYC verification level is insufficient for this transfer", + Error::TransferQuotaExceeded => "Transfer amount exceeds the daily or period quota", + Error::AccountBlacklisted => "The account is blacklisted and cannot participate in transfers", + Error::AccountNotWhitelisted => "The account is not on the whitelist for this token", + Error::HoldPeriodNotMet => "The minimum hold period for this token has not been met", + Error::SenderRiskLevelTooHigh => "Sender's risk level is too high for this transfer", + Error::RecipientRiskLevelTooHigh => "Recipient's risk level is too high for this transfer", Error::StakeNotFound => "No active stake found for this account and token", Error::LockActive => { "The stake lock period has not yet expired; unstaking is not permitted" diff --git a/contracts/property-token/src/lib.rs b/contracts/property-token/src/lib.rs index bc1542cbd..d314d9583 100644 --- a/contracts/property-token/src/lib.rs +++ b/contracts/property-token/src/lib.rs @@ -91,6 +91,23 @@ pub mod property_token { property_management_contract: Option, /// On-chain management agent per property token (tokenized property) management_agent: Mapping, + + + // KYC-based transfer restriction fields + /// Transfer restriction configuration per token + transfer_restrictions: Mapping, + /// User transfer quota tracking (token_id, account) -> quota + user_transfer_quotas: Mapping<(TokenId, AccountId), UserTransferQuota>, + /// Blacklisted accounts that cannot transfer tokens + blacklist: Mapping, + /// Whitelisted accounts (if whitelist-only restriction is enabled) + whitelist: Mapping<(TokenId, AccountId), bool>, + /// Cached KYC verification levels to reduce cross-contract calls + kyc_verification_cache: Mapping, // (level, block_cached) + /// KYC transfer audit log + kyc_transfer_log: Mapping, + kyc_transfer_log_counter: u64, + /// Vesting schedules for tokens (TokenId, AccountId) vesting_schedules: Mapping<(TokenId, AccountId), VestingSchedule>, /// Custom URI overrides for tokens @@ -415,6 +432,61 @@ pub mod property_token { pub token_id: TokenId, } + + // --- KYC Transfer Restriction Events --- + #[ink(event)] + pub struct TransferRestrictionConfigured { + #[ink(topic)] + pub token_id: TokenId, + pub restriction_level: String, + pub min_verification_level: u8, + pub max_transfer_amount: u128, + } + + #[ink(event)] + pub struct TransferRestrictionRemoved { + #[ink(topic)] + pub token_id: TokenId, + } + + #[ink(event)] + pub struct KYCTransferVerified { + #[ink(topic)] + pub from: AccountId, + #[ink(topic)] + pub to: AccountId, + #[ink(topic)] + pub token_id: TokenId, + pub amount: u128, + pub from_verification_level: u8, + pub to_verification_level: u8, + } + + #[ink(event)] + pub struct KYCTransferRejected { + #[ink(topic)] + pub from: AccountId, + #[ink(topic)] + pub to: AccountId, + #[ink(topic)] + pub token_id: TokenId, + pub reason: String, + } + + #[ink(event)] + pub struct AccountBlacklisted { + #[ink(topic)] + pub account: AccountId, + pub status: bool, + } + + #[ink(event)] + pub struct AccountWhitelisted { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub account: AccountId, + pub status: bool, // --- Staking Events --- #[ink(event)] pub struct SharesStaked { @@ -475,6 +547,7 @@ pub mod property_token { #[ink(topic)] pub account: AccountId, pub amount: u128, + } // --- Supply Management Events --- @@ -572,8 +645,19 @@ pub mod property_token { max_batch_size: 50, property_management_contract: None, management_agent: Mapping::default(), + + // Initialize KYC transfer restriction fields + transfer_restrictions: Mapping::default(), + user_transfer_quotas: Mapping::default(), + blacklist: Mapping::default(), + whitelist: Mapping::default(), + kyc_verification_cache: Mapping::default(), + kyc_transfer_log: Mapping::default(), + kyc_transfer_log_counter: 0, + vesting_schedules: Mapping::default(), token_uris: Mapping::default(), + reentrancy_guard: ReentrancyGuard::new(), snapshot_counter: Mapping::default(), snapshots: Mapping::default(), @@ -787,6 +871,16 @@ pub mod property_token { return Err(Error::LengthMismatch); } + + // Verify KYC transfer restrictions for all tokens + for i in 0..ids.len() { + let token_id = ids[i]; + let amount = amounts[i]; + self.verify_kyc_transfer(&from, &to, token_id, amount)?; + } + + // Transfer each token + if ids.is_empty() { return Err(Error::InvalidAmount); } @@ -803,6 +897,7 @@ pub mod property_token { } // Execute all transfers + for i in 0..ids.len() { let token_id = ids[i]; let amount = amounts[i]; @@ -813,6 +908,9 @@ pub mod property_token { let to_balance = self.balances.get((&to, &token_id)).unwrap_or(0); self.balances .insert((&to, &token_id), &(to_balance + amount)); + + // Update transfer quota + self.update_transfer_quota(&from, &to, token_id, amount)?; } // Single batch event instead of N individual events @@ -853,6 +951,309 @@ pub mod property_token { Ok(()) } + // --- KYC-Based Transfer Restriction Management --- + + /// Configures KYC-based transfer restrictions for a specific token + /// Only admin can configure transfer restrictions + #[ink(message)] + pub fn configure_transfer_restrictions( + &mut self, + token_id: TokenId, + restriction_level: u8, // 0=None, 1=KYCRequired, 2=VerificationLevel, 3=WhitelistOnly, 4=BlacklistBased + min_verification_level: u8, // 0-4 + max_transfer_amount: u128, + quota_period: u32, + hold_period: u32, + check_risk_level: bool, + max_allowed_risk_level: u8, + ) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + + // Verify token exists + if self.token_owner.get(token_id).is_none() { + return Err(Error::TokenNotFound); + } + + let level_str = match restriction_level { + 0 => "None", + 1 => "KYCRequired", + 2 => "VerificationLevelRequired", + 3 => "WhitelistOnly", + 4 => "BlacklistBased", + _ => return Err(Error::InvalidRequest), + }; + + let restriction_level_enum = match restriction_level { + 0 => TransferRestrictionLevel::None, + 1 => TransferRestrictionLevel::KYCRequired, + 2 => TransferRestrictionLevel::VerificationLevelRequired, + 3 => TransferRestrictionLevel::WhitelistOnly, + 4 => TransferRestrictionLevel::BlacklistBased, + _ => return Err(Error::InvalidRequest), + }; + + let min_level = match min_verification_level { + 0 => KYCVerificationLevel::None, + 1 => KYCVerificationLevel::Basic, + 2 => KYCVerificationLevel::Standard, + 3 => KYCVerificationLevel::Enhanced, + 4 => KYCVerificationLevel::Institutional, + _ => return Err(Error::InvalidRequest), + }; + + let config = TransferRestrictionConfig { + restriction_level: restriction_level_enum, + min_verification_level: min_level, + max_transfer_amount, + quota_period, + hold_period, + check_risk_level, + max_allowed_risk_level, + }; + + self.transfer_restrictions.insert(token_id, &config); + + self.env().emit_event(TransferRestrictionConfigured { + token_id, + restriction_level: level_str.to_string(), + min_verification_level, + max_transfer_amount, + }); + + Ok(()) + } + + /// Gets the transfer restriction configuration for a token + #[ink(message)] + pub fn get_transfer_restrictions( + &self, + token_id: TokenId, + ) -> Option<(u8, u8, u128, u32, u32, bool, u8)> { + self.transfer_restrictions.get(token_id).map(|config| { + let restriction_level = match config.restriction_level { + TransferRestrictionLevel::None => 0, + TransferRestrictionLevel::KYCRequired => 1, + TransferRestrictionLevel::VerificationLevelRequired => 2, + TransferRestrictionLevel::WhitelistOnly => 3, + TransferRestrictionLevel::BlacklistBased => 4, + }; + let min_level = match config.min_verification_level { + KYCVerificationLevel::None => 0, + KYCVerificationLevel::Basic => 1, + KYCVerificationLevel::Standard => 2, + KYCVerificationLevel::Enhanced => 3, + KYCVerificationLevel::Institutional => 4, + }; + ( + restriction_level, + min_level, + config.max_transfer_amount, + config.quota_period, + config.hold_period, + config.check_risk_level, + config.max_allowed_risk_level, + ) + }) + } + + /// Adds an account to the blacklist + #[ink(message)] + pub fn blacklist_account(&mut self, account: AccountId) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + self.blacklist.insert(account, &true); + self.env().emit_event(AccountBlacklisted { + account, + status: true, + }); + Ok(()) + } + + /// Removes an account from the blacklist + #[ink(message)] + pub fn remove_from_blacklist(&mut self, account: AccountId) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + self.blacklist.remove(account); + self.env().emit_event(AccountBlacklisted { + account, + status: false, + }); + Ok(()) + } + + /// Checks if an account is blacklisted + #[ink(message)] + pub fn is_account_blacklisted(&self, account: AccountId) -> bool { + self.blacklist.get(account).unwrap_or(false) + } + + /// Adds an account to the whitelist for a specific token + #[ink(message)] + pub fn whitelist_account( + &mut self, + token_id: TokenId, + account: AccountId, + ) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + // Verify token exists + if self.token_owner.get(token_id).is_none() { + return Err(Error::TokenNotFound); + } + self.whitelist.insert((token_id, account), &true); + self.env().emit_event(AccountWhitelisted { + token_id, + account, + status: true, + }); + Ok(()) + } + + /// Removes an account from the whitelist for a specific token + #[ink(message)] + pub fn remove_from_whitelist( + &mut self, + token_id: TokenId, + account: AccountId, + ) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + self.whitelist.remove((token_id, account)); + self.env().emit_event(AccountWhitelisted { + token_id, + account, + status: false, + }); + Ok(()) + } + + /// Checks if an account is whitelisted for a specific token + #[ink(message)] + pub fn is_account_whitelisted(&self, token_id: TokenId, account: AccountId) -> bool { + self.whitelist.get((token_id, account)).unwrap_or(false) + } + + /// Gets the transfer quota status for an account and token + #[ink(message)] + pub fn get_transfer_quota_status( + &self, + token_id: TokenId, + account: AccountId, + ) -> Option<(u128, u32, u32)> { + self.user_transfer_quotas + .get((token_id, account)) + .map(|q| (q.amount_transferred, q.period_start_block, q.acquisition_block)) + } + + /// Sets transfer restrictions for a specific token + #[ink(message)] + pub fn set_transfer_restriction( + &mut self, + token_id: TokenId, + restriction_level: TransferRestrictionLevel, + min_verification_level: KYCVerificationLevel, + max_transfer_amount: u128, + quota_period: u32, + hold_period: u32, + check_risk_level: bool, + max_allowed_risk_level: u8, + ) -> Result<(), Error> { + // Only admin or token owner can set restrictions + let caller = self.env().caller(); + if caller != self.admin { + let owner = self.token_owner.get(token_id).ok_or(Error::TokenNotFound)?; + if caller != owner { + return Err(Error::Unauthorized); + } + } + + // Verify token exists + if self.token_owner.get(token_id).is_none() { + return Err(Error::TokenNotFound); + } + + let config = TransferRestrictionConfig { + restriction_level, + min_verification_level, + max_transfer_amount, + quota_period, + hold_period, + check_risk_level, + max_allowed_risk_level, + }; + + self.transfer_restrictions.insert(token_id, &config); + + let restriction_level_str = match restriction_level { + TransferRestrictionLevel::None => "None".to_string(), + TransferRestrictionLevel::KYCRequired => "KYCRequired".to_string(), + TransferRestrictionLevel::VerificationLevelRequired => "VerificationLevelRequired".to_string(), + TransferRestrictionLevel::WhitelistOnly => "WhitelistOnly".to_string(), + TransferRestrictionLevel::BlacklistBased => "BlacklistBased".to_string(), + }; + + let min_level = match min_verification_level { + KYCVerificationLevel::None => 0, + KYCVerificationLevel::Basic => 1, + KYCVerificationLevel::Standard => 2, + KYCVerificationLevel::Enhanced => 3, + KYCVerificationLevel::Institutional => 4, + }; + + self.env().emit_event(TransferRestrictionConfigured { + token_id, + restriction_level: restriction_level_str, + min_verification_level: min_level, + max_transfer_amount, + }); + + Ok(()) + } + + /// Gets the transfer restriction configuration for a token + #[ink(message)] + pub fn get_transfer_restriction_config( + &self, + token_id: TokenId, + ) -> Option<(TransferRestrictionLevel, KYCVerificationLevel, u128, u32, u32, bool, u8)> { + self.transfer_restrictions.get(token_id).map(|config| { + ( + config.restriction_level, + config.min_verification_level, + config.max_transfer_amount, + config.quota_period, + config.hold_period, + config.check_risk_level, + config.max_allowed_risk_level, + ) + }) + } + + /// Removes transfer restrictions for a token + #[ink(message)] + pub fn remove_transfer_restriction(&mut self, token_id: TokenId) -> Result<(), Error> { + let caller = self.env().caller(); + if caller != self.admin { + let owner = self.token_owner.get(token_id).ok_or(Error::TokenNotFound)?; + if caller != owner { + return Err(Error::Unauthorized); + } + } + + self.transfer_restrictions.remove(token_id); + + self.env().emit_event(TransferRestrictionRemoved { token_id }); + + Ok(()) + } + /// Links the canonical property-management contract (admin). #[ink(message)] pub fn set_property_management_contract( @@ -1000,6 +1401,40 @@ pub mod property_token { if amount == 0 { return Err(Error::InvalidAmount); } + let caller = self.env().caller(); + if caller != from && !self.is_approved_for_all(from, caller) { + return Err(Error::Unauthorized); + } + if !self.pass_compliance(from)? || !self.pass_compliance(to)? { + return Err(Error::ComplianceFailed); + } + + // Check KYC-based transfer restrictions for share transfers + self.verify_kyc_transfer(&from, &to, token_id, amount)?; + + let from_balance = self.balances.get((from, token_id)).unwrap_or(0); + if from_balance < amount { + return Err(Error::InsufficientBalance); + } + + // Update user transfer quota tracking + let mut quota = self.user_transfer_quotas.get((token_id, from)).unwrap_or(UserTransferQuota { + amount_transferred: 0, + period_start_block: self.env().block_number(), + acquisition_block: self.env().block_number(), + }); + + quota.amount_transferred = quota.amount_transferred.saturating_add(amount); + self.user_transfer_quotas.insert((token_id, from), "a); + + self.update_dividend_credit_on_change(from, token_id)?; + self.update_dividend_credit_on_change(to, token_id)?; + self.balances + .insert((from, token_id), &(from_balance.saturating_sub(amount))); + let to_balance = self.balances.get((to, token_id)).unwrap_or(0); + self.balances + .insert((to, token_id), &(to_balance.saturating_add(amount))); + Ok(()) non_reentrant!(self, { let caller = self.env().caller(); @@ -1504,6 +1939,303 @@ pub mod property_token { } } + /// Verifies KYC-based transfer restrictions for an NFT transfer + fn verify_kyc_transfer( + &mut self, + from: &AccountId, + to: &AccountId, + token_id: TokenId, + amount: u128, + ) -> Result<(), Error> { + // Check if account is blacklisted + if self.blacklist.get(from).unwrap_or(false) { + self.env().emit_event(KYCTransferRejected { + from: *from, + to: *to, + token_id, + reason: "Sender is blacklisted".to_string(), + }); + return Err(Error::AccountBlacklisted); + } + + if self.blacklist.get(to).unwrap_or(false) { + self.env().emit_event(KYCTransferRejected { + from: *from, + to: *to, + token_id, + reason: "Recipient is blacklisted".to_string(), + }); + return Err(Error::AccountBlacklisted); + } + + // Get verification levels for logging + let from_level = self.get_kyc_verification_level(from).unwrap_or(KYCVerificationLevel::None); + let to_level = self.get_kyc_verification_level(to).unwrap_or(KYCVerificationLevel::None); + + // Get transfer restrictions for this token + if let Some(config) = self.transfer_restrictions.get(token_id) { + // Check whitelist if enabled + if config.restriction_level == TransferRestrictionLevel::WhitelistOnly { + if !self.whitelist.get((token_id, *from)).unwrap_or(false) { + self.env().emit_event(KYCTransferRejected { + from: *from, + to: *to, + token_id, + reason: "Sender not whitelisted".to_string(), + }); + return Err(Error::AccountNotWhitelisted); + } + if !self.whitelist.get((token_id, *to)).unwrap_or(false) { + self.env().emit_event(KYCTransferRejected { + from: *from, + to: *to, + token_id, + reason: "Recipient not whitelisted".to_string(), + }); + return Err(Error::AccountNotWhitelisted); + } + } + + // Check verification level if required + if config.restriction_level != TransferRestrictionLevel::None { + if from_level < config.min_verification_level { + self.env().emit_event(KYCTransferRejected { + from: *from, + to: *to, + token_id, + reason: "Sender verification level insufficient".to_string(), + }); + return Err(Error::VerificationLevelInsufficient); + } + + if to_level < config.min_verification_level { + self.env().emit_event(KYCTransferRejected { + from: *from, + to: *to, + token_id, + reason: "Recipient verification level insufficient".to_string(), + }); + return Err(Error::VerificationLevelInsufficient); + } + } + + // Check transfer quota + if config.max_transfer_amount > 0 { + self.check_transfer_quota(from, token_id, amount, &config)?; + } + + // Check hold period + if config.hold_period > 0 { + self.check_hold_period(from, token_id, &config)?; + } + + // Check risk level + if config.check_risk_level { + self.check_risk_levels(from, to, config.max_allowed_risk_level)?; + } + } + + // Convert verification levels to u8 for event + let from_level_u8 = match from_level { + KYCVerificationLevel::None => 0, + KYCVerificationLevel::Basic => 1, + KYCVerificationLevel::Standard => 2, + KYCVerificationLevel::Enhanced => 3, + KYCVerificationLevel::Institutional => 4, + }; + + let to_level_u8 = match to_level { + KYCVerificationLevel::None => 0, + KYCVerificationLevel::Basic => 1, + KYCVerificationLevel::Standard => 2, + KYCVerificationLevel::Enhanced => 3, + KYCVerificationLevel::Institutional => 4, + }; + + self.env().emit_event(KYCTransferVerified { + from: *from, + to: *to, + token_id, + amount, + from_verification_level: from_level_u8, + to_verification_level: to_level_u8, + }); + + // Log to KYC transfer audit log + let timestamp = self.env().block_timestamp(); + let log_entry = KYCTransferEvent { + from: *from, + to: *to, + token_id, + amount, + timestamp, + from_verification_level: from_level, + to_verification_level: to_level, + }; + self.kyc_transfer_log.insert(self.kyc_transfer_log_counter, &log_entry); + self.kyc_transfer_log_counter = self.kyc_transfer_log_counter.saturating_add(1); + + Ok(()) + } + + /// Gets the KYC verification level for an account + fn get_kyc_verification_level( + &self, + account: &AccountId, + ) -> Result { + let current_block = self.env().block_number(); + + // Check cache first (cache for 100 blocks) + if let Some((cached_level, cached_block)) = self.kyc_verification_cache.get(account) { + if current_block.saturating_sub(cached_block) < 100 { + return Ok(cached_level); + } + } + + // Check compliance status using the compliance registry + let level = if let Some(registry) = self.compliance_registry { + use ink::env::call::FromAccountId; + let checker: ink::contract_ref!(propchain_traits::ComplianceChecker) = + FromAccountId::from_account_id(registry); + + // If compliant, assume Standard level; otherwise Basic + if checker.is_compliant(*account) { + KYCVerificationLevel::Standard + } else { + KYCVerificationLevel::Basic + } + } else { + // If no compliance registry, default to Basic + KYCVerificationLevel::Basic + }; + + Ok(level) + } + + /// Checks risk levels from compliance registry + fn check_risk_levels( + &self, + from: &AccountId, + to: &AccountId, + max_allowed_risk: u8, + ) -> Result<(), Error> { + // Check compliance for both sender and recipient + if let Some(registry) = self.compliance_registry { + use ink::env::call::FromAccountId; + let checker: ink::contract_ref!(propchain_traits::ComplianceChecker) = + FromAccountId::from_account_id(registry); + + if !checker.is_compliant(*from) { + return Err(Error::HighRiskAccount); + } + if !checker.is_compliant(*to) { + return Err(Error::HighRiskAccount); + } + } + + Ok(()) + } + + /// Checks transfer quota for an account + fn check_transfer_quota( + &self, + from: &AccountId, + token_id: TokenId, + amount: u128, + config: &TransferRestrictionConfig, + ) -> Result<(), Error> { + let quota = self.user_transfer_quotas.get((token_id, *from)); + let current_block = self.env().block_number(); + + if let Some(mut q) = quota { + // Check if period has expired + if current_block.saturating_sub(q.period_start_block) >= config.quota_period { + // New period, reset quota + q.amount_transferred = 0; + q.period_start_block = current_block; + } + + // Check if adding this amount exceeds quota + if q.amount_transferred.saturating_add(amount) > config.max_transfer_amount { + return Err(Error::TransferQuotaExceeded); + } + } else { + // First transfer, check against quota + if amount > config.max_transfer_amount { + return Err(Error::TransferQuotaExceeded); + } + } + + Ok(()) + } + + /// Checks hold period for an account + fn check_hold_period( + &self, + from: &AccountId, + token_id: TokenId, + config: &TransferRestrictionConfig, + ) -> Result<(), Error> { + if let Some(quota) = self.user_transfer_quotas.get((token_id, *from)) { + let current_block = self.env().block_number(); + let blocks_held = current_block.saturating_sub(quota.acquisition_block); + + if blocks_held < config.hold_period { + return Err(Error::HoldPeriodNotMet); + } + } + + Ok(()) + } + + /// Updates transfer quota for an account after a successful transfer + fn update_transfer_quota( + &mut self, + from: &AccountId, + to: &AccountId, + token_id: TokenId, + amount: u128, + ) -> Result<(), Error> { + let current_block = self.env().block_number(); + let config = match self.transfer_restrictions.get(token_id) { + Some(cfg) => cfg, + None => return Ok(()), // No quota tracking if no restrictions + }; + + // Update sender's quota + let mut from_quota = match self.user_transfer_quotas.get((token_id, *from)) { + Some(q) => q, + None => UserTransferQuota { + amount_transferred: 0, + period_start_block: current_block as u32, + acquisition_block: current_block as u32, + }, + }; + + // Check if period has expired and reset if needed + if current_block.saturating_sub(from_quota.period_start_block as u64) >= config.quota_period as u64 { + from_quota.amount_transferred = 0; + from_quota.period_start_block = current_block as u32; + } + + // Update amount transferred + from_quota.amount_transferred = from_quota.amount_transferred.saturating_add(amount); + self.user_transfer_quotas + .insert((token_id, *from), &from_quota); + + // Initialize recipient's quota if first transfer to them + if self.user_transfer_quotas.get((token_id, *to)).is_none() { + let to_quota = UserTransferQuota { + amount_transferred: 0, + period_start_block: current_block as u32, + acquisition_block: current_block as u32, + }; + self.user_transfer_quotas.insert((token_id, *to), &to_quota); + } + + Ok(()) + } + fn update_dividend_credit_on_change( &mut self, account: AccountId, @@ -3014,6 +3746,4 @@ pub mod property_token { } } - // Unit tests extracted to tests.rs (Issue #101) - include!("../tests.rs"); } diff --git a/contracts/property-token/src/types.rs b/contracts/property-token/src/types.rs index cc574aafb..1f196b8de 100644 --- a/contracts/property-token/src/types.rs +++ b/contracts/property-token/src/types.rs @@ -156,6 +156,93 @@ pub struct TaxRecord { pub proceeds: u128, } +/// KYC verification levels +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum KYCVerificationLevel { + /// No KYC verification + None = 0, + /// Basic KYC with document verification + Basic = 1, + /// Standard KYC with AML and sanctions checks + Standard = 2, + /// Enhanced KYC with biometric and risk assessment + Enhanced = 3, + /// Institutional verification with full due diligence + Institutional = 4, +} + +/// Transfer restriction levels/types +#[derive( + Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub enum TransferRestrictionLevel { + /// No restrictions + None, + /// Only KYC verified users can transfer + KYCRequired, + /// Requires specific verification level + VerificationLevelRequired, + /// Whitelist only transfers + WhitelistOnly, + /// Blacklist prevents transfers + BlacklistBased, +} + +/// Per-token transfer restrictions configuration +#[derive( + Debug, Clone, Copy, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct TransferRestrictionConfig { + /// Restriction level for this token + pub restriction_level: TransferRestrictionLevel, + /// Minimum KYC verification level required + pub min_verification_level: KYCVerificationLevel, + /// Maximum transfer amount per period (0 = unlimited) + pub max_transfer_amount: u128, + /// Period for transfer quota (in blocks) + pub quota_period: u32, + /// Minimum hold period before transfer allowed (in blocks) + pub hold_period: u32, + /// Enable risk level checking + pub check_risk_level: bool, + /// Maximum allowed risk level (0-100) + pub max_allowed_risk_level: u8, +} + +/// User transfer quota tracking +#[derive( + Debug, Clone, Copy, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct UserTransferQuota { + /// Total amount transferred in current period + pub amount_transferred: u128, + /// Block when the current period started + pub period_start_block: u32, + /// Block when the user first acquired this token + pub acquisition_block: u32, +} + +/// KYC transfer event for audit logging +#[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct KYCTransferEvent { + pub from: AccountId, + pub to: AccountId, + pub token_id: TokenId, + pub amount: u128, + pub timestamp: u64, + pub from_verification_level: KYCVerificationLevel, + pub to_verification_level: KYCVerificationLevel, +} + #[derive( Debug, Clone, @@ -190,6 +277,7 @@ pub struct VestingSchedule { pub vesting_duration: u64, } + /// Snapshot for governance voting (Issue #194) #[derive( Debug, diff --git a/contracts/traits/src/errors.rs b/contracts/traits/src/errors.rs index d98675139..0ec398387 100644 --- a/contracts/traits/src/errors.rs +++ b/contracts/traits/src/errors.rs @@ -268,6 +268,16 @@ pub mod property_token_codes { pub const PROPOSAL_CLOSED: u32 = 1023; pub const ASK_NOT_FOUND: u32 = 1024; pub const BATCH_SIZE_EXCEEDED: u32 = 1025; + // KYC-based transfer restriction error codes + pub const SENDER_NOT_VERIFIED: u32 = 1026; + pub const RECIPIENT_NOT_VERIFIED: u32 = 1027; + pub const VERIFICATION_LEVEL_INSUFFICIENT: u32 = 1028; + pub const TRANSFER_QUOTA_EXCEEDED: u32 = 1029; + pub const ACCOUNT_BLACKLISTED: u32 = 1030; + pub const ACCOUNT_NOT_WHITELISTED: u32 = 1031; + pub const HOLD_PERIOD_NOT_MET: u32 = 1032; + pub const SENDER_RISK_LEVEL_TOO_HIGH: u32 = 1033; + pub const RECIPIENT_RISK_LEVEL_TOO_HIGH: u32 = 1034; pub const STAKE_NOT_FOUND: u32 = 1026; pub const LOCK_ACTIVE: u32 = 1027; pub const NO_REWARDS: u32 = 1028; diff --git a/tests/property_token_tests.rs b/tests/property_token_tests.rs index d4f5496ca..896c0ee7b 100644 --- a/tests/property_token_tests.rs +++ b/tests/property_token_tests.rs @@ -320,6 +320,420 @@ mod property_token_tests { let result = contract.approve(accounts.bob, 999); assert_eq!(result, Err(Error::TokenNotFound)); } +<<<<<<< HEAD + + // ============================================================ + // KYC-Based Transfer Restriction Tests + // ============================================================ + + #[ink::test] + fn test_set_transfer_restriction() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Set transfer restrictions + let result = contract.set_transfer_restriction( + token_id, + property_token::TransferRestrictionLevel::VerificationLevelRequired, + property_token::KYCVerificationLevel::Standard, + 1000, // max_transfer_amount + 100, // quota_period in blocks + 10, // hold_period in blocks + false, // check_risk_level + 50, // max_allowed_risk_level + ); + assert!(result.is_ok()); + } + + #[ink::test] + fn test_get_transfer_restriction_config() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Initially no restrictions + assert_eq!(contract.get_transfer_restriction_config(token_id), None); + + // Set restrictions + let result = contract.set_transfer_restriction( + token_id, + property_token::TransferRestrictionLevel::KYCRequired, + property_token::KYCVerificationLevel::Basic, + 5000, + 200, + 20, + true, + 75, + ); + assert!(result.is_ok()); + + // Get restrictions + let config = contract.get_transfer_restriction_config(token_id); + assert!(config.is_some()); + + let (restriction_level, min_level, max_amount, quota_period, hold_period, check_risk, max_risk) = config.unwrap(); + assert_eq!(min_level, property_token::KYCVerificationLevel::Basic); + assert_eq!(max_amount, 5000); + assert_eq!(quota_period, 200); + assert_eq!(hold_period, 20); + assert_eq!(check_risk, true); + assert_eq!(max_risk, 75); + } + + #[ink::test] + fn test_remove_transfer_restriction() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Set restrictions + let result = contract.set_transfer_restriction( + token_id, + property_token::TransferRestrictionLevel::WhitelistOnly, + property_token::KYCVerificationLevel::Enhanced, + 2000, + 150, + 15, + false, + 60, + ); + assert!(result.is_ok()); + assert!(contract.get_transfer_restriction_config(token_id).is_some()); + + // Remove restrictions + let result = contract.remove_transfer_restriction(token_id); + assert!(result.is_ok()); + assert_eq!(contract.get_transfer_restriction_config(token_id), None); + } + + #[ink::test] + fn test_blacklist_and_whitelist_operations() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Test blacklist + assert!(!contract.is_account_blacklisted(accounts.bob)); + let result = contract.blacklist_account(accounts.bob); + assert!(result.is_ok()); + assert!(contract.is_account_blacklisted(accounts.bob)); + + // Remove from blacklist + let result = contract.remove_from_blacklist(accounts.bob); + assert!(result.is_ok()); + assert!(!contract.is_account_blacklisted(accounts.bob)); + + // Test whitelist + assert!(!contract.is_account_whitelisted(token_id, accounts.charlie)); + let result = contract.whitelist_account(token_id, accounts.charlie); + assert!(result.is_ok()); + assert!(contract.is_account_whitelisted(token_id, accounts.charlie)); + + // Remove from whitelist + let result = contract.remove_from_whitelist(token_id, accounts.charlie); + assert!(result.is_ok()); + assert!(!contract.is_account_whitelisted(token_id, accounts.charlie)); + } + + #[ink::test] + fn test_transfer_with_no_restrictions() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Transfer should succeed without any restrictions set + let result = contract.transfer_from(accounts.alice, accounts.bob, token_id); + assert!(result.is_ok()); + assert_eq!(contract.owner_of(token_id), Some(accounts.bob)); + } + + #[ink::test] + fn test_transfer_quota_tracking() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Set transfer restrictions with quota + let result = contract.set_transfer_restriction( + token_id, + property_token::TransferRestrictionLevel::None, + property_token::KYCVerificationLevel::None, + 100, // max_transfer_amount + 1000, // quota_period + 0, // no hold period + false, + 0, + ); + assert!(result.is_ok()); + + // Transfer and check quota + test::set_caller::(accounts.alice); + let result = contract.transfer_from(accounts.alice, accounts.bob, token_id); + assert!(result.is_ok()); + + // Check transfer quota status (returns (amount_transferred, period_start_block, acquisition_block)) + let quota_status = contract.get_transfer_quota_status(token_id, accounts.alice); + assert!(quota_status.is_some()); + let (amount_transferred, _, _) = quota_status.unwrap(); + assert_eq!(amount_transferred, 1); // We transferred 1 share + } + + #[ink::test] + fn test_blacklist_prevents_transfer() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Blacklist bob + let result = contract.blacklist_account(accounts.bob); + assert!(result.is_ok()); + + // Try to transfer to blacklisted account + let result = contract.transfer_from(accounts.alice, accounts.bob, token_id); + assert_eq!(result, Err(Error::AccountBlacklisted)); + } + + #[ink::test] + fn test_whitelist_only_restriction() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Set whitelist-only restriction + let result = contract.set_transfer_restriction( + token_id, + property_token::TransferRestrictionLevel::WhitelistOnly, + property_token::KYCVerificationLevel::None, + 0, + 0, + 0, + false, + 0, + ); + assert!(result.is_ok()); + + // Whitelist alice and bob + let result = contract.whitelist_account(token_id, accounts.alice); + assert!(result.is_ok()); + let result = contract.whitelist_account(token_id, accounts.bob); + assert!(result.is_ok()); + + // Transfer should succeed (both are whitelisted) + let result = contract.transfer_from(accounts.alice, accounts.bob, token_id); + assert!(result.is_ok()); + } + + #[ink::test] + fn test_whitelist_only_rejection() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Set whitelist-only restriction + let result = contract.set_transfer_restriction( + token_id, + property_token::TransferRestrictionLevel::WhitelistOnly, + property_token::KYCVerificationLevel::None, + 0, + 0, + 0, + false, + 0, + ); + assert!(result.is_ok()); + + // Only whitelist alice + let result = contract.whitelist_account(token_id, accounts.alice); + assert!(result.is_ok()); + + // Transfer should fail (bob is not whitelisted) + let result = contract.transfer_from(accounts.alice, accounts.bob, token_id); + assert_eq!(result, Err(Error::AccountNotWhitelisted)); + } + + #[ink::test] + fn test_batch_transfer_with_kyc_restrictions() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + // Register multiple tokens + let token_id1 = contract.register_property_with_token(metadata.clone()).unwrap(); + let token_id2 = contract.register_property_with_token(metadata).unwrap(); + + // Set restrictions on both + for token_id in &[token_id1, token_id2] { + let result = contract.set_transfer_restriction( + *token_id, + property_token::TransferRestrictionLevel::None, + property_token::KYCVerificationLevel::None, + 0, + 0, + 0, + false, + 0, + ); + assert!(result.is_ok()); + } + + // Batch transfer - should succeed + let ids = vec![token_id1, token_id2]; + let amounts = vec![1, 1]; + let result = contract.safe_batch_transfer_from( + accounts.alice, + accounts.bob, + ids, + amounts, + vec![], + ); + assert!(result.is_ok()); + } + + #[ink::test] + fn test_batch_transfer_with_blacklist() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + let token_id1 = contract.register_property_with_token(metadata.clone()).unwrap(); + let token_id2 = contract.register_property_with_token(metadata).unwrap(); + + // Blacklist bob + let result = contract.blacklist_account(accounts.bob); + assert!(result.is_ok()); + + // Batch transfer to blacklisted account should fail + let ids = vec![token_id1, token_id2]; + let amounts = vec![1, 1]; + let result = contract.safe_batch_transfer_from( + accounts.alice, + accounts.bob, + ids, + amounts, + vec![], + ); + assert_eq!(result, Err(Error::AccountBlacklisted)); + } +} +======= } #[ink::test] fn test_batch_transfer_success() { @@ -391,3 +805,4 @@ fn test_batch_transfer_unauthorized() { ); assert_eq!(result, Err(Error::Unauthorized)); } +>>>>>>> origin/main