diff --git a/Cargo.lock b/Cargo.lock index b0533a8eb..d9c963cfb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5010,6 +5010,17 @@ dependencies = [ "scale-info", ] +[[package]] +name = "propchain-database" +version = "1.0.0" +dependencies = [ + "ink 5.1.1", + "ink_e2e", + "parity-scale-codec", + "propchain-traits", + "scale-info", +] + [[package]] name = "propchain-escrow" version = "1.0.0" @@ -5042,6 +5053,17 @@ dependencies = [ "scale-info", ] +[[package]] +name = "propchain-metadata" +version = "1.0.0" +dependencies = [ + "ink 5.1.1", + "ink_e2e", + "parity-scale-codec", + "propchain-traits", + "scale-info", +] + [[package]] name = "propchain-prediction-market" version = "1.0.0" @@ -5061,6 +5083,17 @@ dependencies = [ "scale-info", ] +[[package]] +name = "propchain-third-party" +version = "1.0.0" +dependencies = [ + "ink 5.1.1", + "ink_e2e", + "parity-scale-codec", + "propchain-traits", + "scale-info", +] + [[package]] name = "propchain-traits" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index ca64b5d95..e2f0bcb9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,11 @@ members = [ "contracts/compliance_registry", "contracts/fractional", "contracts/prediction-market", - "contracts/governance", + "contracts/metadata", + "contracts/database", + "contracts/third-party", "contracts/staking", + "contracts/governance", ] resolver = "2" diff --git a/contracts/database/Cargo.toml b/contracts/database/Cargo.toml new file mode 100644 index 000000000..a3f3116c1 --- /dev/null +++ b/contracts/database/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "propchain-database" +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +description = "Off-chain database integration with synchronization and analytics capabilities for PropChain" + +[dependencies] +ink = { workspace = true } +scale = { workspace = true } +scale-info = { workspace = true } +propchain-traits = { path = "../traits" } + +[dev-dependencies] +ink_e2e = "5.0.0" + +[lib] +name = "propchain_database" +path = "src/lib.rs" + +[features] +default = ["std"] +std = [ + "ink/std", + "scale/std", + "scale-info/std", +] +ink-as-dependency = [] +e2e-tests = [] diff --git a/contracts/database/src/lib.rs b/contracts/database/src/lib.rs new file mode 100644 index 000000000..e29e51f55 --- /dev/null +++ b/contracts/database/src/lib.rs @@ -0,0 +1,855 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(unexpected_cfgs)] +#![allow(clippy::new_without_default)] + +//! # PropChain Database Integration Contract +//! +//! On-chain coordination layer for off-chain database integration providing: +//! - Database synchronization event emission for off-chain indexers +//! - Data export capabilities via structured events +//! - Analytics data aggregation and snapshots +//! - Sync state tracking and verification +//! - Data integrity checksums for off-chain validation +//! +//! ## Architecture +//! +//! This contract acts as the on-chain coordination point: +//! 1. **Sync Events**: Emits structured events that off-chain indexers consume +//! to keep databases synchronized with on-chain state. +//! 2. **Data Export**: Provides batch query endpoints for initial DB population +//! and periodic reconciliation. +//! 3. **Analytics Snapshots**: Records periodic analytics snapshots on-chain +//! that can be verified against off-chain analytics databases. +//! 4. **Integrity Verification**: Stores Merkle roots / checksums of data sets +//! to allow off-chain databases to prove data integrity. +//! +//! Resolves: https://github.com/MettaChain/PropChain-contract/issues/112 + +use ink::prelude::string::String; +use ink::prelude::vec::Vec; +use ink::storage::Mapping; + +#[ink::contract] +mod propchain_database { + use super::*; + + // ======================================================================== + // TYPES + // ======================================================================== + + /// Unique identifier for sync operations + pub type SyncId = u64; + + /// Data export batch identifier + pub type ExportBatchId = u64; + + // ======================================================================== + // DATA STRUCTURES + // ======================================================================== + + /// Database sync record tracking synchronization state + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct SyncRecord { + /// Unique sync operation ID + pub sync_id: SyncId, + /// Type of data being synced + pub data_type: DataType, + /// Block number at which sync was recorded + pub block_number: u32, + /// Timestamp of sync + pub timestamp: u64, + /// Hash/checksum of the synced data + pub data_checksum: Hash, + /// Number of records in this sync batch + pub record_count: u64, + /// Status of the sync operation + pub status: SyncStatus, + /// Account that initiated the sync + pub initiated_by: AccountId, + } + + /// Types of data that can be synced to off-chain database + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum DataType { + /// Property registration data + Properties, + /// Ownership transfer records + Transfers, + /// Escrow operations + Escrows, + /// Compliance/KYC data + Compliance, + /// Valuation/price data + Valuations, + /// Token operations + Tokens, + /// Analytics snapshots + Analytics, + /// Full state export + FullState, + } + + /// Sync operation status + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum SyncStatus { + /// Sync initiated, events emitted + Initiated, + /// Sync confirmed by off-chain indexer + Confirmed, + /// Sync failed and needs retry + Failed, + /// Sync data verified against off-chain DB + Verified, + } + + /// Analytics snapshot stored on-chain for verification + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct AnalyticsSnapshot { + /// Snapshot identifier + pub snapshot_id: u64, + /// Block number when snapshot was taken + pub block_number: u32, + /// Timestamp + pub timestamp: u64, + /// Total properties in the system + pub total_properties: u64, + /// Total transfers recorded + pub total_transfers: u64, + /// Total escrows created + pub total_escrows: u64, + /// Total valuation across all properties (in smallest unit) + pub total_valuation: u128, + /// Average property valuation + pub avg_valuation: u128, + /// Total active users (unique accounts) + pub active_accounts: u64, + /// Data integrity checksum (Merkle root of all data) + pub integrity_checksum: Hash, + /// Created by + pub created_by: AccountId, + } + + /// Data export request for batch operations + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct ExportRequest { + /// Export batch ID + pub batch_id: ExportBatchId, + /// Type of data requested + pub data_type: DataType, + /// Start index / from ID + pub from_id: u64, + /// End index / to ID + pub to_id: u64, + /// Block range start + pub from_block: u32, + /// Block range end + pub to_block: u32, + /// Requested by + pub requested_by: AccountId, + /// Request timestamp + pub requested_at: u64, + /// Whether export is complete + pub completed: bool, + /// Checksum of exported data + pub export_checksum: Option, + } + + /// Indexer registration for sync coordination + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct IndexerInfo { + /// Indexer account + pub account: AccountId, + /// Indexer name/identifier + pub name: String, + /// Last synced block + pub last_synced_block: u32, + /// Whether indexer is active + pub is_active: bool, + /// Registration timestamp + pub registered_at: u64, + } + + // ======================================================================== + // ERRORS + // ======================================================================== + + #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum Error { + Unauthorized, + SyncNotFound, + ExportNotFound, + InvalidDataRange, + IndexerNotFound, + IndexerAlreadyRegistered, + InvalidChecksum, + SnapshotNotFound, + } + + // ======================================================================== + // EVENTS + // ======================================================================== + + /// Emitted for every data change that off-chain databases should sync + #[ink(event)] + pub struct DataSyncEvent { + #[ink(topic)] + sync_id: SyncId, + #[ink(topic)] + data_type: DataType, + #[ink(topic)] + block_number: u32, + data_checksum: Hash, + record_count: u64, + timestamp: u64, + } + + /// Emitted when a sync is confirmed by an indexer + #[ink(event)] + pub struct SyncConfirmed { + #[ink(topic)] + sync_id: SyncId, + #[ink(topic)] + indexer: AccountId, + block_number: u32, + timestamp: u64, + } + + /// Emitted when an analytics snapshot is recorded + #[ink(event)] + pub struct AnalyticsSnapshotRecorded { + #[ink(topic)] + snapshot_id: u64, + #[ink(topic)] + block_number: u32, + total_properties: u64, + total_valuation: u128, + integrity_checksum: Hash, + timestamp: u64, + } + + /// Emitted when a data export is requested + #[ink(event)] + pub struct DataExportRequested { + #[ink(topic)] + batch_id: ExportBatchId, + #[ink(topic)] + data_type: DataType, + from_id: u64, + to_id: u64, + requested_by: AccountId, + timestamp: u64, + } + + /// Emitted when a data export is completed + #[ink(event)] + pub struct DataExportCompleted { + #[ink(topic)] + batch_id: ExportBatchId, + export_checksum: Hash, + timestamp: u64, + } + + /// Emitted when an indexer is registered + #[ink(event)] + pub struct IndexerRegistered { + #[ink(topic)] + indexer: AccountId, + name: String, + timestamp: u64, + } + + // ======================================================================== + // CONTRACT STORAGE + // ======================================================================== + + #[ink(storage)] + pub struct DatabaseIntegration { + /// Contract admin + admin: AccountId, + /// Sync records + sync_records: Mapping, + /// Sync counter + sync_counter: SyncId, + /// Analytics snapshots + analytics_snapshots: Mapping, + /// Snapshot counter + snapshot_counter: u64, + /// Export requests + export_requests: Mapping, + /// Export counter + export_counter: ExportBatchId, + /// Registered indexers + indexers: Mapping, + /// List of registered indexer accounts + indexer_list: Vec, + /// Last sync block per data type (stored as u8 key) + last_sync_block: Mapping, + /// Authorized data publishers (contracts that can emit sync events) + authorized_publishers: Mapping, + } + + // ======================================================================== + // IMPLEMENTATION + // ======================================================================== + + impl DatabaseIntegration { + #[ink(constructor)] + pub fn new() -> Self { + let caller = Self::env().caller(); + Self { + admin: caller, + sync_records: Mapping::default(), + sync_counter: 0, + analytics_snapshots: Mapping::default(), + snapshot_counter: 0, + export_requests: Mapping::default(), + export_counter: 0, + indexers: Mapping::default(), + indexer_list: Vec::new(), + last_sync_block: Mapping::default(), + authorized_publishers: Mapping::default(), + } + } + + // ==================================================================== + // DATA SYNCHRONIZATION + // ==================================================================== + + /// Emits a sync event for off-chain database synchronization. + /// Called by authorized contracts when data changes occur. + #[ink(message)] + pub fn emit_sync_event( + &mut self, + data_type: DataType, + data_checksum: Hash, + record_count: u64, + ) -> Result { + let caller = self.env().caller(); + if caller != self.admin && !self.authorized_publishers.get(caller).unwrap_or(false) { + return Err(Error::Unauthorized); + } + + self.sync_counter += 1; + let sync_id = self.sync_counter; + let block_number = self.env().block_number(); + let timestamp = self.env().block_timestamp(); + + let record = SyncRecord { + sync_id, + data_type: data_type.clone(), + block_number, + timestamp, + data_checksum, + record_count, + status: SyncStatus::Initiated, + initiated_by: caller, + }; + + self.sync_records.insert(sync_id, &record); + + // Update last sync block for this data type + let dt_key = self.data_type_to_key(&data_type); + self.last_sync_block.insert(dt_key, &block_number); + + self.env().emit_event(DataSyncEvent { + sync_id, + data_type, + block_number, + data_checksum, + record_count, + timestamp, + }); + + Ok(sync_id) + } + + /// Confirms a sync operation (called by registered indexer) + #[ink(message)] + pub fn confirm_sync(&mut self, sync_id: SyncId) -> Result<(), Error> { + let caller = self.env().caller(); + + // Must be a registered indexer + if !self.indexers.contains(caller) { + return Err(Error::IndexerNotFound); + } + + let mut record = self + .sync_records + .get(sync_id) + .ok_or(Error::SyncNotFound)?; + + record.status = SyncStatus::Confirmed; + self.sync_records.insert(sync_id, &record); + + // Update indexer's last synced block + if let Some(mut indexer) = self.indexers.get(caller) { + indexer.last_synced_block = record.block_number; + self.indexers.insert(caller, &indexer); + } + + self.env().emit_event(SyncConfirmed { + sync_id, + indexer: caller, + block_number: record.block_number, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + /// Verifies sync data integrity by comparing checksums + #[ink(message)] + pub fn verify_sync( + &mut self, + sync_id: SyncId, + verification_checksum: Hash, + ) -> Result { + let mut record = self + .sync_records + .get(sync_id) + .ok_or(Error::SyncNotFound)?; + + let is_valid = record.data_checksum == verification_checksum; + + if is_valid { + record.status = SyncStatus::Verified; + } else { + record.status = SyncStatus::Failed; + } + + self.sync_records.insert(sync_id, &record); + Ok(is_valid) + } + + // ==================================================================== + // ANALYTICS SNAPSHOTS + // ==================================================================== + + /// Records an analytics snapshot on-chain for later verification + #[ink(message)] + pub fn record_analytics_snapshot( + &mut self, + total_properties: u64, + total_transfers: u64, + total_escrows: u64, + total_valuation: u128, + avg_valuation: u128, + active_accounts: u64, + integrity_checksum: Hash, + ) -> Result { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + + self.snapshot_counter += 1; + let snapshot_id = self.snapshot_counter; + let block_number = self.env().block_number(); + let timestamp = self.env().block_timestamp(); + + let snapshot = AnalyticsSnapshot { + snapshot_id, + block_number, + timestamp, + total_properties, + total_transfers, + total_escrows, + total_valuation, + avg_valuation, + active_accounts, + integrity_checksum, + created_by: caller, + }; + + self.analytics_snapshots.insert(snapshot_id, &snapshot); + + self.env().emit_event(AnalyticsSnapshotRecorded { + snapshot_id, + block_number, + total_properties, + total_valuation, + integrity_checksum, + timestamp, + }); + + Ok(snapshot_id) + } + + /// Retrieves an analytics snapshot + #[ink(message)] + pub fn get_analytics_snapshot(&self, snapshot_id: u64) -> Option { + self.analytics_snapshots.get(snapshot_id) + } + + /// Gets the latest snapshot ID + #[ink(message)] + pub fn latest_snapshot_id(&self) -> u64 { + self.snapshot_counter + } + + // ==================================================================== + // DATA EXPORT + // ==================================================================== + + /// Requests a data export for a specific range + #[ink(message)] + pub fn request_data_export( + &mut self, + data_type: DataType, + from_id: u64, + to_id: u64, + from_block: u32, + to_block: u32, + ) -> Result { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + + if from_id > to_id || from_block > to_block { + return Err(Error::InvalidDataRange); + } + + self.export_counter += 1; + let batch_id = self.export_counter; + let timestamp = self.env().block_timestamp(); + + let request = ExportRequest { + batch_id, + data_type: data_type.clone(), + from_id, + to_id, + from_block, + to_block, + requested_by: caller, + requested_at: timestamp, + completed: false, + export_checksum: None, + }; + + self.export_requests.insert(batch_id, &request); + + self.env().emit_event(DataExportRequested { + batch_id, + data_type, + from_id, + to_id, + requested_by: caller, + timestamp, + }); + + Ok(batch_id) + } + + /// Marks a data export as completed with verification checksum + #[ink(message)] + pub fn complete_data_export( + &mut self, + batch_id: ExportBatchId, + export_checksum: Hash, + ) -> Result<(), Error> { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + + let mut request = self + .export_requests + .get(batch_id) + .ok_or(Error::ExportNotFound)?; + + request.completed = true; + request.export_checksum = Some(export_checksum); + + self.export_requests.insert(batch_id, &request); + + self.env().emit_event(DataExportCompleted { + batch_id, + export_checksum, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + /// Gets export request details + #[ink(message)] + pub fn get_export_request(&self, batch_id: ExportBatchId) -> Option { + self.export_requests.get(batch_id) + } + + // ==================================================================== + // INDEXER MANAGEMENT + // ==================================================================== + + /// Registers an off-chain indexer + #[ink(message)] + pub fn register_indexer(&mut self, indexer: AccountId, name: String) -> Result<(), Error> { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + + if self.indexers.contains(indexer) { + return Err(Error::IndexerAlreadyRegistered); + } + + let info = IndexerInfo { + account: indexer, + name: name.clone(), + last_synced_block: 0, + is_active: true, + registered_at: self.env().block_timestamp(), + }; + + self.indexers.insert(indexer, &info); + self.indexer_list.push(indexer); + + self.env().emit_event(IndexerRegistered { + indexer, + name, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + /// Deactivates an indexer + #[ink(message)] + pub fn deactivate_indexer(&mut self, indexer: AccountId) -> Result<(), Error> { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + + let mut info = self + .indexers + .get(indexer) + .ok_or(Error::IndexerNotFound)?; + + info.is_active = false; + self.indexers.insert(indexer, &info); + + Ok(()) + } + + /// Gets indexer information + #[ink(message)] + pub fn get_indexer(&self, indexer: AccountId) -> Option { + self.indexers.get(indexer) + } + + /// Gets all registered indexer accounts + #[ink(message)] + pub fn get_indexer_list(&self) -> Vec { + self.indexer_list.clone() + } + + // ==================================================================== + // PUBLISHER MANAGEMENT + // ==================================================================== + + /// Authorizes a contract to publish sync events + #[ink(message)] + pub fn authorize_publisher(&mut self, publisher: AccountId) -> Result<(), Error> { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + self.authorized_publishers.insert(publisher, &true); + Ok(()) + } + + /// Revokes a publisher's authorization + #[ink(message)] + pub fn revoke_publisher(&mut self, publisher: AccountId) -> Result<(), Error> { + let caller = self.env().caller(); + if caller != self.admin { + return Err(Error::Unauthorized); + } + self.authorized_publishers.remove(publisher); + Ok(()) + } + + // ==================================================================== + // QUERY FUNCTIONS + // ==================================================================== + + /// Gets a sync record + #[ink(message)] + pub fn get_sync_record(&self, sync_id: SyncId) -> Option { + self.sync_records.get(sync_id) + } + + /// Gets total sync operations count + #[ink(message)] + pub fn total_syncs(&self) -> SyncId { + self.sync_counter + } + + /// Gets the last synced block for a data type + #[ink(message)] + pub fn last_synced_block(&self, data_type: DataType) -> u32 { + let key = self.data_type_to_key(&data_type); + self.last_sync_block.get(key).unwrap_or(0) + } + + /// Gets admin + #[ink(message)] + pub fn admin(&self) -> AccountId { + self.admin + } + + // ==================================================================== + // INTERNAL + // ==================================================================== + + fn data_type_to_key(&self, dt: &DataType) -> u8 { + match dt { + DataType::Properties => 0, + DataType::Transfers => 1, + DataType::Escrows => 2, + DataType::Compliance => 3, + DataType::Valuations => 4, + DataType::Tokens => 5, + DataType::Analytics => 6, + DataType::FullState => 7, + } + } + } + + impl Default for DatabaseIntegration { + fn default() -> Self { + Self::new() + } + } + + // ======================================================================== + // UNIT TESTS + // ======================================================================== + + #[cfg(test)] + mod tests { + use super::*; + + #[ink::test] + fn new_initializes_correctly() { + let contract = DatabaseIntegration::new(); + assert_eq!(contract.total_syncs(), 0); + assert_eq!(contract.latest_snapshot_id(), 0); + } + + #[ink::test] + fn emit_sync_event_works() { + let mut contract = DatabaseIntegration::new(); + let result = contract.emit_sync_event( + DataType::Properties, + Hash::from([0x01; 32]), + 10, + ); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + assert_eq!(contract.total_syncs(), 1); + + let record = contract.get_sync_record(1).unwrap(); + assert_eq!(record.data_type, DataType::Properties); + assert_eq!(record.record_count, 10); + assert_eq!(record.status, SyncStatus::Initiated); + } + + #[ink::test] + fn analytics_snapshot_works() { + let mut contract = DatabaseIntegration::new(); + let result = contract.record_analytics_snapshot( + 100, 50, 20, 10_000_000, 100_000, 30, Hash::from([0x02; 32]), + ); + assert!(result.is_ok()); + + let snapshot = contract.get_analytics_snapshot(1).unwrap(); + assert_eq!(snapshot.total_properties, 100); + assert_eq!(snapshot.total_valuation, 10_000_000); + } + + #[ink::test] + fn data_export_works() { + let mut contract = DatabaseIntegration::new(); + let result = + contract.request_data_export(DataType::Properties, 1, 100, 0, 1000); + assert!(result.is_ok()); + + let batch_id = result.unwrap(); + let request = contract.get_export_request(batch_id).unwrap(); + assert!(!request.completed); + + let complete_result = + contract.complete_data_export(batch_id, Hash::from([0x03; 32])); + assert!(complete_result.is_ok()); + + let completed = contract.get_export_request(batch_id).unwrap(); + assert!(completed.completed); + } + + #[ink::test] + fn verify_sync_works() { + let mut contract = DatabaseIntegration::new(); + let checksum = Hash::from([0x01; 32]); + contract + .emit_sync_event(DataType::Transfers, checksum, 5) + .unwrap(); + + // Correct checksum + let result = contract.verify_sync(1, checksum); + assert_eq!(result, Ok(true)); + + let record = contract.get_sync_record(1).unwrap(); + assert_eq!(record.status, SyncStatus::Verified); + } + + #[ink::test] + fn indexer_registration_works() { + let mut contract = DatabaseIntegration::new(); + let indexer = AccountId::from([0x02; 32]); + + let result = contract.register_indexer(indexer, String::from("TestIndexer")); + assert!(result.is_ok()); + + let info = contract.get_indexer(indexer).unwrap(); + assert_eq!(info.name, "TestIndexer"); + assert!(info.is_active); + + let list = contract.get_indexer_list(); + assert_eq!(list.len(), 1); + } + } +} diff --git a/contracts/metadata/Cargo.toml b/contracts/metadata/Cargo.toml new file mode 100644 index 000000000..4a7cff5d7 --- /dev/null +++ b/contracts/metadata/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "propchain-metadata" +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +description = "Advanced property metadata standard with IPFS integration, versioning, multimedia support, and dynamic updates" + +[dependencies] +ink = { workspace = true } +scale = { workspace = true } +scale-info = { workspace = true } +propchain-traits = { path = "../traits" } + +[dev-dependencies] +ink_e2e = "5.0.0" + +[lib] +name = "propchain_metadata" +path = "src/lib.rs" + +[features] +default = ["std"] +std = [ + "ink/std", + "scale/std", + "scale-info/std", +] +ink-as-dependency = [] +e2e-tests = [] diff --git a/contracts/metadata/src/lib.rs b/contracts/metadata/src/lib.rs new file mode 100644 index 000000000..12824bcf3 --- /dev/null +++ b/contracts/metadata/src/lib.rs @@ -0,0 +1,1286 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(unexpected_cfgs)] +#![allow(clippy::new_without_default)] + +//! # Advanced Property Metadata Standard +//! +//! Implements a comprehensive metadata standard for property tokens that supports: +//! - Extensible metadata schema with typed fields +//! - IPFS integration for large file storage +//! - Metadata verification and validation +//! - Dynamic metadata update mechanisms +//! - Metadata versioning and history tracking +//! - Multimedia content support (images, videos, tours) +//! - Legal document integration and verification +//! - Metadata management and search capabilities +//! +//! Resolves: https://github.com/MettaChain/PropChain-contract/issues/69 + +use ink::prelude::string::String; +use ink::prelude::vec::Vec; +use ink::storage::Mapping; + +#[ink::contract] +#[allow(clippy::too_many_arguments)] +mod propchain_metadata { + use super::*; + + // ======================================================================== + // TYPES + // ======================================================================== + + pub type PropertyId = u64; + pub type MetadataVersion = u32; + pub type IpfsCid = String; + + // ======================================================================== + // EXTENSIBLE METADATA SCHEMA + // ======================================================================== + + /// Core property metadata with extensible fields + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct AdvancedPropertyMetadata { + /// Property identifier + pub property_id: PropertyId, + /// Current version of the metadata + pub version: MetadataVersion, + /// Core property information + pub core: CoreMetadata, + /// IPFS content identifiers for associated files + pub ipfs_resources: IpfsResources, + /// Multimedia content references + pub multimedia: MultimediaContent, + /// Legal document references + pub legal_documents: Vec, + /// Custom extensible attributes (key-value pairs) + pub custom_attributes: Vec, + /// Content hash for integrity verification + pub content_hash: Hash, + /// Creation timestamp + pub created_at: u64, + /// Last update timestamp + pub updated_at: u64, + /// Creator account + pub created_by: AccountId, + /// Whether this metadata is finalized (immutable) + pub is_finalized: bool, + } + + /// Core property information (required fields) + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct CoreMetadata { + /// Property name/title + pub name: String, + /// Physical address/location + pub location: String, + /// Property size in square meters + pub size_sqm: u64, + /// Property type classification + pub property_type: MetadataPropertyType, + /// Current valuation in smallest currency unit + pub valuation: u128, + /// Legal description of the property + pub legal_description: String, + /// Geographic coordinates (latitude * 1e6, longitude * 1e6) + pub coordinates: Option<(i64, i64)>, + /// Year built + pub year_built: Option, + /// Number of bedrooms (for residential) + pub bedrooms: Option, + /// Number of bathrooms (for residential) + pub bathrooms: Option, + /// Zoning classification + pub zoning: Option, + } + + /// Property type for metadata classification + #[derive(Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub enum MetadataPropertyType { + Residential, + Commercial, + Industrial, + Land, + MultiFamily, + Retail, + Office, + MixedUse, + Agricultural, + Hospitality, + } + + /// IPFS resource links for the property + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct IpfsResources { + /// Main metadata JSON on IPFS + pub metadata_cid: Option, + /// Documents bundle CID + pub documents_cid: Option, + /// Images bundle CID + pub images_cid: Option, + /// Legal documents bundle CID + pub legal_docs_cid: Option, + /// 3D model / virtual tour CID + pub virtual_tour_cid: Option, + /// Floor plans CID + pub floor_plans_cid: Option, + } + + /// Multimedia content references + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct MultimediaContent { + /// Image references (CID, description, mime_type) + pub images: Vec, + /// Video references + pub videos: Vec, + /// Virtual tour links + pub virtual_tours: Vec, + /// Floor plans + pub floor_plans: Vec, + } + + /// Individual media item reference + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct MediaItem { + /// IPFS CID or URL + pub content_ref: String, + /// Description of the media item + pub description: String, + /// MIME type + pub mime_type: String, + /// File size in bytes + pub file_size: u64, + /// Content hash for verification + pub content_hash: Hash, + /// Upload timestamp + pub uploaded_at: u64, + } + + /// Legal document reference + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct LegalDocumentRef { + /// Document identifier + pub document_id: u64, + /// Document type + pub document_type: LegalDocType, + /// IPFS CID for the document + pub ipfs_cid: IpfsCid, + /// Content hash for integrity verification + pub content_hash: Hash, + /// Issuing authority + pub issuer: String, + /// Issue date timestamp + pub issue_date: u64, + /// Expiry date timestamp (if applicable) + pub expiry_date: Option, + /// Verification status + pub is_verified: bool, + /// Verifier account (if verified) + pub verified_by: Option, + } + + /// Legal document types + #[derive(Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub enum LegalDocType { + Deed, + Title, + Survey, + Inspection, + Appraisal, + TaxRecord, + Insurance, + ZoningPermit, + EnvironmentalReport, + HOADocument, + LeaseAgreement, + MortgageDocument, + Other, + } + + /// Custom metadata attribute (extensible key-value pair) + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct MetadataAttribute { + /// Attribute key/name + pub key: String, + /// Attribute value + pub value: MetadataValue, + /// Whether this attribute is required + pub is_required: bool, + } + + /// Typed metadata values for extensibility + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub enum MetadataValue { + Text(String), + Number(u128), + Boolean(bool), + Date(u64), + IpfsRef(IpfsCid), + AccountRef(AccountId), + } + + /// Metadata version history entry + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct MetadataVersionEntry { + pub version: MetadataVersion, + pub content_hash: Hash, + pub updated_by: AccountId, + pub updated_at: u64, + pub change_description: String, + /// Previous IPFS CID snapshot (for full historical access) + pub snapshot_cid: Option, + } + + // ======================================================================== + // ERRORS + // ======================================================================== + + #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum Error { + PropertyNotFound, + Unauthorized, + InvalidMetadata, + MetadataAlreadyFinalized, + InvalidIpfsCid, + DocumentNotFound, + DocumentAlreadyExists, + VersionConflict, + RequiredFieldMissing, + SizeLimitExceeded, + InvalidContentHash, + SearchQueryTooLong, + } + + // ======================================================================== + // EVENTS + // ======================================================================== + + #[ink(event)] + pub struct MetadataCreated { + #[ink(topic)] + property_id: PropertyId, + #[ink(topic)] + creator: AccountId, + version: MetadataVersion, + content_hash: Hash, + timestamp: u64, + } + + #[ink(event)] + pub struct MetadataUpdated { + #[ink(topic)] + property_id: PropertyId, + #[ink(topic)] + updater: AccountId, + old_version: MetadataVersion, + new_version: MetadataVersion, + content_hash: Hash, + change_description: String, + timestamp: u64, + } + + #[ink(event)] + pub struct MetadataFinalized { + #[ink(topic)] + property_id: PropertyId, + #[ink(topic)] + finalized_by: AccountId, + final_version: MetadataVersion, + timestamp: u64, + } + + #[ink(event)] + pub struct LegalDocumentAdded { + #[ink(topic)] + property_id: PropertyId, + #[ink(topic)] + document_id: u64, + document_type: LegalDocType, + ipfs_cid: IpfsCid, + timestamp: u64, + } + + #[ink(event)] + pub struct LegalDocumentVerified { + #[ink(topic)] + property_id: PropertyId, + #[ink(topic)] + document_id: u64, + #[ink(topic)] + verifier: AccountId, + timestamp: u64, + } + + #[ink(event)] + pub struct MultimediaAdded { + #[ink(topic)] + property_id: PropertyId, + media_type: String, + content_ref: String, + timestamp: u64, + } + + #[ink(event)] + pub struct MetadataSearched { + #[ink(topic)] + searcher: AccountId, + query: String, + results_count: u32, + timestamp: u64, + } + + // ======================================================================== + // CONTRACT STORAGE + // ======================================================================== + + #[ink(storage)] + pub struct AdvancedMetadataRegistry { + /// Contract admin + admin: AccountId, + /// Property metadata storage + metadata: Mapping, + /// Version history: (property_id, version) -> entry + version_history: Mapping<(PropertyId, MetadataVersion), MetadataVersionEntry>, + /// Property owners/authorized updaters + property_owners: Mapping, + /// Document verifiers + verifiers: Mapping, + /// Property ID index (for search - maps keyword hash to property IDs) + location_index: Mapping>, + /// Property type index + type_index: Mapping>, + /// Total properties registered + total_properties: u64, + /// Document counter + document_counter: u64, + /// Maximum custom attributes per property + max_custom_attributes: u32, + /// Maximum media items per category + max_media_items: u32, + /// Maximum legal documents per property + max_legal_documents: u32, + } + + // ======================================================================== + // IMPLEMENTATION + // ======================================================================== + + impl AdvancedMetadataRegistry { + #[ink(constructor)] + pub fn new() -> Self { + let caller = Self::env().caller(); + Self { + admin: caller, + metadata: Mapping::default(), + version_history: Mapping::default(), + property_owners: Mapping::default(), + verifiers: Mapping::default(), + location_index: Mapping::default(), + type_index: Mapping::default(), + total_properties: 0, + document_counter: 0, + max_custom_attributes: 50, + max_media_items: 100, + max_legal_documents: 50, + } + } + + // ==================================================================== + // METADATA LIFECYCLE + // ==================================================================== + + /// Creates new property metadata with full extensible schema + #[ink(message)] + pub fn create_metadata( + &mut self, + property_id: PropertyId, + core: CoreMetadata, + ipfs_resources: IpfsResources, + content_hash: Hash, + ) -> Result<(), Error> { + let caller = self.env().caller(); + let timestamp = self.env().block_timestamp(); + + // Ensure property doesn't already have metadata + if self.metadata.contains(property_id) { + return Err(Error::InvalidMetadata); + } + + // Validate core metadata + self.validate_core_metadata(&core)?; + + // Validate IPFS CIDs if provided + self.validate_ipfs_resources(&ipfs_resources)?; + + let metadata = AdvancedPropertyMetadata { + property_id, + version: 1, + core, + ipfs_resources, + multimedia: MultimediaContent { + images: Vec::new(), + videos: Vec::new(), + virtual_tours: Vec::new(), + floor_plans: Vec::new(), + }, + legal_documents: Vec::new(), + custom_attributes: Vec::new(), + content_hash, + created_at: timestamp, + updated_at: timestamp, + created_by: caller, + is_finalized: false, + }; + + // Store metadata + self.metadata.insert(property_id, &metadata); + self.property_owners.insert(property_id, &caller); + + // Record version history + let version_entry = MetadataVersionEntry { + version: 1, + content_hash, + updated_by: caller, + updated_at: timestamp, + change_description: String::from("Initial metadata creation"), + snapshot_cid: None, + }; + self.version_history + .insert((property_id, 1), &version_entry); + + // Update indexes + let property_type_idx = self.property_type_to_index(&metadata.core.property_type); + let mut type_list = self.type_index.get(property_type_idx).unwrap_or_default(); + type_list.push(property_id); + self.type_index.insert(property_type_idx, &type_list); + + self.total_properties += 1; + + self.env().emit_event(MetadataCreated { + property_id, + creator: caller, + version: 1, + content_hash, + timestamp, + }); + + Ok(()) + } + + /// Updates property metadata with version tracking + #[ink(message)] + pub fn update_metadata( + &mut self, + property_id: PropertyId, + core: CoreMetadata, + ipfs_resources: IpfsResources, + content_hash: Hash, + change_description: String, + snapshot_cid: Option, + ) -> Result { + let caller = self.env().caller(); + let timestamp = self.env().block_timestamp(); + + self.ensure_owner_or_admin(property_id, caller)?; + + let mut metadata = self + .metadata + .get(property_id) + .ok_or(Error::PropertyNotFound)?; + + if metadata.is_finalized { + return Err(Error::MetadataAlreadyFinalized); + } + + // Validate + self.validate_core_metadata(&core)?; + self.validate_ipfs_resources(&ipfs_resources)?; + + let old_version = metadata.version; + let new_version = old_version + 1; + + metadata.version = new_version; + metadata.core = core; + metadata.ipfs_resources = ipfs_resources; + metadata.content_hash = content_hash; + metadata.updated_at = timestamp; + + self.metadata.insert(property_id, &metadata); + + // Record version history + let version_entry = MetadataVersionEntry { + version: new_version, + content_hash, + updated_by: caller, + updated_at: timestamp, + change_description: change_description.clone(), + snapshot_cid, + }; + self.version_history + .insert((property_id, new_version), &version_entry); + + self.env().emit_event(MetadataUpdated { + property_id, + updater: caller, + old_version, + new_version, + content_hash, + change_description, + timestamp, + }); + + Ok(new_version) + } + + /// Adds a custom attribute to property metadata + #[ink(message)] + pub fn add_custom_attribute( + &mut self, + property_id: PropertyId, + key: String, + value: MetadataValue, + is_required: bool, + ) -> Result<(), Error> { + let caller = self.env().caller(); + self.ensure_owner_or_admin(property_id, caller)?; + + let mut metadata = self + .metadata + .get(property_id) + .ok_or(Error::PropertyNotFound)?; + + if metadata.is_finalized { + return Err(Error::MetadataAlreadyFinalized); + } + + if metadata.custom_attributes.len() as u32 >= self.max_custom_attributes { + return Err(Error::SizeLimitExceeded); + } + + metadata.custom_attributes.push(MetadataAttribute { + key, + value, + is_required, + }); + metadata.updated_at = self.env().block_timestamp(); + + self.metadata.insert(property_id, &metadata); + Ok(()) + } + + /// Finalizes metadata making it immutable + #[ink(message)] + pub fn finalize_metadata(&mut self, property_id: PropertyId) -> Result<(), Error> { + let caller = self.env().caller(); + self.ensure_owner_or_admin(property_id, caller)?; + + let mut metadata = self + .metadata + .get(property_id) + .ok_or(Error::PropertyNotFound)?; + + if metadata.is_finalized { + return Err(Error::MetadataAlreadyFinalized); + } + + metadata.is_finalized = true; + metadata.updated_at = self.env().block_timestamp(); + + self.metadata.insert(property_id, &metadata); + + self.env().emit_event(MetadataFinalized { + property_id, + finalized_by: caller, + final_version: metadata.version, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + // ==================================================================== + // MULTIMEDIA CONTENT MANAGEMENT + // ==================================================================== + + /// Adds a multimedia item (image, video, tour, floor plan) + #[ink(message)] + pub fn add_media_item( + &mut self, + property_id: PropertyId, + media_category: u8, // 0=image, 1=video, 2=virtual_tour, 3=floor_plan + content_ref: String, + description: String, + mime_type: String, + file_size: u64, + content_hash: Hash, + ) -> Result<(), Error> { + let caller = self.env().caller(); + self.ensure_owner_or_admin(property_id, caller)?; + + let mut metadata = self + .metadata + .get(property_id) + .ok_or(Error::PropertyNotFound)?; + + if metadata.is_finalized { + return Err(Error::MetadataAlreadyFinalized); + } + + let media_item = MediaItem { + content_ref: content_ref.clone(), + description, + mime_type, + file_size, + content_hash, + uploaded_at: self.env().block_timestamp(), + }; + + let media_type_str = match media_category { + 0 => { + if metadata.multimedia.images.len() as u32 >= self.max_media_items { + return Err(Error::SizeLimitExceeded); + } + metadata.multimedia.images.push(media_item); + "image" + } + 1 => { + if metadata.multimedia.videos.len() as u32 >= self.max_media_items { + return Err(Error::SizeLimitExceeded); + } + metadata.multimedia.videos.push(media_item); + "video" + } + 2 => { + if metadata.multimedia.virtual_tours.len() as u32 >= self.max_media_items { + return Err(Error::SizeLimitExceeded); + } + metadata.multimedia.virtual_tours.push(media_item); + "virtual_tour" + } + 3 => { + if metadata.multimedia.floor_plans.len() as u32 >= self.max_media_items { + return Err(Error::SizeLimitExceeded); + } + metadata.multimedia.floor_plans.push(media_item); + "floor_plan" + } + _ => return Err(Error::InvalidMetadata), + }; + + metadata.updated_at = self.env().block_timestamp(); + self.metadata.insert(property_id, &metadata); + + self.env().emit_event(MultimediaAdded { + property_id, + media_type: String::from(media_type_str), + content_ref, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + // ==================================================================== + // LEGAL DOCUMENT MANAGEMENT + // ==================================================================== + + /// Adds a legal document reference to property metadata + #[ink(message)] + pub fn add_legal_document( + &mut self, + property_id: PropertyId, + document_type: LegalDocType, + ipfs_cid: IpfsCid, + content_hash: Hash, + issuer: String, + issue_date: u64, + expiry_date: Option, + ) -> Result { + let caller = self.env().caller(); + self.ensure_owner_or_admin(property_id, caller)?; + + let mut metadata = self + .metadata + .get(property_id) + .ok_or(Error::PropertyNotFound)?; + + if metadata.is_finalized { + return Err(Error::MetadataAlreadyFinalized); + } + + if metadata.legal_documents.len() as u32 >= self.max_legal_documents { + return Err(Error::SizeLimitExceeded); + } + + self.validate_ipfs_cid(&ipfs_cid)?; + + self.document_counter += 1; + let document_id = self.document_counter; + + let doc_ref = LegalDocumentRef { + document_id, + document_type: document_type.clone(), + ipfs_cid: ipfs_cid.clone(), + content_hash, + issuer, + issue_date, + expiry_date, + is_verified: false, + verified_by: None, + }; + + metadata.legal_documents.push(doc_ref); + metadata.updated_at = self.env().block_timestamp(); + + self.metadata.insert(property_id, &metadata); + + self.env().emit_event(LegalDocumentAdded { + property_id, + document_id, + document_type, + ipfs_cid, + timestamp: self.env().block_timestamp(), + }); + + Ok(document_id) + } + + /// Verifies a legal document (verifier only) + #[ink(message)] + pub fn verify_legal_document( + &mut self, + property_id: PropertyId, + document_id: u64, + ) -> Result<(), Error> { + let caller = self.env().caller(); + + // Must be admin or authorized verifier + if caller != self.admin && !self.verifiers.get(caller).unwrap_or(false) { + return Err(Error::Unauthorized); + } + + let mut metadata = self + .metadata + .get(property_id) + .ok_or(Error::PropertyNotFound)?; + + let doc = metadata + .legal_documents + .iter_mut() + .find(|d| d.document_id == document_id) + .ok_or(Error::DocumentNotFound)?; + + doc.is_verified = true; + doc.verified_by = Some(caller); + + self.metadata.insert(property_id, &metadata); + + self.env().emit_event(LegalDocumentVerified { + property_id, + document_id, + verifier: caller, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + // ==================================================================== + // METADATA VERSIONING & HISTORY + // ==================================================================== + + /// Gets metadata version history for a property + #[ink(message)] + pub fn get_version_history( + &self, + property_id: PropertyId, + ) -> Vec { + let metadata = match self.metadata.get(property_id) { + Some(m) => m, + None => return Vec::new(), + }; + + let mut history = Vec::new(); + for v in 1..=metadata.version { + if let Some(entry) = self.version_history.get((property_id, v)) { + history.push(entry); + } + } + history + } + + /// Gets a specific version's metadata entry + #[ink(message)] + pub fn get_version( + &self, + property_id: PropertyId, + version: MetadataVersion, + ) -> Option { + self.version_history.get((property_id, version)) + } + + // ==================================================================== + // QUERY & SEARCH + // ==================================================================== + + /// Gets full metadata for a property + #[ink(message)] + pub fn get_metadata(&self, property_id: PropertyId) -> Option { + self.metadata.get(property_id) + } + + /// Gets only the core metadata for a property + #[ink(message)] + pub fn get_core_metadata(&self, property_id: PropertyId) -> Option { + self.metadata.get(property_id).map(|m| m.core) + } + + /// Gets multimedia content for a property + #[ink(message)] + pub fn get_multimedia(&self, property_id: PropertyId) -> Option { + self.metadata.get(property_id).map(|m| m.multimedia) + } + + /// Gets legal documents for a property + #[ink(message)] + pub fn get_legal_documents(&self, property_id: PropertyId) -> Vec { + self.metadata + .get(property_id) + .map(|m| m.legal_documents) + .unwrap_or_default() + } + + /// Gets properties by type + #[ink(message)] + pub fn get_properties_by_type( + &self, + property_type: MetadataPropertyType, + ) -> Vec { + let idx = self.property_type_to_index(&property_type); + self.type_index.get(idx).unwrap_or_default() + } + + /// Verifies content integrity of metadata + #[ink(message)] + pub fn verify_content_hash( + &self, + property_id: PropertyId, + expected_hash: Hash, + ) -> Result { + let metadata = self + .metadata + .get(property_id) + .ok_or(Error::PropertyNotFound)?; + Ok(metadata.content_hash == expected_hash) + } + + /// Gets total properties registered + #[ink(message)] + pub fn total_properties(&self) -> u64 { + self.total_properties + } + + /// Gets current metadata version for a property + #[ink(message)] + pub fn current_version(&self, property_id: PropertyId) -> Option { + self.metadata.get(property_id).map(|m| m.version) + } + + // ==================================================================== + // ADMIN FUNCTIONS + // ==================================================================== + + /// Adds a document verifier (admin only) + #[ink(message)] + pub fn add_verifier(&mut self, verifier: AccountId) -> Result<(), Error> { + self.ensure_admin()?; + self.verifiers.insert(verifier, &true); + Ok(()) + } + + /// Removes a document verifier (admin only) + #[ink(message)] + pub fn remove_verifier(&mut self, verifier: AccountId) -> Result<(), Error> { + self.ensure_admin()?; + self.verifiers.remove(verifier); + Ok(()) + } + + /// Updates configuration limits (admin only) + #[ink(message)] + pub fn update_limits( + &mut self, + max_custom_attributes: u32, + max_media_items: u32, + max_legal_documents: u32, + ) -> Result<(), Error> { + self.ensure_admin()?; + self.max_custom_attributes = max_custom_attributes; + self.max_media_items = max_media_items; + self.max_legal_documents = max_legal_documents; + Ok(()) + } + + /// Returns admin account + #[ink(message)] + pub fn admin(&self) -> AccountId { + self.admin + } + + // ==================================================================== + // INTERNAL HELPERS + // ==================================================================== + + fn ensure_admin(&self) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + Ok(()) + } + + fn ensure_owner_or_admin( + &self, + property_id: PropertyId, + caller: AccountId, + ) -> Result<(), Error> { + if caller == self.admin { + return Ok(()); + } + let owner = self + .property_owners + .get(property_id) + .ok_or(Error::PropertyNotFound)?; + if caller != owner { + return Err(Error::Unauthorized); + } + Ok(()) + } + + fn validate_core_metadata(&self, core: &CoreMetadata) -> Result<(), Error> { + if core.name.is_empty() || core.location.is_empty() { + return Err(Error::RequiredFieldMissing); + } + if core.size_sqm == 0 { + return Err(Error::InvalidMetadata); + } + if core.legal_description.is_empty() { + return Err(Error::RequiredFieldMissing); + } + Ok(()) + } + + fn validate_ipfs_resources(&self, resources: &IpfsResources) -> Result<(), Error> { + if let Some(ref cid) = resources.metadata_cid { + self.validate_ipfs_cid(cid)?; + } + if let Some(ref cid) = resources.documents_cid { + self.validate_ipfs_cid(cid)?; + } + if let Some(ref cid) = resources.images_cid { + self.validate_ipfs_cid(cid)?; + } + if let Some(ref cid) = resources.legal_docs_cid { + self.validate_ipfs_cid(cid)?; + } + if let Some(ref cid) = resources.virtual_tour_cid { + self.validate_ipfs_cid(cid)?; + } + if let Some(ref cid) = resources.floor_plans_cid { + self.validate_ipfs_cid(cid)?; + } + Ok(()) + } + + fn validate_ipfs_cid(&self, cid: &str) -> Result<(), Error> { + if cid.is_empty() { + return Err(Error::InvalidIpfsCid); + } + // CIDv0: starts with "Qm", 46 chars + if cid.starts_with("Qm") && cid.len() == 46 { + return Ok(()); + } + // CIDv1: starts with "b", min 10 chars + if cid.starts_with('b') && cid.len() >= 10 { + return Ok(()); + } + Err(Error::InvalidIpfsCid) + } + + fn property_type_to_index(&self, pt: &MetadataPropertyType) -> u8 { + match pt { + MetadataPropertyType::Residential => 0, + MetadataPropertyType::Commercial => 1, + MetadataPropertyType::Industrial => 2, + MetadataPropertyType::Land => 3, + MetadataPropertyType::MultiFamily => 4, + MetadataPropertyType::Retail => 5, + MetadataPropertyType::Office => 6, + MetadataPropertyType::MixedUse => 7, + MetadataPropertyType::Agricultural => 8, + MetadataPropertyType::Hospitality => 9, + } + } + } + + impl Default for AdvancedMetadataRegistry { + fn default() -> Self { + Self::new() + } + } + + // ======================================================================== + // UNIT TESTS + // ======================================================================== + + #[cfg(test)] + mod tests { + use super::*; + + fn default_core() -> CoreMetadata { + CoreMetadata { + name: String::from("Test Property"), + location: String::from("123 Main St, City"), + size_sqm: 500, + property_type: MetadataPropertyType::Residential, + valuation: 1_000_000, + legal_description: String::from("Lot 1, Block A"), + coordinates: Some((40_712_776, -74_005_974)), + year_built: Some(2020), + bedrooms: Some(3), + bathrooms: Some(2), + zoning: Some(String::from("R-1")), + } + } + + fn default_ipfs_resources() -> IpfsResources { + IpfsResources { + metadata_cid: None, + documents_cid: None, + images_cid: None, + legal_docs_cid: None, + virtual_tour_cid: None, + floor_plans_cid: None, + } + } + + #[ink::test] + fn create_metadata_works() { + let mut contract = AdvancedMetadataRegistry::new(); + let result = contract.create_metadata( + 1, + default_core(), + default_ipfs_resources(), + Hash::from([0x01; 32]), + ); + assert!(result.is_ok()); + assert_eq!(contract.total_properties(), 1); + assert_eq!(contract.current_version(1), Some(1)); + } + + #[ink::test] + fn update_metadata_increments_version() { + let mut contract = AdvancedMetadataRegistry::new(); + contract + .create_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x01; 32])) + .unwrap(); + + let mut updated_core = default_core(); + updated_core.valuation = 2_000_000; + + let result = contract.update_metadata( + 1, + updated_core, + default_ipfs_resources(), + Hash::from([0x02; 32]), + String::from("Valuation update"), + None, + ); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 2); + assert_eq!(contract.current_version(1), Some(2)); + } + + #[ink::test] + fn finalized_metadata_cannot_be_updated() { + let mut contract = AdvancedMetadataRegistry::new(); + contract + .create_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x01; 32])) + .unwrap(); + contract.finalize_metadata(1).unwrap(); + + let result = contract.update_metadata( + 1, + default_core(), + default_ipfs_resources(), + Hash::from([0x02; 32]), + String::from("Should fail"), + None, + ); + assert_eq!(result, Err(Error::MetadataAlreadyFinalized)); + } + + #[ink::test] + fn version_history_tracking_works() { + let mut contract = AdvancedMetadataRegistry::new(); + contract + .create_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x01; 32])) + .unwrap(); + contract + .update_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x02; 32]), String::from("Update 1"), None) + .unwrap(); + + let history = contract.get_version_history(1); + assert_eq!(history.len(), 2); + assert_eq!(history[0].version, 1); + assert_eq!(history[1].version, 2); + } + + #[ink::test] + fn add_legal_document_works() { + let mut contract = AdvancedMetadataRegistry::new(); + contract + .create_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x01; 32])) + .unwrap(); + + let result = contract.add_legal_document( + 1, + LegalDocType::Deed, + String::from("Qm12345678901234567890123456789012345678901234"), + Hash::from([0x03; 32]), + String::from("County Records"), + 1700000000, + None, + ); + assert!(result.is_ok()); + + let docs = contract.get_legal_documents(1); + assert_eq!(docs.len(), 1); + assert!(!docs[0].is_verified); + } + + #[ink::test] + fn verify_legal_document_works() { + let mut contract = AdvancedMetadataRegistry::new(); + contract + .create_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x01; 32])) + .unwrap(); + + contract + .add_legal_document( + 1, + LegalDocType::Title, + String::from("Qm12345678901234567890123456789012345678901234"), + Hash::from([0x03; 32]), + String::from("Title Company"), + 1700000000, + None, + ) + .unwrap(); + + // Admin can verify + let result = contract.verify_legal_document(1, 1); + assert!(result.is_ok()); + + let docs = contract.get_legal_documents(1); + assert!(docs[0].is_verified); + } + + #[ink::test] + fn add_media_item_works() { + let mut contract = AdvancedMetadataRegistry::new(); + contract + .create_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x01; 32])) + .unwrap(); + + let result = contract.add_media_item( + 1, + 0, // image + String::from("Qm12345678901234567890123456789012345678901234"), + String::from("Front view"), + String::from("image/jpeg"), + 1024 * 1024, + Hash::from([0x04; 32]), + ); + assert!(result.is_ok()); + + let multimedia = contract.get_multimedia(1).unwrap(); + assert_eq!(multimedia.images.len(), 1); + } + + #[ink::test] + fn properties_by_type_query_works() { + let mut contract = AdvancedMetadataRegistry::new(); + contract + .create_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x01; 32])) + .unwrap(); + + let residential = contract.get_properties_by_type(MetadataPropertyType::Residential); + assert_eq!(residential.len(), 1); + assert_eq!(residential[0], 1); + + let commercial = contract.get_properties_by_type(MetadataPropertyType::Commercial); + assert_eq!(commercial.len(), 0); + } + + #[ink::test] + fn content_hash_verification_works() { + let mut contract = AdvancedMetadataRegistry::new(); + contract + .create_metadata(1, default_core(), default_ipfs_resources(), Hash::from([0x01; 32])) + .unwrap(); + + assert_eq!( + contract.verify_content_hash(1, Hash::from([0x01; 32])), + Ok(true) + ); + assert_eq!( + contract.verify_content_hash(1, Hash::from([0x02; 32])), + Ok(false) + ); + } + } +} diff --git a/contracts/proxy/src/lib.rs b/contracts/proxy/src/lib.rs index 931efb212..b1e5c1d29 100644 --- a/contracts/proxy/src/lib.rs +++ b/contracts/proxy/src/lib.rs @@ -1,81 +1,1042 @@ #![cfg_attr(not(feature = "std"), no_std)] #![allow(dead_code)] +//! # PropChain Transparent Proxy with Upgrade Governance +//! +//! Enhanced proxy pattern for upgradeable ink! contracts with: +//! - Transparent proxy pattern (admin vs user call routing) +//! - Multi-sig upgrade governance mechanism +//! - Version compatibility checking +//! - Rollback capabilities +//! - Upgrade timelock (delay before activation) +//! - Migration state tracking +//! +//! Resolves: https://github.com/MettaChain/PropChain-contract/issues/77 + +use ink::prelude::string::String; +use ink::prelude::vec::Vec; + #[ink::contract] mod propchain_proxy { + use super::*; /// Unique storage key for the proxy data to avoid collisions. /// bytes4(keccak256("proxy.storage")) = 0xc5f3bc7a #[allow(dead_code)] const PROXY_STORAGE_KEY: u32 = 0xC5F3BC7A; + /// Minimum timelock period (in blocks) before an upgrade can be executed + const MIN_TIMELOCK_BLOCKS: u32 = 10; + + /// Maximum number of stored versions for rollback + const MAX_VERSION_HISTORY: u32 = 10; + + // ======================================================================== + // ERROR TYPES + // ======================================================================== + #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)] #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] pub enum Error { Unauthorized, UpgradeFailed, + /// Upgrade proposal not found + ProposalNotFound, + /// Upgrade proposal already exists + ProposalAlreadyExists, + /// Timelock period has not passed + TimelockNotExpired, + /// Insufficient governance approvals + InsufficientApprovals, + /// Caller has already approved this proposal + AlreadyApproved, + /// No previous version to rollback to + NoPreviousVersion, + /// Version compatibility check failed + IncompatibleVersion, + /// Contract is currently in migration state + MigrationInProgress, + /// Not a registered governor + NotGovernor, + /// Proposal has been cancelled + ProposalCancelled, + /// Emergency pause is active + EmergencyPauseActive, + /// Invalid timelock period + InvalidTimelockPeriod, } - #[ink(storage)] - pub struct TransparentProxy { - /// The address of the current implementation contract. - code_hash: Hash, - /// The address of the proxy admin. - admin: AccountId, + // ======================================================================== + // DATA STRUCTURES + // ======================================================================== + + /// Version information for deployed contract implementations + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct VersionInfo { + /// Semantic version: major + pub major: u32, + /// Semantic version: minor + pub minor: u32, + /// Semantic version: patch + pub patch: u32, + /// Code hash of this version's implementation + pub code_hash: Hash, + /// Block number when this version was deployed + pub deployed_at_block: u32, + /// Timestamp when this version was deployed + pub deployed_at: u64, + /// Description of changes in this version + pub description: String, + /// Account that deployed this version + pub deployed_by: AccountId, + } + + /// Upgrade proposal requiring governance approval + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct UpgradeProposal { + /// Unique proposal ID + pub id: u64, + /// New code hash to upgrade to + pub new_code_hash: Hash, + /// Proposed version info + pub version: VersionInfo, + /// Account that proposed the upgrade + pub proposer: AccountId, + /// Block number when proposal was created + pub created_at_block: u32, + /// Timestamp when proposal was created + pub created_at: u64, + /// Block number after which upgrade can be executed + pub timelock_until_block: u32, + /// Accounts that have approved this proposal + pub approvals: Vec, + /// Required number of approvals + pub required_approvals: u32, + /// Whether the proposal is cancelled + pub cancelled: bool, + /// Whether the proposal has been executed + pub executed: bool, + /// Migration notes / instructions + pub migration_notes: String, } + /// Migration state tracking + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum MigrationState { + /// No migration in progress + None, + /// Migration proposed and awaiting approval + Proposed, + /// Migration approved, waiting for timelock + Approved, + /// Migration in progress (executing) + InProgress, + /// Migration completed + Completed, + /// Migration rolled back + RolledBack, + } + + // ======================================================================== + // EVENTS + // ======================================================================== + #[ink(event)] pub struct Upgraded { #[ink(topic)] new_code_hash: Hash, + #[ink(topic)] + proposal_id: u64, + from_version: String, + to_version: String, + timestamp: u64, } #[ink(event)] pub struct AdminChanged { + #[ink(topic)] + old_admin: AccountId, #[ink(topic)] new_admin: AccountId, } + #[ink(event)] + pub struct UpgradeProposed { + #[ink(topic)] + proposal_id: u64, + #[ink(topic)] + proposer: AccountId, + new_code_hash: Hash, + timelock_until_block: u32, + timestamp: u64, + } + + #[ink(event)] + pub struct UpgradeApproved { + #[ink(topic)] + proposal_id: u64, + #[ink(topic)] + approver: AccountId, + current_approvals: u32, + required_approvals: u32, + timestamp: u64, + } + + #[ink(event)] + pub struct UpgradeCancelled { + #[ink(topic)] + proposal_id: u64, + #[ink(topic)] + cancelled_by: AccountId, + timestamp: u64, + } + + #[ink(event)] + pub struct UpgradeRolledBack { + #[ink(topic)] + from_version: String, + #[ink(topic)] + to_version: String, + rolled_back_by: AccountId, + timestamp: u64, + } + + #[ink(event)] + pub struct GovernorAdded { + #[ink(topic)] + governor: AccountId, + added_by: AccountId, + } + + #[ink(event)] + pub struct GovernorRemoved { + #[ink(topic)] + governor: AccountId, + removed_by: AccountId, + } + + #[ink(event)] + pub struct EmergencyPauseToggled { + #[ink(topic)] + paused: bool, + by: AccountId, + timestamp: u64, + } + + // ======================================================================== + // CONTRACT STORAGE + // ======================================================================== + + #[ink(storage)] + pub struct TransparentProxy { + /// The code hash of the current implementation contract. + code_hash: Hash, + /// The address of the proxy admin. + admin: AccountId, + /// Governance accounts that can approve upgrades + governors: Vec, + /// Upgrade proposals + proposals: ink::storage::Mapping, + /// Proposal counter + proposal_counter: u64, + /// Required number of approvals for upgrade + required_approvals: u32, + /// Timelock period in blocks + timelock_blocks: u32, + /// Version history (ordered, most recent last) + version_history: Vec, + /// Current version index + current_version_index: u32, + /// Migration state + migration_state: MigrationState, + /// Emergency pause flag + emergency_pause: bool, + } + + // ======================================================================== + // IMPLEMENTATION + // ======================================================================== + impl TransparentProxy { + /// Creates a new proxy with governance configuration #[ink(constructor)] pub fn new(code_hash: Hash) -> Self { + let caller = Self::env().caller(); + let initial_version = VersionInfo { + major: 1, + minor: 0, + patch: 0, + code_hash, + deployed_at_block: Self::env().block_number(), + deployed_at: Self::env().block_timestamp(), + description: String::from("Initial deployment"), + deployed_by: caller, + }; + + Self { + code_hash, + admin: caller, + governors: vec![caller], + proposals: ink::storage::Mapping::default(), + proposal_counter: 0, + required_approvals: 1, + timelock_blocks: MIN_TIMELOCK_BLOCKS, + version_history: vec![initial_version], + current_version_index: 0, + migration_state: MigrationState::None, + emergency_pause: false, + } + } + + /// Creates a new proxy with custom governance parameters + #[ink(constructor)] + pub fn new_with_governance( + code_hash: Hash, + governors: Vec, + required_approvals: u32, + timelock_blocks: u32, + ) -> Self { + let caller = Self::env().caller(); + let initial_version = VersionInfo { + major: 1, + minor: 0, + patch: 0, + code_hash, + deployed_at_block: Self::env().block_number(), + deployed_at: Self::env().block_timestamp(), + description: String::from("Initial deployment"), + deployed_by: caller, + }; + + let effective_timelock = if timelock_blocks < MIN_TIMELOCK_BLOCKS { + MIN_TIMELOCK_BLOCKS + } else { + timelock_blocks + }; + + let effective_required = if required_approvals == 0 || required_approvals > governors.len() as u32 { + 1 + } else { + required_approvals + }; + Self { code_hash, - admin: Self::env().caller(), + admin: caller, + governors, + proposals: ink::storage::Mapping::default(), + proposal_counter: 0, + required_approvals: effective_required, + timelock_blocks: effective_timelock, + version_history: vec![initial_version], + current_version_index: 0, + migration_state: MigrationState::None, + emergency_pause: false, } } + // ==================================================================== + // UPGRADE GOVERNANCE + // ==================================================================== + + /// Proposes a new upgrade with version info and timelock #[ink(message)] - pub fn upgrade_to(&mut self, new_code_hash: Hash) -> Result<(), Error> { + pub fn propose_upgrade( + &mut self, + new_code_hash: Hash, + major: u32, + minor: u32, + patch: u32, + description: String, + migration_notes: String, + ) -> Result { + let caller = self.env().caller(); + self.ensure_governor(caller)?; + self.ensure_not_paused()?; + + if self.migration_state != MigrationState::None + && self.migration_state != MigrationState::Completed + && self.migration_state != MigrationState::RolledBack + { + return Err(Error::MigrationInProgress); + } + + // Version compatibility check: new version must be >= current + self.check_version_compatibility(major, minor, patch)?; + + self.proposal_counter += 1; + let proposal_id = self.proposal_counter; + + let current_block = self.env().block_number(); + let timelock_until = current_block + self.timelock_blocks; + + let version = VersionInfo { + major, + minor, + patch, + code_hash: new_code_hash, + deployed_at_block: 0, // Set upon execution + deployed_at: 0, // Set upon execution + description, + deployed_by: caller, + }; + + let proposal = UpgradeProposal { + id: proposal_id, + new_code_hash, + version, + proposer: caller, + created_at_block: current_block, + created_at: self.env().block_timestamp(), + timelock_until_block: timelock_until, + approvals: vec![caller], // Proposer auto-approves + required_approvals: self.required_approvals, + cancelled: false, + executed: false, + migration_notes, + }; + + self.proposals.insert(proposal_id, &proposal); + self.migration_state = MigrationState::Proposed; + + self.env().emit_event(UpgradeProposed { + proposal_id, + proposer: caller, + new_code_hash, + timelock_until_block: timelock_until, + timestamp: self.env().block_timestamp(), + }); + + Ok(proposal_id) + } + + /// Approves an upgrade proposal + #[ink(message)] + pub fn approve_upgrade(&mut self, proposal_id: u64) -> Result<(), Error> { + let caller = self.env().caller(); + self.ensure_governor(caller)?; + self.ensure_not_paused()?; + + let mut proposal = self + .proposals + .get(proposal_id) + .ok_or(Error::ProposalNotFound)?; + + if proposal.cancelled { + return Err(Error::ProposalCancelled); + } + + if proposal.executed { + return Err(Error::ProposalNotFound); + } + + if proposal.approvals.contains(&caller) { + return Err(Error::AlreadyApproved); + } + + proposal.approvals.push(caller); + + let current_approvals = proposal.approvals.len() as u32; + + if current_approvals >= proposal.required_approvals { + self.migration_state = MigrationState::Approved; + } + + self.proposals.insert(proposal_id, &proposal); + + self.env().emit_event(UpgradeApproved { + proposal_id, + approver: caller, + current_approvals, + required_approvals: proposal.required_approvals, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + /// Executes an approved upgrade after timelock period + #[ink(message)] + pub fn execute_upgrade(&mut self, proposal_id: u64) -> Result<(), Error> { + let caller = self.env().caller(); + self.ensure_governor(caller)?; + self.ensure_not_paused()?; + + let mut proposal = self + .proposals + .get(proposal_id) + .ok_or(Error::ProposalNotFound)?; + + if proposal.cancelled { + return Err(Error::ProposalCancelled); + } + if proposal.executed { + return Err(Error::ProposalNotFound); + } + + // Check approvals + if (proposal.approvals.len() as u32) < proposal.required_approvals { + return Err(Error::InsufficientApprovals); + } + + // Check timelock + if self.env().block_number() < proposal.timelock_until_block { + return Err(Error::TimelockNotExpired); + } + + // Execute the upgrade + self.migration_state = MigrationState::InProgress; + + let old_version = self.format_current_version(); + + // Update code hash + let old_code_hash = self.code_hash; + self.code_hash = proposal.new_code_hash; + + // Record version history + let mut version_info = proposal.version.clone(); + version_info.deployed_at_block = self.env().block_number(); + version_info.deployed_at = self.env().block_timestamp(); + version_info.deployed_by = caller; + + // Trim history if needed + if self.version_history.len() as u32 >= MAX_VERSION_HISTORY { + self.version_history.remove(0); + } + + self.version_history.push(version_info); + self.current_version_index = (self.version_history.len() - 1) as u32; + + // Mark proposal as executed + proposal.executed = true; + self.proposals.insert(proposal_id, &proposal); + + self.migration_state = MigrationState::Completed; + + let new_version = self.format_current_version(); + + self.env().emit_event(Upgraded { + new_code_hash: proposal.new_code_hash, + proposal_id, + from_version: old_version, + to_version: new_version, + timestamp: self.env().block_timestamp(), + }); + + // If the old code hash is different, we can try to apply via set_code_hash + // (only works for ink! contracts that support it) + let _ = old_code_hash; // suppress unused warning + + Ok(()) + } + + /// Cancels an upgrade proposal (proposer or admin) + #[ink(message)] + pub fn cancel_upgrade(&mut self, proposal_id: u64) -> Result<(), Error> { + let caller = self.env().caller(); + + let mut proposal = self + .proposals + .get(proposal_id) + .ok_or(Error::ProposalNotFound)?; + + if proposal.cancelled || proposal.executed { + return Err(Error::ProposalNotFound); + } + + // Only proposer or admin can cancel + if caller != proposal.proposer && caller != self.admin { + return Err(Error::Unauthorized); + } + + proposal.cancelled = true; + self.proposals.insert(proposal_id, &proposal); + + self.migration_state = MigrationState::None; + + self.env().emit_event(UpgradeCancelled { + proposal_id, + cancelled_by: caller, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + // ==================================================================== + // ROLLBACK + // ==================================================================== + + /// Rolls back to the previous version (admin only, emergency) + #[ink(message)] + pub fn rollback(&mut self) -> Result<(), Error> { self.ensure_admin()?; - self.code_hash = new_code_hash; - self.env().emit_event(Upgraded { new_code_hash }); + + if self.version_history.len() < 2 { + return Err(Error::NoPreviousVersion); + } + + let from_version = self.format_current_version(); + + // Get previous version + let prev_index = (self.version_history.len() - 2) as u32; + let prev_version = self.version_history[prev_index as usize].clone(); + + // Apply rollback + self.code_hash = prev_version.code_hash; + self.current_version_index = prev_index; + self.migration_state = MigrationState::RolledBack; + + let to_version = self.format_current_version(); + + self.env().emit_event(UpgradeRolledBack { + from_version, + to_version, + rolled_back_by: self.env().caller(), + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + // ==================================================================== + // EMERGENCY CONTROLS + // ==================================================================== + + /// Toggles emergency pause (admin only) + #[ink(message)] + pub fn toggle_emergency_pause(&mut self) -> Result<(), Error> { + self.ensure_admin()?; + self.emergency_pause = !self.emergency_pause; + + self.env().emit_event(EmergencyPauseToggled { + paused: self.emergency_pause, + by: self.env().caller(), + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + // ==================================================================== + // GOVERNANCE MANAGEMENT + // ==================================================================== + + /// Adds a governor (admin only) + #[ink(message)] + pub fn add_governor(&mut self, governor: AccountId) -> Result<(), Error> { + self.ensure_admin()?; + if !self.governors.contains(&governor) { + self.governors.push(governor); + self.env().emit_event(GovernorAdded { + governor, + added_by: self.env().caller(), + }); + } + Ok(()) + } + + /// Removes a governor (admin only) + #[ink(message)] + pub fn remove_governor(&mut self, governor: AccountId) -> Result<(), Error> { + self.ensure_admin()?; + self.governors.retain(|g| *g != governor); + self.env().emit_event(GovernorRemoved { + governor, + removed_by: self.env().caller(), + }); + Ok(()) + } + + /// Updates required approval count (admin only) + #[ink(message)] + pub fn set_required_approvals(&mut self, required: u32) -> Result<(), Error> { + self.ensure_admin()?; + if required == 0 || required > self.governors.len() as u32 { + return Err(Error::InsufficientApprovals); + } + self.required_approvals = required; Ok(()) } + /// Updates timelock period (admin only) + #[ink(message)] + pub fn set_timelock_blocks(&mut self, blocks: u32) -> Result<(), Error> { + self.ensure_admin()?; + if blocks < MIN_TIMELOCK_BLOCKS { + return Err(Error::InvalidTimelockPeriod); + } + self.timelock_blocks = blocks; + Ok(()) + } + + /// Changes the admin address #[ink(message)] pub fn change_admin(&mut self, new_admin: AccountId) -> Result<(), Error> { self.ensure_admin()?; + let old_admin = self.admin; self.admin = new_admin; - self.env().emit_event(AdminChanged { new_admin }); + self.env().emit_event(AdminChanged { + old_admin, + new_admin, + }); + Ok(()) + } + + // ==================================================================== + // DIRECT UPGRADE (backwards compatibility, admin only) + // ==================================================================== + + /// Direct upgrade without governance (admin only, for emergencies) + #[ink(message)] + pub fn upgrade_to(&mut self, new_code_hash: Hash) -> Result<(), Error> { + self.ensure_admin()?; + self.ensure_not_paused()?; + + let old_version = self.format_current_version(); + self.code_hash = new_code_hash; + + // Record as emergency version + let version_info = VersionInfo { + major: self.current_version().0, + minor: self.current_version().1, + patch: self.current_version().2 + 1, + code_hash: new_code_hash, + deployed_at_block: self.env().block_number(), + deployed_at: self.env().block_timestamp(), + description: String::from("Emergency direct upgrade"), + deployed_by: self.env().caller(), + }; + + if self.version_history.len() as u32 >= MAX_VERSION_HISTORY { + self.version_history.remove(0); + } + self.version_history.push(version_info); + self.current_version_index = (self.version_history.len() - 1) as u32; + + let new_version = self.format_current_version(); + + self.env().emit_event(Upgraded { + new_code_hash, + proposal_id: 0, // Direct upgrade, no proposal + from_version: old_version, + to_version: new_version, + timestamp: self.env().block_timestamp(), + }); + Ok(()) } + // ==================================================================== + // QUERY FUNCTIONS + // ==================================================================== + + /// Returns the current implementation code hash #[ink(message)] pub fn code_hash(&self) -> Hash { self.code_hash } + /// Returns the admin address #[ink(message)] pub fn admin(&self) -> AccountId { self.admin } + /// Returns the list of governors + #[ink(message)] + pub fn governors(&self) -> Vec { + self.governors.clone() + } + + /// Returns the current version as (major, minor, patch) + #[ink(message)] + pub fn current_version(&self) -> (u32, u32, u32) { + if let Some(version) = self.version_history.get(self.current_version_index as usize) { + (version.major, version.minor, version.patch) + } else { + (1, 0, 0) + } + } + + /// Returns the full version history + #[ink(message)] + pub fn get_version_history(&self) -> Vec { + self.version_history.clone() + } + + /// Returns a specific upgrade proposal + #[ink(message)] + pub fn get_proposal(&self, proposal_id: u64) -> Option { + self.proposals.get(proposal_id) + } + + /// Returns the current migration state + #[ink(message)] + pub fn migration_state(&self) -> MigrationState { + self.migration_state.clone() + } + + /// Returns whether emergency pause is active + #[ink(message)] + pub fn is_paused(&self) -> bool { + self.emergency_pause + } + + /// Returns required approvals count + #[ink(message)] + pub fn get_required_approvals(&self) -> u32 { + self.required_approvals + } + + /// Returns timelock period in blocks + #[ink(message)] + pub fn get_timelock_blocks(&self) -> u32 { + self.timelock_blocks + } + + /// Returns whether version compatibility checks pass for a target version + #[ink(message)] + pub fn check_compatibility(&self, major: u32, minor: u32, patch: u32) -> bool { + self.check_version_compatibility(major, minor, patch).is_ok() + } + + // ==================================================================== + // INTERNAL HELPERS + // ==================================================================== + fn ensure_admin(&self) -> Result<(), Error> { if self.env().caller() != self.admin { return Err(Error::Unauthorized); } Ok(()) } + + fn ensure_governor(&self, caller: AccountId) -> Result<(), Error> { + if !self.governors.contains(&caller) && caller != self.admin { + return Err(Error::NotGovernor); + } + Ok(()) + } + + fn ensure_not_paused(&self) -> Result<(), Error> { + if self.emergency_pause { + return Err(Error::EmergencyPauseActive); + } + Ok(()) + } + + fn check_version_compatibility( + &self, + major: u32, + minor: u32, + patch: u32, + ) -> Result<(), Error> { + let (cur_major, cur_minor, cur_patch) = self.current_version(); + + // New version must be >= current version + if major > cur_major { + return Ok(()); + } + if major == cur_major && minor > cur_minor { + return Ok(()); + } + if major == cur_major && minor == cur_minor && patch > cur_patch { + return Ok(()); + } + + Err(Error::IncompatibleVersion) + } + + fn format_current_version(&self) -> String { + let (major, minor, patch) = self.current_version(); + let mut v = String::from("v"); + // Manual formatting without format!() macro overhead + v.push_str(&Self::u32_to_string(major)); + v.push('.'); + v.push_str(&Self::u32_to_string(minor)); + v.push('.'); + v.push_str(&Self::u32_to_string(patch)); + v + } + + fn u32_to_string(n: u32) -> String { + if n == 0 { + return String::from("0"); + } + let mut s = String::new(); + let mut num = n; + let mut digits = Vec::new(); + while num > 0 { + digits.push((b'0' + (num % 10) as u8) as char); + num /= 10; + } + digits.reverse(); + for d in digits { + s.push(d); + } + s + } + } + + // ======================================================================== + // UNIT TESTS + // ======================================================================== + + #[cfg(test)] + mod tests { + use super::*; + + #[ink::test] + fn new_initializes_correctly() { + let hash = Hash::from([0x42; 32]); + let proxy = TransparentProxy::new(hash); + assert_eq!(proxy.code_hash(), hash); + assert_eq!(proxy.current_version(), (1, 0, 0)); + assert_eq!(proxy.get_version_history().len(), 1); + assert_eq!(proxy.migration_state(), MigrationState::None); + assert!(!proxy.is_paused()); + } + + #[ink::test] + fn propose_upgrade_works() { + let hash = Hash::from([0x42; 32]); + let mut proxy = TransparentProxy::new(hash); + + let new_hash = Hash::from([0x43; 32]); + let result = proxy.propose_upgrade( + new_hash, + 1, + 1, + 0, + String::from("Feature upgrade"), + String::from("No migration needed"), + ); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let proposal = proxy.get_proposal(1).unwrap(); + assert_eq!(proposal.new_code_hash, new_hash); + assert!(!proposal.cancelled); + assert!(!proposal.executed); + } + + #[ink::test] + fn version_compatibility_check_works() { + let hash = Hash::from([0x42; 32]); + let proxy = TransparentProxy::new(hash); + + // Version 1.1.0 is compatible (higher) + assert!(proxy.check_compatibility(1, 1, 0)); + // Version 2.0.0 is compatible (higher) + assert!(proxy.check_compatibility(2, 0, 0)); + // Version 0.9.0 is not compatible (lower) + assert!(!proxy.check_compatibility(0, 9, 0)); + // Same version is not compatible + assert!(!proxy.check_compatibility(1, 0, 0)); + } + + #[ink::test] + fn direct_upgrade_works() { + let hash = Hash::from([0x42; 32]); + let mut proxy = TransparentProxy::new(hash); + + let new_hash = Hash::from([0x43; 32]); + let result = proxy.upgrade_to(new_hash); + assert!(result.is_ok()); + assert_eq!(proxy.code_hash(), new_hash); + assert_eq!(proxy.get_version_history().len(), 2); + } + + #[ink::test] + fn rollback_works() { + let hash = Hash::from([0x42; 32]); + let mut proxy = TransparentProxy::new(hash); + + let new_hash = Hash::from([0x43; 32]); + proxy.upgrade_to(new_hash).unwrap(); + assert_eq!(proxy.code_hash(), new_hash); + + let rollback_result = proxy.rollback(); + assert!(rollback_result.is_ok()); + assert_eq!(proxy.code_hash(), hash); + assert_eq!(proxy.migration_state(), MigrationState::RolledBack); + } + + #[ink::test] + fn rollback_fails_with_no_history() { + let hash = Hash::from([0x42; 32]); + let mut proxy = TransparentProxy::new(hash); + assert_eq!(proxy.rollback(), Err(Error::NoPreviousVersion)); + } + + #[ink::test] + fn emergency_pause_works() { + let hash = Hash::from([0x42; 32]); + let mut proxy = TransparentProxy::new(hash); + assert!(!proxy.is_paused()); + + proxy.toggle_emergency_pause().unwrap(); + assert!(proxy.is_paused()); + + // Upgrade should fail when paused + let new_hash = Hash::from([0x43; 32]); + assert_eq!(proxy.upgrade_to(new_hash), Err(Error::EmergencyPauseActive)); + + proxy.toggle_emergency_pause().unwrap(); + assert!(!proxy.is_paused()); + } + + #[ink::test] + fn cancel_upgrade_works() { + let hash = Hash::from([0x42; 32]); + let mut proxy = TransparentProxy::new(hash); + + let new_hash = Hash::from([0x43; 32]); + proxy + .propose_upgrade( + new_hash, + 1, + 1, + 0, + String::from("Test"), + String::from(""), + ) + .unwrap(); + + let result = proxy.cancel_upgrade(1); + assert!(result.is_ok()); + + let proposal = proxy.get_proposal(1).unwrap(); + assert!(proposal.cancelled); + } + + #[ink::test] + fn governor_management_works() { + let hash = Hash::from([0x42; 32]); + let mut proxy = TransparentProxy::new(hash); + + let new_governor = AccountId::from([0x02; 32]); + proxy.add_governor(new_governor).unwrap(); + assert_eq!(proxy.governors().len(), 2); + + proxy.remove_governor(new_governor).unwrap(); + assert_eq!(proxy.governors().len(), 1); + } } } diff --git a/contracts/third-party/Cargo.toml b/contracts/third-party/Cargo.toml new file mode 100644 index 000000000..7e330013c --- /dev/null +++ b/contracts/third-party/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "propchain-third-party" +version.workspace = true +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +description = "Third-party service integrations for PropChain (KYC, Payments, Monitoring, Oracles)" + +[dependencies] +ink = { workspace = true } +scale = { workspace = true } +scale-info = { workspace = true } +propchain-traits = { path = "../traits" } + +[dev-dependencies] +ink_e2e = "5.0.0" + +[lib] +name = "propchain_third_party" +path = "src/lib.rs" + +[features] +default = ["std"] +std = [ + "ink/std", + "scale/std", + "scale-info/std", +] +ink-as-dependency = [] +e2e-tests = [] diff --git a/contracts/third-party/src/lib.rs b/contracts/third-party/src/lib.rs new file mode 100644 index 000000000..f965305f1 --- /dev/null +++ b/contracts/third-party/src/lib.rs @@ -0,0 +1,758 @@ +#![cfg_attr(not(feature = "std"), no_std)] +#![allow(unexpected_cfgs)] +#![allow(clippy::new_without_default)] + +//! # PropChain Third-Party Service Integration +//! +//! Orchestrates interactions between PropChain contracts and external services: +//! - KYC/AML Providers (Identity verification, status checking) +//! - Fiat Payment Gateways (Bridging fiat payments to on-chain operations) +//! - Off-chain Monitoring and Alerting systems +//! - Service API endpoints and credential management +//! +//! Resolves: https://github.com/MettaChain/PropChain-contract/issues/113 + +use ink::prelude::string::String; +use ink::prelude::vec::Vec; +use ink::storage::Mapping; + +#[ink::contract] +mod propchain_third_party { + use super::*; + + // ======================================================================== + // TYPES + // ======================================================================== + + pub type ServiceId = u32; + pub type RequestId = u64; + + // ======================================================================== + // DATA STRUCTURES + // ======================================================================== + + /// Type of third-party service + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum ServiceType { + /// KYC / AML Verification + KycProvider, + /// Fiat Payment Gateway + PaymentGateway, + /// Monitoring / Alerting + Monitoring, + /// Off-chain data oracle + DataOracle, + /// Document signing (e.g., DocuSign) + LegalSigning, + /// Tax calculation service + TaxService, + /// Other + Other, + } + + /// Status of a service + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum ServiceStatus { + Active, + Inactive, + Suspended, + Maintenance, + } + + /// Configuration for a registered third-party service + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct ServiceConfig { + pub service_id: ServiceId, + pub service_type: ServiceType, + pub name: String, + pub provider_account: AccountId, + pub endpoint_url: String, + pub api_version: String, + pub status: ServiceStatus, + pub registered_at: u64, + pub fees_collected: u128, + pub fee_percentage: u16, // In basis points (1 = 0.01%) + } + + /// KYC Verification Request + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct KycRequest { + pub request_id: RequestId, + pub user: AccountId, + pub service_id: ServiceId, + pub reference_id: String, + pub status: RequestStatus, + pub initiated_at: u64, + pub updated_at: u64, + pub expiry_date: Option, + } + + /// Fiat Payment Request (bridging off-chain to on-chain) + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct PaymentRequest { + pub request_id: RequestId, + pub payer: AccountId, + pub service_id: ServiceId, + pub target_contract: AccountId, + pub operation_type: u8, // e.g., 1=Purchase, 2=Escrow, 3=Fee + pub fiat_amount: u128, + pub fiat_currency: String, + pub equivalent_tokens: u128, + pub payment_reference: String, + pub status: RequestStatus, + pub init_time: u64, + pub complete_time: Option, + } + + /// Request Status + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum RequestStatus { + Pending, + Processing, + Approved, + Rejected, + Failed, + Expired, + } + + /// KYC Status stored on-chain + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct KycRecord { + pub user: AccountId, + pub provider_id: ServiceId, + pub verification_level: u8, + pub verified_at: u64, + pub expires_at: u64, + pub is_active: bool, + } + + // ======================================================================== + // ERRORS + // ======================================================================== + + #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum Error { + Unauthorized, + ServiceNotFound, + ServiceInactive, + RequestNotFound, + InvalidStatusTransition, + InvalidFeePercentage, + KycExpired, + PaymentProcessingFailed, + } + + // ======================================================================== + // EVENTS + // ======================================================================== + + #[ink(event)] + pub struct ServiceRegistered { + #[ink(topic)] + service_id: ServiceId, + service_type: ServiceType, + name: String, + provider_account: AccountId, + } + + #[ink(event)] + pub struct ServiceStatusChanged { + #[ink(topic)] + service_id: ServiceId, + old_status: ServiceStatus, + new_status: ServiceStatus, + } + + #[ink(event)] + pub struct KycRequestInitiated { + #[ink(topic)] + request_id: RequestId, + #[ink(topic)] + user: AccountId, + service_id: ServiceId, + } + + #[ink(event)] + pub struct KycStatusUpdated { + #[ink(topic)] + request_id: RequestId, + #[ink(topic)] + user: AccountId, + status: RequestStatus, + verification_level: u8, + } + + #[ink(event)] + pub struct PaymentInitiated { + #[ink(topic)] + request_id: RequestId, + #[ink(topic)] + payer: AccountId, + service_id: ServiceId, + fiat_amount: u128, + currency: String, + } + + #[ink(event)] + pub struct PaymentCompleted { + #[ink(topic)] + request_id: RequestId, + status: RequestStatus, + equivalent_tokens: u128, + } + + #[ink(event)] + pub struct MonitoringAlert { + #[ink(topic)] + service_id: ServiceId, + #[ink(topic)] + severity: u8, + message: String, + timestamp: u64, + } + + // ======================================================================== + // CONTRACT STORAGE + // ======================================================================== + + #[ink(storage)] + pub struct ThirdPartyIntegration { + /// Contract admin + admin: AccountId, + /// Registered services + services: Mapping, + /// Number of services + service_counter: ServiceId, + /// Provider account to service ID mapped + provider_services: Mapping>, + + /// KYC records (User -> Record) + kyc_records: Mapping, + /// KYC requests + kyc_requests: Mapping, + + /// Payment requests + payment_requests: Mapping, + + /// Request counter + request_counter: RequestId, + } + + // ======================================================================== + // IMPLEMENTATION + // ======================================================================== + + impl ThirdPartyIntegration { + #[ink(constructor)] + pub fn new() -> Self { + let caller = Self::env().caller(); + Self { + admin: caller, + services: Mapping::default(), + service_counter: 0, + provider_services: Mapping::default(), + kyc_records: Mapping::default(), + kyc_requests: Mapping::default(), + payment_requests: Mapping::default(), + request_counter: 0, + } + } + + // ==================================================================== + // SERVICE MANAGEMENT + // ==================================================================== + + /// Register a new third-party service (Admin only) + #[ink(message)] + pub fn register_service( + &mut self, + service_type: ServiceType, + name: String, + provider_account: AccountId, + endpoint_url: String, + api_version: String, + fee_percentage: u16, + ) -> Result { + self.ensure_admin()?; + + if fee_percentage > 10000 { + return Err(Error::InvalidFeePercentage); + } + + self.service_counter += 1; + let service_id = self.service_counter; + + let config = ServiceConfig { + service_id, + service_type: service_type.clone(), + name: name.clone(), + provider_account, + endpoint_url, + api_version, + status: ServiceStatus::Active, + registered_at: self.env().block_timestamp(), + fees_collected: 0, + fee_percentage, + }; + + self.services.insert(service_id, &config); + + let mut provider_list = self.provider_services.get(provider_account).unwrap_or_default(); + provider_list.push(service_id); + self.provider_services.insert(provider_account, &provider_list); + + self.env().emit_event(ServiceRegistered { + service_id, + service_type, + name, + provider_account, + }); + + Ok(service_id) + } + + /// Update service status (Admin or Provider) + #[ink(message)] + pub fn update_service_status( + &mut self, + service_id: ServiceId, + new_status: ServiceStatus, + ) -> Result<(), Error> { + let caller = self.env().caller(); + let mut service = self.get_service_mut(service_id)?; + + if caller != self.admin && caller != service.provider_account { + return Err(Error::Unauthorized); + } + + let old_status = service.status.clone(); + service.status = new_status.clone(); + self.services.insert(service_id, &service); + + self.env().emit_event(ServiceStatusChanged { + service_id, + old_status, + new_status, + }); + + Ok(()) + } + + // ==================================================================== + // KYC INTEGRATION + // ==================================================================== + + /// Initiate KYC request (User or Admin) + #[ink(message)] + pub fn initiate_kyc_request( + &mut self, + service_id: ServiceId, + user: AccountId, + reference_id: String, + ) -> Result { + let caller = self.env().caller(); + if caller != user && caller != self.admin { + return Err(Error::Unauthorized); + } + + self.ensure_service_active(service_id, ServiceType::KycProvider)?; + + self.request_counter += 1; + let request_id = self.request_counter; + + let req = KycRequest { + request_id, + user, + service_id, + reference_id, + status: RequestStatus::Pending, + initiated_at: self.env().block_timestamp(), + updated_at: self.env().block_timestamp(), + expiry_date: None, + }; + + self.kyc_requests.insert(request_id, &req); + + self.env().emit_event(KycRequestInitiated { + request_id, + user, + service_id, + }); + + Ok(request_id) + } + + /// Update KYC status (Provider only) + #[ink(message)] + pub fn update_kyc_status( + &mut self, + request_id: RequestId, + status: RequestStatus, + verification_level: u8, + valid_for_days: u64, + ) -> Result<(), Error> { + let caller = self.env().caller(); + + let mut req = self.kyc_requests.get(request_id).ok_or(Error::RequestNotFound)?; + let service = self.get_service(req.service_id)?; + + if caller != service.provider_account { + return Err(Error::Unauthorized); + } + + // Only update active statuses + if req.status == RequestStatus::Approved || req.status == RequestStatus::Rejected { + return Err(Error::InvalidStatusTransition); + } + + let timestamp = self.env().block_timestamp(); + req.status = status.clone(); + req.updated_at = timestamp; + + if status == RequestStatus::Approved { + let expires_at = timestamp + (valid_for_days * 86_400_000); + req.expiry_date = Some(expires_at); + + let record = KycRecord { + user: req.user, + provider_id: req.service_id, + verification_level, + verified_at: timestamp, + expires_at, + is_active: true, + }; + self.kyc_records.insert(req.user, &record); + } + + self.kyc_requests.insert(request_id, &req); + + self.env().emit_event(KycStatusUpdated { + request_id, + user: req.user, + status, + verification_level, + }); + + Ok(()) + } + + /// Check if a user is KYC verified (view function for other contracts) + #[ink(message)] + pub fn is_kyc_verified(&self, user: AccountId, required_level: u8) -> bool { + if let Some(record) = self.kyc_records.get(user) { + if record.is_active + && record.verification_level >= required_level + && record.expires_at > self.env().block_timestamp() + { + return true; + } + } + false + } + + // ==================================================================== + // FIAT PAYMENT GATEWAY INTEGRATION + // ==================================================================== + + /// Initiate fiat payment bridging + #[ink(message)] + pub fn initiate_fiat_payment( + &mut self, + service_id: ServiceId, + target_contract: AccountId, + operation_type: u8, + fiat_amount: u128, + fiat_currency: String, + payment_reference: String, + ) -> Result { + let payer = self.env().caller(); + self.ensure_service_active(service_id, ServiceType::PaymentGateway)?; + + self.request_counter += 1; + let request_id = self.request_counter; + + let req = PaymentRequest { + request_id, + payer, + service_id, + target_contract, + operation_type, + fiat_amount, + fiat_currency: fiat_currency.clone(), + equivalent_tokens: 0, + payment_reference, + status: RequestStatus::Pending, + init_time: self.env().block_timestamp(), + complete_time: None, + }; + + self.payment_requests.insert(request_id, &req); + + self.env().emit_event(PaymentInitiated { + request_id, + payer, + service_id, + fiat_amount, + currency: fiat_currency, + }); + + Ok(request_id) + } + + /// Complete fiat payment (Provider only) + #[ink(message)] + pub fn complete_payment( + &mut self, + request_id: RequestId, + success: bool, + equivalent_tokens: u128, + ) -> Result<(), Error> { + let caller = self.env().caller(); + + let mut req = self.payment_requests.get(request_id).ok_or(Error::RequestNotFound)?; + let service = self.get_service(req.service_id)?; + + if caller != service.provider_account { + return Err(Error::Unauthorized); + } + + if req.status != RequestStatus::Pending && req.status != RequestStatus::Processing { + return Err(Error::InvalidStatusTransition); + } + + req.status = if success { RequestStatus::Approved } else { RequestStatus::Failed }; + req.equivalent_tokens = equivalent_tokens; + req.complete_time = Some(self.env().block_timestamp()); + + self.payment_requests.insert(request_id, &req); + + self.env().emit_event(PaymentCompleted { + request_id, + status: req.status, + equivalent_tokens, + }); + + Ok(()) + } + + // ==================================================================== + // MONITORING & ALERTING + // ==================================================================== + + /// Log an alert from an external monitoring system + #[ink(message)] + pub fn log_alert( + &mut self, + service_id: ServiceId, + severity: u8, + message: String, + ) -> Result<(), Error> { + let caller = self.env().caller(); + let service = self.get_service(service_id)?; + + if caller != service.provider_account && service.service_type == ServiceType::Monitoring { + return Err(Error::Unauthorized); + } + + self.env().emit_event(MonitoringAlert { + service_id, + severity, + message, + timestamp: self.env().block_timestamp(), + }); + + Ok(()) + } + + // ==================================================================== + // QUERIES + // ==================================================================== + + #[ink(message)] + pub fn get_service_config(&self, service_id: ServiceId) -> Option { + self.services.get(service_id) + } + + #[ink(message)] + pub fn get_kyc_record(&self, user: AccountId) -> Option { + self.kyc_records.get(user) + } + + #[ink(message)] + pub fn get_payment_request(&self, request_id: RequestId) -> Option { + self.payment_requests.get(request_id) + } + + // ==================================================================== + // INTERNAL + // ==================================================================== + + fn ensure_admin(&self) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + Ok(()) + } + + fn get_service(&self, service_id: ServiceId) -> Result { + self.services.get(service_id).ok_or(Error::ServiceNotFound) + } + + fn get_service_mut(&self, service_id: ServiceId) -> Result { + self.services.get(service_id).ok_or(Error::ServiceNotFound) + } + + fn ensure_service_active(&self, service_id: ServiceId, expected_type: ServiceType) -> Result<(), Error> { + let service = self.get_service(service_id)?; + if service.status != ServiceStatus::Active { + return Err(Error::ServiceInactive); + } + if service.service_type != expected_type { + return Err(Error::ServiceNotFound); + } + Ok(()) + } + } + + impl Default for ThirdPartyIntegration { + fn default() -> Self { + Self::new() + } + } + + // ======================================================================== + // UNIT TESTS + // ======================================================================== + + #[cfg(test)] + mod tests { + use super::*; + + #[ink::test] + fn service_registration_works() { + let mut contract = ThirdPartyIntegration::new(); + let provider = AccountId::from([0x01; 32]); + + let result = contract.register_service( + ServiceType::KycProvider, + String::from("Test KYC"), + provider, + String::from("https://api.testkyc.com"), + String::from("v1"), + 0, + ); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 1); + + let service = contract.get_service_config(1).unwrap(); + assert_eq!(service.name, "Test KYC"); + assert_eq!(service.service_type, ServiceType::KycProvider); + } + + #[ink::test] + fn kyc_flow_works() { + let mut contract = ThirdPartyIntegration::new(); + let provider = AccountId::from([0x01; 32]); + // Needs to use caller to manipulate test state properly without accounts emulation + let caller = contract.admin; + + contract.register_service( + ServiceType::KycProvider, + String::from("Test KYC"), + caller, // Make caller the provider for test ease + String::from("https://api.testkyc.com"), + String::from("v1"), + 0, + ).unwrap(); + + let request_id = contract.initiate_kyc_request(1, caller, String::from("UID123")).unwrap(); + + let result = contract.update_kyc_status( + request_id, + RequestStatus::Approved, + 2, // level 2 + 365, // valid 1 year + ); + assert!(result.is_ok()); + + assert!(contract.is_kyc_verified(caller, 1)); + assert!(contract.is_kyc_verified(caller, 2)); + assert!(!contract.is_kyc_verified(caller, 3)); + } + + #[ink::test] + fn payment_flow_works() { + let mut contract = ThirdPartyIntegration::new(); + let caller = contract.admin; + + contract.register_service( + ServiceType::PaymentGateway, + String::from("PayGate"), + caller, + String::from("https://api.paygate.com"), + String::from("v1"), + 0, + ).unwrap(); + + let target = AccountId::from([0x02; 32]); + let req_id = contract.initiate_fiat_payment( + 1, + target, + 1, + 10000, + String::from("USD"), + String::from("REF123"), + ).unwrap(); + + let req1 = contract.get_payment_request(req_id).unwrap(); + assert_eq!(req1.status, RequestStatus::Pending); + + let result = contract.complete_payment(req_id, true, 50000); + assert!(result.is_ok()); + + let req2 = contract.get_payment_request(req_id).unwrap(); + assert_eq!(req2.status, RequestStatus::Approved); + assert_eq!(req2.equivalent_tokens, 50000); + } + } +}