diff --git a/Cargo.lock b/Cargo.lock index 405da59..c02abea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1858,6 +1858,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fractional" +version = "0.1.0" +dependencies = [ + "ink 5.1.1", + "parity-scale-codec", + "propchain-traits", + "scale-info", +] + [[package]] name = "frame-benchmarking" version = "32.0.0" diff --git a/Cargo.toml b/Cargo.toml index aa31d90..d9e82cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "contracts/analytics", "contracts/fees", "contracts/compliance_registry", + "contracts/fractional", ] resolver = "2" diff --git a/contracts/analytics/src/lib.rs b/contracts/analytics/src/lib.rs index dc086f9..72924a6 100644 --- a/contracts/analytics/src/lib.rs +++ b/contracts/analytics/src/lib.rs @@ -21,6 +21,7 @@ mod propchain_analytics { /// Portfolio performance for an individual owner. #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout)] #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + #[allow(dead_code)] pub struct PortfolioPerformance { pub total_value: u128, pub property_count: u64, @@ -40,6 +41,7 @@ mod propchain_analytics { /// User behavior analytics for a specific account. #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout)] #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + #[allow(dead_code)] pub struct UserBehavior { pub account: AccountId, pub total_interactions: u64, diff --git a/contracts/fractional/Cargo.toml b/contracts/fractional/Cargo.toml new file mode 100644 index 0000000..b23c7ee --- /dev/null +++ b/contracts/fractional/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "fractional" +version = "0.1.0" +edition = "2021" +authors = ["PropChain Team "] +license = "MIT" +publish = false + +[lib] +name = "fractional" +path = "src/lib.rs" +crate-type = ["cdylib", "rlib"] + +[dependencies] +ink = { version = "5.0.0", default-features = false } +scale = { package = "parity-scale-codec", version = "3.6.9", default-features = false, features = ["derive"] } +scale-info = { version = "2.10.0", default-features = false, features = ["derive"] } +propchain_traits = { package = "propchain-traits", path = "../traits", default-features = false } + +[features] +default = ["std"] +std = [ + "ink/std", + "scale/std", + "scale-info/std", + "propchain_traits/std", +] +ink-as-dependency = [] diff --git a/contracts/fractional/src/lib.rs b/contracts/fractional/src/lib.rs new file mode 100644 index 0000000..a4566fd --- /dev/null +++ b/contracts/fractional/src/lib.rs @@ -0,0 +1,120 @@ +#![cfg_attr(not(feature = "std"), no_std, no_main)] + +#[ink::contract] +mod fractional { + use ink::prelude::vec::Vec; + use ink::storage::Mapping; + + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct PortfolioItem { + pub token_id: u64, + pub shares: u128, + pub price_per_share: u128, + } + + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct PortfolioAggregation { + pub total_value: u128, + pub positions: Vec<(u64, u128, u128)>, + } + + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct TaxReport { + pub total_dividends: u128, + pub total_proceeds: u128, + pub transactions: u64, + } + + #[ink(storage)] + pub struct Fractional { + last_prices: Mapping, + } + + impl Fractional { + #[ink(constructor)] + pub fn new() -> Self { + Self { + last_prices: Mapping::default(), + } + } + + #[ink(message)] + pub fn set_last_price(&mut self, token_id: u64, price_per_share: u128) { + self.last_prices.insert(token_id, &price_per_share); + } + + #[ink(message)] + pub fn get_last_price(&self, token_id: u64) -> Option { + self.last_prices.get(token_id) + } + + #[ink(message)] + pub fn aggregate_portfolio(&self, items: Vec) -> PortfolioAggregation { + let mut total: u128 = 0; + let mut positions: Vec<(u64, u128, u128)> = Vec::new(); + for it in items.iter() { + let price = if it.price_per_share > 0 { + it.price_per_share + } else { + self.last_prices.get(it.token_id).unwrap_or(0) + }; + let value = price.saturating_mul(it.shares); + total = total.saturating_add(value); + positions.push((it.token_id, it.shares, price)); + } + PortfolioAggregation { + total_value: total, + positions, + } + } + + #[ink(message)] + pub fn summarize_tax( + &self, + dividends: Vec<(u64, u128)>, + proceeds: Vec<(u64, u128)>, + ) -> TaxReport { + let mut total_dividends: u128 = 0; + for d in dividends.iter() { + total_dividends = total_dividends.saturating_add(d.1); + } + let mut total_proceeds: u128 = 0; + for p in proceeds.iter() { + total_proceeds = total_proceeds.saturating_add(p.1); + } + TaxReport { + total_dividends, + total_proceeds, + transactions: (dividends.len() + proceeds.len()) as u64, + } + } + } +} + diff --git a/contracts/lib/src/lib.rs b/contracts/lib/src/lib.rs index 06bb6be..edbbaa8 100644 --- a/contracts/lib/src/lib.rs +++ b/contracts/lib/src/lib.rs @@ -92,6 +92,8 @@ mod propchain_contracts { oracle: Option, /// Fee manager contract for dynamic fees and market mechanism (optional) fee_manager: Option, + /// Fractional properties info + fractional: Mapping, } /// Escrow information @@ -145,6 +147,16 @@ mod propchain_contracts { pub registered_at: u64, } + #[derive( + Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct FractionalInfo { + pub total_shares: u128, + pub enabled: bool, + pub created_at: u64, + } + /// Global analytics data #[derive( Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, @@ -775,6 +787,7 @@ mod propchain_contracts { pause_guardians: Mapping::default(), oracle: None, fee_manager: None, + fractional: Mapping::default(), }; // Emit contract initialization event @@ -2519,6 +2532,44 @@ mod propchain_contracts { self.refund_escrow(escrow_id) } } + + impl PropertyRegistry { + #[ink(message)] + pub fn enable_fractional( + &mut self, + property_id: u64, + total_shares: u128, + ) -> Result<(), Error> { + let caller = self.env().caller(); + let property = self.properties.get(property_id).ok_or(Error::PropertyNotFound)?; + if caller != self.admin && caller != property.owner { + return Err(Error::Unauthorized); + } + if total_shares == 0 { + return Err(Error::InvalidMetadata); + } + let info = FractionalInfo { + total_shares, + enabled: true, + created_at: self.env().block_timestamp(), + }; + self.fractional.insert(property_id, &info); + Ok(()) + } + + #[ink(message)] + pub fn get_fractional_info(&self, property_id: u64) -> Option { + self.fractional.get(property_id) + } + + #[ink(message)] + pub fn is_fractional(&self, property_id: u64) -> bool { + self.fractional + .get(property_id) + .map(|i: FractionalInfo| i.enabled) + .unwrap_or(false) + } + } } #[cfg(test)] diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 2c8a052..3f5e3d8 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -523,7 +523,7 @@ mod propchain_oracle { } OracleSourceType::AIModel => { // AI model integration - call AI valuation contract - if let Some(ai_contract) = self.ai_valuation_contract { + if let Some(_ai_contract) = self.ai_valuation_contract { // In production, this would make a cross-contract call to AI valuation engine // For now, return a mock price based on property_id let mock_price = 500000u128 + (property_id as u128 * 1000); diff --git a/contracts/property-token/src/lib.rs b/contracts/property-token/src/lib.rs index bbe85e7..5b95f1f 100644 --- a/contracts/property-token/src/lib.rs +++ b/contracts/property-token/src/lib.rs @@ -37,6 +37,11 @@ mod property_token { DuplicateBridgeRequest, BridgeTimeout, AlreadySigned, + InsufficientBalance, + InvalidAmount, + ProposalNotFound, + ProposalClosed, + AskNotFound, } /// Property Token contract that maintains compatibility with ERC-721 and ERC-1155 @@ -79,6 +84,19 @@ mod property_token { error_rates: Mapping, // (count, window_start) recent_errors: Mapping, error_log_counter: u64, + + total_shares: Mapping, + dividends_per_share: Mapping, + dividend_credit: Mapping<(AccountId, TokenId), u128>, + dividend_balance: Mapping<(AccountId, TokenId), u128>, + proposal_counter: Mapping, + proposals: Mapping<(TokenId, u64), Proposal>, + votes_cast: Mapping<(TokenId, u64, AccountId), bool>, + asks: Mapping<(TokenId, AccountId), Ask>, + escrowed_shares: Mapping<(TokenId, AccountId), u128>, + last_trade_price: Mapping, + compliance_registry: Option, + tax_records: Mapping<(AccountId, TokenId), TaxRecord>, } /// Token ID type alias @@ -165,6 +183,54 @@ mod property_token { pub context: Vec<(String, String)>, } + #[derive( + Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct Proposal { + pub id: u64, + pub token_id: TokenId, + pub description_hash: Hash, + pub quorum: u128, + pub for_votes: u128, + pub against_votes: u128, + pub status: ProposalStatus, + pub created_at: u64, + } + + #[derive( + Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum ProposalStatus { + Open, + Executed, + Rejected, + Closed, + } + + #[derive( + Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct Ask { + pub token_id: TokenId, + pub seller: AccountId, + pub price_per_share: u128, + pub amount: u128, + pub created_at: u64, + } + + #[derive( + Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct TaxRecord { + pub dividends_received: u128, + pub shares_sold: u128, + pub proceeds: u128, + } + // Events for tracking property token operations #[ink(event)] pub struct Transfer { @@ -287,6 +353,101 @@ mod property_token { pub recovery_action: RecoveryAction, } + #[ink(event)] + pub struct SharesIssued { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub to: AccountId, + pub amount: u128, + } + + #[ink(event)] + pub struct SharesRedeemed { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub from: AccountId, + pub amount: u128, + } + + #[ink(event)] + pub struct DividendsDeposited { + #[ink(topic)] + pub token_id: TokenId, + pub amount: u128, + pub per_share: u128, + } + + #[ink(event)] + pub struct DividendsWithdrawn { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub account: AccountId, + pub amount: u128, + } + + #[ink(event)] + pub struct ProposalCreated { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub proposal_id: u64, + pub quorum: u128, + } + + #[ink(event)] + pub struct Voted { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub proposal_id: u64, + #[ink(topic)] + pub voter: AccountId, + pub support: bool, + pub weight: u128, + } + + #[ink(event)] + pub struct ProposalExecuted { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub proposal_id: u64, + pub passed: bool, + } + + #[ink(event)] + pub struct AskPlaced { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub seller: AccountId, + pub price_per_share: u128, + pub amount: u128, + } + + #[ink(event)] + pub struct AskCancelled { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub seller: AccountId, + } + + #[ink(event)] + pub struct SharesPurchased { + #[ink(topic)] + pub token_id: TokenId, + #[ink(topic)] + pub seller: AccountId, + #[ink(topic)] + pub buyer: AccountId, + pub amount: u128, + pub price_per_share: u128, + } + impl PropertyToken { /// Creates a new PropertyToken contract #[ink(constructor)] @@ -341,6 +502,19 @@ mod property_token { error_rates: Mapping::default(), recent_errors: Mapping::default(), error_log_counter: 0, + + total_shares: Mapping::default(), + dividends_per_share: Mapping::default(), + dividend_credit: Mapping::default(), + dividend_balance: Mapping::default(), + proposal_counter: Mapping::default(), + proposals: Mapping::default(), + votes_cast: Mapping::default(), + asks: Mapping::default(), + escrowed_shares: Mapping::default(), + last_trade_price: Mapping::default(), + compliance_registry: None, + tax_records: Mapping::default(), } } @@ -576,6 +750,438 @@ mod property_token { )) } + #[ink(message)] + pub fn set_compliance_registry(&mut self, registry: AccountId) -> Result<(), Error> { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + self.compliance_registry = Some(registry); + Ok(()) + } + + #[ink(message)] + pub fn total_shares(&self, token_id: TokenId) -> u128 { + self.total_shares.get(token_id).unwrap_or(0) + } + + #[ink(message)] + pub fn share_balance_of(&self, owner: AccountId, token_id: TokenId) -> u128 { + self.balances.get((owner, token_id)).unwrap_or(0) + } + + #[ink(message)] + pub fn issue_shares( + &mut self, + token_id: TokenId, + to: AccountId, + amount: u128, + ) -> Result<(), Error> { + if amount == 0 { + return Err(Error::InvalidAmount); + } + let caller = self.env().caller(); + let owner = self.token_owner.get(token_id).ok_or(Error::TokenNotFound)?; + if caller != self.admin && caller != owner { + return Err(Error::Unauthorized); + } + let bal = self.balances.get((to, token_id)).unwrap_or(0); + self.balances.insert((to, token_id), &(bal.saturating_add(amount))); + let ts = self.total_shares.get(token_id).unwrap_or(0); + self.total_shares + .insert(token_id, &(ts.saturating_add(amount))); + self.update_dividend_credit_on_change(to, token_id)?; + self.env().emit_event(SharesIssued { token_id, to, amount }); + Ok(()) + } + + #[ink(message)] + pub fn redeem_shares( + &mut self, + token_id: TokenId, + from: AccountId, + amount: u128, + ) -> Result<(), Error> { + 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); + } + let bal = self.balances.get((from, token_id)).unwrap_or(0); + if bal < amount { + return Err(Error::InsufficientBalance); + } + self.balances + .insert((from, token_id), &(bal.saturating_sub(amount))); + let ts = self.total_shares.get(token_id).unwrap_or(0); + self.total_shares + .insert(token_id, &(ts.saturating_sub(amount))); + self.update_dividend_credit_on_change(from, token_id)?; + self.env().emit_event(SharesRedeemed { + token_id, + from, + amount, + }); + Ok(()) + } + + #[ink(message)] + pub fn transfer_shares( + &mut self, + from: AccountId, + to: AccountId, + token_id: TokenId, + amount: u128, + ) -> Result<(), Error> { + 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); + } + let from_balance = self.balances.get((from, token_id)).unwrap_or(0); + if from_balance < amount { + return Err(Error::InsufficientBalance); + } + 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(()) + } + + #[ink(message, payable)] + pub fn deposit_dividends(&mut self, token_id: TokenId) -> Result<(), Error> { + let value = self.env().transferred_value(); + if value == 0 { + return Err(Error::InvalidAmount); + } + let ts = self.total_shares.get(token_id).unwrap_or(0); + if ts == 0 { + return Err(Error::InvalidRequest); + } + let scaling: u128 = 1_000_000_000_000; + let add = value.saturating_mul(scaling) / ts; + let cur = self.dividends_per_share.get(token_id).unwrap_or(0); + let new = cur.saturating_add(add); + self.dividends_per_share.insert(token_id, &new); + self.env().emit_event(DividendsDeposited { + token_id, + amount: value, + per_share: add, + }); + Ok(()) + } + + #[ink(message)] + pub fn withdraw_dividends(&mut self, token_id: TokenId) -> Result { + let caller = self.env().caller(); + self.update_dividend_credit_on_change(caller, token_id)?; + let owed = self.dividend_balance.get((caller, token_id)).unwrap_or(0); + if owed == 0 { + return Ok(0); + } + self.dividend_balance.insert((caller, token_id), &0u128); + match self.env().transfer(caller, owed) { + Ok(_) => { + let mut rec = self.tax_records.get((caller, token_id)).unwrap_or(TaxRecord { + dividends_received: 0, + shares_sold: 0, + proceeds: 0, + }); + rec.dividends_received = rec.dividends_received.saturating_add(owed); + self.tax_records.insert((caller, token_id), &rec); + self.env().emit_event(DividendsWithdrawn { + token_id, + account: caller, + amount: owed, + }); + Ok(owed) + } + Err(_) => Err(Error::InvalidRequest), + } + } + + #[ink(message)] + pub fn create_proposal( + &mut self, + token_id: TokenId, + quorum: u128, + description_hash: Hash, + ) -> Result { + let owner = self.token_owner.get(token_id).ok_or(Error::TokenNotFound)?; + let caller = self.env().caller(); + if caller != self.admin && caller != owner { + return Err(Error::Unauthorized); + } + let counter = self.proposal_counter.get(token_id).unwrap_or(0) + 1; + self.proposal_counter.insert(token_id, &counter); + let proposal = Proposal { + id: counter, + token_id, + description_hash, + quorum, + for_votes: 0, + against_votes: 0, + status: ProposalStatus::Open, + created_at: self.env().block_timestamp(), + }; + self.proposals.insert((token_id, counter), &proposal); + self.env().emit_event(ProposalCreated { + token_id, + proposal_id: counter, + quorum, + }); + Ok(counter) + } + + #[ink(message)] + pub fn vote( + &mut self, + token_id: TokenId, + proposal_id: u64, + support: bool, + ) -> Result<(), Error> { + let mut proposal = self + .proposals + .get((token_id, proposal_id)) + .ok_or(Error::ProposalNotFound)?; + if proposal.status != ProposalStatus::Open { + return Err(Error::ProposalClosed); + } + let voter = self.env().caller(); + if self.votes_cast.get((token_id, proposal_id, voter)).unwrap_or(false) { + return Err(Error::Unauthorized); + } + let weight = self.balances.get((voter, token_id)).unwrap_or(0); + if support { + proposal.for_votes = proposal.for_votes.saturating_add(weight); + } else { + proposal.against_votes = proposal.against_votes.saturating_add(weight); + } + self.proposals.insert((token_id, proposal_id), &proposal); + self.votes_cast.insert((token_id, proposal_id, voter), &true); + self.env().emit_event(Voted { + token_id, + proposal_id, + voter, + support, + weight, + }); + Ok(()) + } + + #[ink(message)] + pub fn execute_proposal(&mut self, token_id: TokenId, proposal_id: u64) -> Result { + let mut proposal = self + .proposals + .get((token_id, proposal_id)) + .ok_or(Error::ProposalNotFound)?; + if proposal.status != ProposalStatus::Open { + return Err(Error::ProposalClosed); + } + let passed = proposal.for_votes >= proposal.quorum && proposal.for_votes > proposal.against_votes; + proposal.status = if passed { + ProposalStatus::Executed + } else { + ProposalStatus::Rejected + }; + self.proposals.insert((token_id, proposal_id), &proposal); + self.env().emit_event(ProposalExecuted { + token_id, + proposal_id, + passed, + }); + Ok(passed) + } + + #[ink(message)] + pub fn place_ask( + &mut self, + token_id: TokenId, + price_per_share: u128, + amount: u128, + ) -> Result<(), Error> { + if price_per_share == 0 || amount == 0 { + return Err(Error::InvalidAmount); + } + let seller = self.env().caller(); + let bal = self.balances.get((seller, token_id)).unwrap_or(0); + if bal < amount { + return Err(Error::InsufficientBalance); + } + let esc = self.escrowed_shares.get((token_id, seller)).unwrap_or(0); + self.escrowed_shares + .insert((token_id, seller), &(esc.saturating_add(amount))); + self.balances + .insert((seller, token_id), &(bal.saturating_sub(amount))); + let ask = Ask { + token_id, + seller, + price_per_share, + amount, + created_at: self.env().block_timestamp(), + }; + self.asks.insert((token_id, seller), &ask); + self.env().emit_event(AskPlaced { + token_id, + seller, + price_per_share, + amount, + }); + Ok(()) + } + + #[ink(message)] + pub fn cancel_ask(&mut self, token_id: TokenId) -> Result<(), Error> { + let seller = self.env().caller(); + let _ask = self.asks.get((token_id, seller)).ok_or(Error::AskNotFound)?; + let esc = self.escrowed_shares.get((token_id, seller)).unwrap_or(0); + let bal = self.balances.get((seller, token_id)).unwrap_or(0); + self.balances + .insert((seller, token_id), &(bal.saturating_add(esc))); + self.escrowed_shares.insert((token_id, seller), &0u128); + self.asks.remove((token_id, seller)); + self.env().emit_event(AskCancelled { token_id, seller }); + Ok(()) + } + + #[ink(message, payable)] + pub fn buy_shares( + &mut self, + token_id: TokenId, + seller: AccountId, + amount: u128, + ) -> Result<(), Error> { + if amount == 0 { + return Err(Error::InvalidAmount); + } + let ask = self.asks.get((token_id, seller)).ok_or(Error::AskNotFound)?; + if ask.amount < amount { + return Err(Error::InvalidAmount); + } + let cost = ask.price_per_share.saturating_mul(amount); + let paid = self.env().transferred_value(); + if paid != cost { + return Err(Error::InvalidAmount); + } + let buyer = self.env().caller(); + if !self.pass_compliance(buyer)? || !self.pass_compliance(seller)? { + return Err(Error::ComplianceFailed); + } + let esc = self.escrowed_shares.get((token_id, seller)).unwrap_or(0); + if esc < amount { + return Err(Error::AskNotFound); + } + let to_balance = self.balances.get((buyer, token_id)).unwrap_or(0); + self.balances + .insert((buyer, token_id), &(to_balance.saturating_add(amount))); + self.escrowed_shares + .insert((token_id, seller), &(esc.saturating_sub(amount))); + match self.env().transfer(seller, cost) { + Ok(_) => { + let mut rec = self.tax_records.get((seller, token_id)).unwrap_or(TaxRecord { + dividends_received: 0, + shares_sold: 0, + proceeds: 0, + }); + rec.shares_sold = rec.shares_sold.saturating_add(amount); + rec.proceeds = rec.proceeds.saturating_add(cost); + self.tax_records.insert((seller, token_id), &rec); + } + Err(_) => return Err(Error::InvalidRequest), + } + self.last_trade_price.insert(token_id, &ask.price_per_share); + if ask.amount == amount { + self.asks.remove((token_id, seller)); + } else { + let mut new_ask = ask.clone(); + new_ask.amount = ask.amount.saturating_sub(amount); + self.asks.insert((token_id, seller), &new_ask); + } + self.env().emit_event(SharesPurchased { + token_id, + seller, + buyer, + amount, + price_per_share: ask.price_per_share, + }); + Ok(()) + } + + #[ink(message)] + pub fn get_last_trade_price(&self, token_id: TokenId) -> Option { + self.last_trade_price.get(token_id) + } + + #[ink(message)] + pub fn get_portfolio( + &self, + owner: AccountId, + token_ids: Vec, + ) -> Vec<(TokenId, u128, u128)> { + let mut out = Vec::new(); + for t in token_ids.iter() { + let bal = self.balances.get((owner, *t)).unwrap_or(0); + let price = self.last_trade_price.get(*t).unwrap_or(0); + out.push((*t, bal, price)); + } + out + } + + #[ink(message)] + pub fn get_tax_record(&self, owner: AccountId, token_id: TokenId) -> TaxRecord { + self.tax_records + .get((owner, token_id)) + .unwrap_or(TaxRecord { + dividends_received: 0, + shares_sold: 0, + proceeds: 0, + }) + } + + fn pass_compliance(&self, account: AccountId) -> Result { + 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); + Ok(checker.is_compliant(account)) + } else { + Ok(true) + } + } + + fn update_dividend_credit_on_change( + &mut self, + account: AccountId, + token_id: TokenId, + ) -> Result<(), Error> { + let scaling: u128 = 1_000_000_000_000; + let dps = self.dividends_per_share.get(token_id).unwrap_or(0); + let credited = self.dividend_credit.get((account, token_id)).unwrap_or(0); + if dps > credited { + let bal = self.balances.get((account, token_id)).unwrap_or(0); + let mut owed = self.dividend_balance.get((account, token_id)).unwrap_or(0); + let delta = dps.saturating_sub(credited); + let add = bal.saturating_mul(delta) / scaling; + owed = owed.saturating_add(add); + self.dividend_balance.insert((account, token_id), &owed); + self.dividend_credit.insert((account, token_id), &dps); + } else if credited == 0 && dps > 0 { + self.dividend_credit.insert((account, token_id), &dps); + } + Ok(()) + } + /// Property-specific: Registers a property and mints a token #[ink(message)] pub fn register_property_with_token( diff --git a/tests/fractional_ownership_tests.rs b/tests/fractional_ownership_tests.rs new file mode 100644 index 0000000..4cb6b05 --- /dev/null +++ b/tests/fractional_ownership_tests.rs @@ -0,0 +1,86 @@ +#[cfg(test)] +mod fractional_tests { + use ink::env::{DefaultEnvironment, test}; + use crate::property_token::{PropertyToken, PropertyMetadata, Error}; + use ink::primitives::Hash; + + fn sample_metadata() -> PropertyMetadata { + PropertyMetadata { + location: String::from("Fractional Ave"), + size: 1200, + legal_description: String::from("Fractional Property"), + valuation: 1_000_000, + documents_url: String::from("ipfs://docs"), + } + } + + #[ink::test] + fn test_issue_and_transfer_shares() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + let token_id = contract.register_property_with_token(sample_metadata()).unwrap(); + + assert!(contract.issue_shares(token_id, accounts.alice, 1_000).is_ok()); + assert_eq!(contract.total_shares(token_id), 1_000); + assert_eq!(contract.share_balance_of(accounts.alice, token_id), 1_000); + + assert!(contract.transfer_shares(accounts.alice, accounts.bob, token_id, 400).is_ok()); + assert_eq!(contract.share_balance_of(accounts.alice, token_id), 600); + assert_eq!(contract.share_balance_of(accounts.bob, token_id), 400); + } + + #[ink::test] + fn test_dividends_flow() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + let token_id = contract.register_property_with_token(sample_metadata()).unwrap(); + + assert!(contract.issue_shares(token_id, accounts.alice, 1_000).is_ok()); + assert!(contract.transfer_shares(accounts.alice, accounts.bob, token_id, 500).is_ok()); + + test::set_value_transferred::(1_000_000); + assert!(contract.deposit_dividends(token_id).is_ok()); + + test::set_caller::(accounts.bob); + let withdrawn = contract.withdraw_dividends(token_id).unwrap(); + assert!(withdrawn > 0); + } + + #[ink::test] + fn test_trading_flow() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + let token_id = contract.register_property_with_token(sample_metadata()).unwrap(); + assert!(contract.issue_shares(token_id, accounts.alice, 2_000).is_ok()); + + assert!(contract.place_ask(token_id, 10, 500).is_ok()); + + test::set_caller::(accounts.bob); + test::set_value_transferred::(5_000); + assert!(contract.buy_shares(token_id, accounts.alice, 500).is_ok()); + assert_eq!(contract.share_balance_of(accounts.bob, token_id), 500); + assert_eq!(contract.get_last_trade_price(token_id), Some(10)); + } + + #[ink::test] + fn test_governance_flow() { + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let mut contract = PropertyToken::new(); + let token_id = contract.register_property_with_token(sample_metadata()).unwrap(); + assert!(contract.issue_shares(token_id, accounts.alice, 1_000).is_ok()); + + let proposal_id = contract.create_proposal(token_id, 600, Hash::from([2u8; 32])).unwrap(); + assert!(contract.vote(token_id, proposal_id, true).is_ok()); + let executed = contract.execute_proposal(token_id, proposal_id).unwrap(); + assert!(executed); + } +} +