From 9a581dc401835db2d6aca8dcfc80abd058a29301 Mon Sep 17 00:00:00 2001 From: Abdulmalik Ojo Date: Tue, 23 Jun 2026 14:58:41 +0000 Subject: [PATCH 1/3] #511 perf: Implement storage cleanup for expired and completed escrows --- contracts/escrow/src/lib.rs | 281 +++++++++++++++++++++++++++++++++- contracts/escrow/src/tests.rs | 235 ++++++++++++++++++++++++++++ contracts/escrow/src/types.rs | 27 ++++ 3 files changed, 542 insertions(+), 1 deletion(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 1e9bd29e..2e4b24b3 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -28,6 +28,8 @@ mod propchain_escrow { pub struct AdvancedEscrow { /// Escrow data mapping escrows: Mapping, + /// Compact escrow summaries retained after cleanup + escrow_summaries: Mapping, /// Escrow counter escrow_count: u64, /// Multi-signature configurations @@ -46,6 +48,8 @@ mod propchain_escrow { disputes: Mapping, /// Audit logs audit_logs: Mapping>, + /// Compressed audit logs retained after cleanup + compressed_audit_logs: Mapping>, /// Admin account admin: AccountId, /// High-value threshold for mandatory multi-sig @@ -79,6 +83,7 @@ mod propchain_escrow { pub struct EscrowCreated { #[ink(topic)] escrow_id: u64, + #[ink(topic)] property_id: u64, buyer: AccountId, seller: AccountId, @@ -118,6 +123,25 @@ mod propchain_escrow { recipient: AccountId, } + /// Emitted when escrow storage has been cleaned up after completion. + #[ink(event)] + pub struct EscrowCleanedUp { + #[ink(topic)] + escrow_id: u64, + #[ink(topic)] + property_id: u64, + #[ink(topic)] + status: EscrowStatus, + #[ink(topic)] + cleaned_by: AccountId, + event_version: u8, + completed_at: u64, + storage_saved_bytes: u64, + timestamp: u64, + block_number: u32, + transaction_hash: Hash, + } + #[ink(event)] pub struct DocumentUploaded { #[ink(topic)] @@ -277,6 +301,7 @@ mod propchain_escrow { pub fn new(min_high_value_threshold: u128, tax_compliance_contract: Option) -> Self { Self { escrows: Mapping::default(), + escrow_summaries: Mapping::default(), escrow_count: 0, multi_sig_configs: Mapping::default(), signatures: Mapping::default(), @@ -286,6 +311,7 @@ mod propchain_escrow { condition_counters: Mapping::default(), disputes: Mapping::default(), audit_logs: Mapping::default(), + compressed_audit_logs: Mapping::default(), admin: Self::env().caller(), min_high_value_threshold, signer_public_keys: Mapping::default(), @@ -340,6 +366,7 @@ mod propchain_escrow { deposited_amount: 0, status: EscrowStatus::Created, created_at: self.env().block_timestamp(), + completed_at: None, release_time_lock, participants: participants.clone(), jurisdiction, @@ -563,6 +590,7 @@ mod propchain_escrow { // Update status AFTER transfer let mut updated_escrow = escrow.clone(); updated_escrow.status = EscrowStatus::Released; + updated_escrow.completed_at = Some(self.env().block_timestamp()); self.escrows.insert(&escrow_id, &updated_escrow); // Track analytics @@ -637,6 +665,7 @@ mod propchain_escrow { // If fully released, mark as Released if escrow.total_released >= escrow.deposited_amount { escrow.status = EscrowStatus::Released; + escrow.completed_at = Some(self.env().block_timestamp()); } self.escrows.insert(&escrow_id, &escrow); @@ -716,6 +745,7 @@ mod propchain_escrow { // Update status AFTER transfer let mut updated_escrow = escrow.clone(); updated_escrow.status = EscrowStatus::Refunded; + updated_escrow.completed_at = Some(self.env().block_timestamp()); self.escrows.insert(&escrow_id, &updated_escrow); // Track analytics @@ -1125,6 +1155,7 @@ mod propchain_escrow { // Update escrow status back to Active let mut escrow = self.escrows.get(&escrow_id).ok_or(Error::EscrowNotFound)?; escrow.status = EscrowStatus::Active; + escrow.completed_at = None; self.escrows.insert(&escrow_id, &escrow); // Add audit entry @@ -1207,6 +1238,7 @@ mod propchain_escrow { } else { EscrowStatus::Refunded }; + updated_escrow.completed_at = Some(self.env().block_timestamp()); self.escrows.insert(&escrow_id, &updated_escrow); // Add audit entry @@ -1399,6 +1431,7 @@ mod propchain_escrow { }; let mut updated_escrow = escrow.clone(); updated_escrow.status = new_escrow_status; + updated_escrow.completed_at = Some(self.env().block_timestamp()); self.escrows.insert(&request.escrow_id, &updated_escrow); // Mark request as executed @@ -1538,6 +1571,12 @@ mod propchain_escrow { self.escrows.get(&escrow_id) } + /// Get the compact escrow summary for a cleaned-up escrow. + #[ink(message)] + pub fn get_escrow_summary(&self, escrow_id: u64) -> Option { + self.escrow_summaries.get(&escrow_id) + } + /// Get documents for escrow #[ink(message)] pub fn get_documents(&self, escrow_id: u64) -> Vec { @@ -1559,7 +1598,104 @@ mod propchain_escrow { /// Get audit trail #[ink(message)] pub fn get_audit_trail(&self, escrow_id: u64) -> Vec { - self.audit_logs.get(&escrow_id).unwrap_or_default() + if let Some(logs) = self.audit_logs.get(&escrow_id) { + logs + } else if let Some(compressed_logs) = self.compressed_audit_logs.get(&escrow_id) { + compressed_logs + .into_iter() + .map(Self::decompress_audit_entry) + .collect() + } else { + Vec::new() + } + } + + /// Cleanup completed escrow storage and retain a compact summary. + #[ink(message)] + pub fn cleanup_escrow(&mut self, escrow_id: u64) -> Result<(), Error> { + let caller = self.env().caller(); + let escrow = self.escrows.get(&escrow_id).ok_or(Error::EscrowNotFound)?; + + if !matches!(escrow.status, EscrowStatus::Released | EscrowStatus::Refunded) { + return Err(Error::InvalidStatus); + } + + if caller != self.admin && caller != escrow.buyer && caller != escrow.seller { + return Err(Error::Unauthorized); + } + + let completed_at = escrow.completed_at.ok_or(Error::InvalidStatus)?; + let documents = self.documents.get(&escrow_id).unwrap_or_default(); + let conditions = self.conditions.get(&escrow_id).unwrap_or_default(); + let audit_logs = self.audit_logs.get(&escrow_id).unwrap_or_default(); + let multi_sig_config = self.multi_sig_configs.get(&escrow_id); + + let summary = EscrowSummary { + id: escrow.id, + property_id: escrow.property_id, + buyer: escrow.buyer, + seller: escrow.seller, + amount: escrow.amount, + status: escrow.status.clone(), + completed_at, + }; + + let compressed_audit_logs = self.compress_audit_logs(&audit_logs); + let detailed_storage_bytes = self.estimate_detailed_storage_bytes( + &escrow, + &documents, + &conditions, + multi_sig_config.as_ref(), + escrow_id, + ); + let compressed_storage_bytes = self.estimate_compressed_storage_bytes( + &summary, + &compressed_audit_logs, + ); + let storage_saved_bytes = detailed_storage_bytes.saturating_sub(compressed_storage_bytes); + + self.escrow_summaries.insert(&escrow_id, &summary); + self.compressed_audit_logs + .insert(&escrow_id, &compressed_audit_logs); + + self.escrows.remove(&escrow_id); + self.documents.remove(&escrow_id); + self.conditions.remove(&escrow_id); + self.audit_logs.remove(&escrow_id); + self.disputes.remove(&escrow_id); + self.multi_sig_configs.remove(&escrow_id); + self.escrow_active_large_transfer.remove(&escrow_id); + self.condition_counters.remove(&escrow_id); + + for participant in escrow.participants.iter().copied() { + self.signatures + .remove(&(escrow_id, ApprovalType::Release, participant)); + self.signatures + .remove(&(escrow_id, ApprovalType::Refund, participant)); + self.signatures + .remove(&(escrow_id, ApprovalType::EmergencyOverride, participant)); + } + self.signature_counts + .remove(&(escrow_id, ApprovalType::Release)); + self.signature_counts + .remove(&(escrow_id, ApprovalType::Refund)); + self.signature_counts + .remove(&(escrow_id, ApprovalType::EmergencyOverride)); + + self.env().emit_event(EscrowCleanedUp { + escrow_id, + property_id: summary.property_id, + status: summary.status.clone(), + cleaned_by: caller, + event_version: 1, + completed_at: summary.completed_at, + storage_saved_bytes, + timestamp: self.env().block_timestamp(), + block_number: self.env().block_number(), + transaction_hash: [0u8; 32].into(), + }); + + Ok(()) } /// Get multi-sig configuration @@ -1916,6 +2052,149 @@ mod propchain_escrow { Ok(count >= config.required_signatures) } + fn compress_audit_logs(&self, audit_logs: &[AuditEntry]) -> Vec { + audit_logs + .iter() + .map(|entry| CompressedAuditEntry { + timestamp: entry.timestamp, + actor: entry.actor, + action_code: Self::action_code(&entry.action), + details_hash: Self::hash_string(&entry.details), + details_len: entry.details.len() as u32, + }) + .collect() + } + + fn decompress_audit_entry(entry: CompressedAuditEntry) -> AuditEntry { + AuditEntry { + timestamp: entry.timestamp, + actor: entry.actor, + action: Self::action_label(entry.action_code).to_string(), + details: format!("compressed:{}:{:?}", entry.details_len, entry.details_hash), + } + } + + fn action_code(action: &str) -> u8 { + match action { + "EscrowCreated" => 1, + "FundsDeposited" => 2, + "FundsReleased" => 3, + "FundsPartiallyReleased" => 4, + "FundsRefunded" => 5, + "DocumentUploaded" => 6, + "DocumentVerified" => 7, + "ConditionAdded" => 8, + "ConditionMet" => 9, + "SignatureAdded" => 10, + "DisputeRaised" => 11, + "DisputeResolved" => 12, + "EmergencyOverride" => 13, + "LargeTransferApproved" => 14, + "LargeTransferExecuted" => 15, + "LargeTransferCancelled" => 16, + "AdminRotationRequested" => 17, + "AdminRotationCompleted" => 18, + _ => 255, + } + } + + fn action_label(action_code: u8) -> &'static str { + match action_code { + 1 => "EscrowCreated", + 2 => "FundsDeposited", + 3 => "FundsReleased", + 4 => "FundsPartiallyReleased", + 5 => "FundsRefunded", + 6 => "DocumentUploaded", + 7 => "DocumentVerified", + 8 => "ConditionAdded", + 9 => "ConditionMet", + 10 => "SignatureAdded", + 11 => "DisputeRaised", + 12 => "DisputeResolved", + 13 => "EmergencyOverride", + 14 => "LargeTransferApproved", + 15 => "LargeTransferExecuted", + 16 => "LargeTransferCancelled", + 17 => "AdminRotationRequested", + 18 => "AdminRotationCompleted", + _ => "CompressedAuditEntry", + } + } + + fn hash_string(value: &str) -> Hash { + let mut output = [0u8; 32]; + let encoded = scale::Encode::encode(&value); + ink::env::hash_bytes::(&encoded, &mut output); + output.into() + } + + fn estimate_detailed_storage_bytes( + &self, + escrow: &EscrowData, + documents: &[DocumentHash], + conditions: &[Condition], + multi_sig_config: Option<&MultiSigConfig>, + escrow_id: u64, + ) -> u64 { + let mut total = scale::Encode::encode(escrow).len() as u64; + total = total.saturating_add(scale::Encode::encode(documents).len() as u64); + total = total.saturating_add(scale::Encode::encode(conditions).len() as u64); + + if let Some(config) = multi_sig_config { + total = total.saturating_add(scale::Encode::encode(config).len() as u64); + + let approval_types = [ + ApprovalType::Release, + ApprovalType::Refund, + ApprovalType::EmergencyOverride, + ]; + for approval_type in approval_types { + let count = self + .signature_counts + .get(&(escrow_id, approval_type.clone())) + .unwrap_or(0); + total = total.saturating_add( + scale::Encode::encode(&(escrow_id, approval_type.clone(), count)).len() + as u64, + ); + + for signer in &config.signers { + if self + .signatures + .get(&(escrow_id, approval_type.clone(), *signer)) + .unwrap_or(false) + { + total = total.saturating_add( + scale::Encode::encode(&( + escrow_id, + approval_type.clone(), + *signer, + true, + )) + .len() as u64, + ); + } + } + } + } + + total = total.saturating_add( + scale::Encode::encode(&self.audit_logs.get(&escrow_id).unwrap_or_default()) + .len() as u64, + ); + total + } + + fn estimate_compressed_storage_bytes( + &self, + summary: &EscrowSummary, + compressed_audit_logs: &[CompressedAuditEntry], + ) -> u64 { + scale::Encode::encode(summary).len() as u64 + + scale::Encode::encode(compressed_audit_logs).len() as u64 + } + /// Add audit entry fn add_audit_entry( &mut self, diff --git a/contracts/escrow/src/tests.rs b/contracts/escrow/src/tests.rs index 6b89d8ef..afa47e30 100644 --- a/contracts/escrow/src/tests.rs +++ b/contracts/escrow/src/tests.rs @@ -3,6 +3,7 @@ pub mod escrow_tests { use crate::propchain_escrow::*; use ink::env::test::DefaultAccounts; use ink::primitives::{AccountId, Hash}; + use scale::Encode; fn default_accounts() -> DefaultAccounts { ink::env::test::default_accounts::() @@ -16,6 +17,10 @@ pub mod escrow_tests { ink::env::test::set_account_balance::(account, balance); } + fn encoded_len(value: &T) -> usize { + value.encode().len() + } + #[ink::test] fn test_new_contract() { let contract = AdvancedEscrow::new(1_000_000, None); @@ -574,4 +579,234 @@ pub mod escrow_tests { assert_eq!(config.required_signatures, 2); assert_eq!(config.signers, participants); } + + #[ink::test] + fn test_cleanup_rejects_active_escrow() { + let accounts = default_accounts(); + set_caller(accounts.alice); + + let mut contract = AdvancedEscrow::new(1_000_000, None); + let participants = vec![accounts.alice, accounts.bob]; + + let escrow_id = contract + .create_escrow_advanced( + 1, + 1_000_000, + accounts.alice, + accounts.bob, + participants, + 2, + None, + ) + .expect("Escrow creation should succeed in test"); + + assert_eq!(contract.cleanup_escrow(escrow_id), Err(Error::InvalidStatus)); + } + + #[ink::test] + fn test_cleanup_preserves_summary_and_removes_detail() { + let accounts = default_accounts(); + set_caller(accounts.alice); + set_balance(accounts.alice, 2_000_000); + + let mut contract = AdvancedEscrow::new(1_000_000, None); + let participants = vec![accounts.alice, accounts.bob]; + + let escrow_id = contract + .create_escrow_advanced( + 7, + 1_000_000, + accounts.alice, + accounts.bob, + participants.clone(), + 2, + None, + ) + .expect("Escrow creation should succeed in test"); + + ink::env::test::set_value_transferred::(1_000_000); + contract + .deposit_funds(escrow_id) + .expect("Deposit should succeed in test"); + + let doc_hash = Hash::from([9u8; 32]); + contract + .upload_document(escrow_id, doc_hash, "Deed".to_string()) + .expect("Document upload should succeed in test"); + contract + .add_condition(escrow_id, "Inspection complete".to_string()) + .expect("Condition addition should succeed in test"); + contract + .sign_approval(escrow_id, ApprovalType::Release) + .expect("First approval should succeed in test"); + + set_caller(accounts.bob); + contract + .sign_approval(escrow_id, ApprovalType::Release) + .expect("Second approval should succeed in test"); + + set_caller(accounts.alice); + contract + .release_funds(escrow_id) + .expect("Release should succeed in test"); + + let before_bytes = { + let escrow = contract + .get_escrow(escrow_id) + .expect("Escrow should still be detailed before cleanup"); + let documents = contract.get_documents(escrow_id); + let conditions = contract.get_conditions(escrow_id); + let audit_trail = contract.get_audit_trail(escrow_id); + let config = contract + .get_multi_sig_config(escrow_id) + .expect("Config should exist before cleanup"); + let signature_types = [ + ApprovalType::Release, + ApprovalType::Refund, + ApprovalType::EmergencyOverride, + ]; + let mut signature_entries = Vec::new(); + let mut signature_counts = Vec::new(); + + for approval_type in signature_types { + let count = contract.get_signature_count(escrow_id, approval_type.clone()); + signature_counts.push((escrow_id, approval_type.clone(), count)); + for signer in config.signers.iter().copied() { + if contract.has_signed(escrow_id, approval_type.clone(), signer) { + signature_entries.push((escrow_id, approval_type.clone(), signer, true)); + } + } + } + + encoded_len(&escrow) as u64 + + encoded_len(&documents) as u64 + + encoded_len(&conditions) as u64 + + encoded_len(&audit_trail) as u64 + + encoded_len(&config) as u64 + + encoded_len(&signature_entries) as u64 + + encoded_len(&signature_counts) as u64 + }; + + set_caller(accounts.bob); + contract + .cleanup_escrow(escrow_id) + .expect("Cleanup should succeed after completion"); + + let summary = contract + .get_escrow_summary(escrow_id) + .expect("Summary should remain after cleanup"); + assert_eq!(summary.id, escrow_id); + assert_eq!(summary.property_id, 7); + assert_eq!(summary.buyer, accounts.alice); + assert_eq!(summary.seller, accounts.bob); + assert_eq!(summary.amount, 1_000_000); + assert_eq!(summary.status, EscrowStatus::Released); + assert!(summary.completed_at > 0); + + assert!(contract.get_escrow(escrow_id).is_none()); + assert!(contract.get_multi_sig_config(escrow_id).is_none()); + assert!(contract.get_dispute(escrow_id).is_none()); + assert!(contract.get_documents(escrow_id).is_empty()); + assert!(contract.get_conditions(escrow_id).is_empty()); + assert_eq!(contract.get_signature_count(escrow_id, ApprovalType::Release), 0); + assert_eq!(contract.get_signature_count(escrow_id, ApprovalType::Refund), 0); + + let after_bytes = encoded_len(&summary) as u64 + + encoded_len(&contract.get_audit_trail(escrow_id)) as u64; + assert!(before_bytes > after_bytes); + } + + #[ink::test] + fn test_storage_savings_are_positive_for_typical_lifecycle() { + let accounts = default_accounts(); + set_caller(accounts.alice); + set_balance(accounts.alice, 2_000_000); + + let mut contract = AdvancedEscrow::new(1_000_000, None); + let participants = vec![accounts.alice, accounts.bob]; + + let escrow_id = contract + .create_escrow_advanced( + 11, + 1_000_000, + accounts.alice, + accounts.bob, + participants, + 2, + None, + ) + .expect("Escrow creation should succeed in test"); + + ink::env::test::set_value_transferred::(1_000_000); + contract + .deposit_funds(escrow_id) + .expect("Deposit should succeed in test"); + + contract + .upload_document(escrow_id, Hash::from([3u8; 32]), "Title".to_string()) + .expect("Document upload should succeed in test"); + contract + .add_condition(escrow_id, "Condition".to_string()) + .expect("Condition should succeed in test"); + contract + .sign_approval(escrow_id, ApprovalType::Release) + .expect("First approval should succeed in test"); + set_caller(accounts.bob); + contract + .sign_approval(escrow_id, ApprovalType::Release) + .expect("Second approval should succeed in test"); + set_caller(accounts.alice); + contract + .release_funds(escrow_id) + .expect("Release should succeed in test"); + + let before_bytes = { + let escrow = contract + .get_escrow(escrow_id) + .expect("Detailed escrow should exist before cleanup"); + let documents = contract.get_documents(escrow_id); + let conditions = contract.get_conditions(escrow_id); + let audit_trail = contract.get_audit_trail(escrow_id); + let config = contract + .get_multi_sig_config(escrow_id) + .expect("Config should exist before cleanup"); + let signature_types = [ + ApprovalType::Release, + ApprovalType::Refund, + ApprovalType::EmergencyOverride, + ]; + let mut signature_entries = Vec::new(); + let mut signature_counts = Vec::new(); + + for approval_type in signature_types { + let count = contract.get_signature_count(escrow_id, approval_type.clone()); + signature_counts.push((escrow_id, approval_type.clone(), count)); + for signer in config.signers.iter().copied() { + if contract.has_signed(escrow_id, approval_type.clone(), signer) { + signature_entries.push((escrow_id, approval_type.clone(), signer, true)); + } + } + } + + encoded_len(&escrow) as u64 + + encoded_len(&documents) as u64 + + encoded_len(&conditions) as u64 + + encoded_len(&audit_trail) as u64 + + encoded_len(&config) as u64 + + encoded_len(&signature_entries) as u64 + + encoded_len(&signature_counts) as u64 + }; + + contract + .cleanup_escrow(escrow_id) + .expect("Cleanup should succeed after completion"); + + let after_bytes = encoded_len( + &contract + .get_escrow_summary(escrow_id) + .expect("Summary should be retained after cleanup"), + ) as u64 + encoded_len(&contract.get_audit_trail(escrow_id)) as u64; + + assert!(before_bytes > after_bytes); + } } diff --git a/contracts/escrow/src/types.rs b/contracts/escrow/src/types.rs index 469d2f8d..1b028410 100644 --- a/contracts/escrow/src/types.rs +++ b/contracts/escrow/src/types.rs @@ -35,6 +35,7 @@ pub struct EscrowData { pub deposited_amount: u128, pub status: EscrowStatus, pub created_at: u64, + pub completed_at: Option, pub release_time_lock: Option, pub participants: Vec, pub jurisdiction: Jurisdiction, @@ -42,6 +43,32 @@ pub struct EscrowData { pub total_released: u128, } +/// Compact escrow summary retained after cleanup. +#[derive(Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +#[derive(ink::storage::traits::StorageLayout)] +pub struct EscrowSummary { + pub id: u64, + pub property_id: u64, + pub buyer: AccountId, + pub seller: AccountId, + pub amount: u128, + pub status: EscrowStatus, + pub completed_at: u64, +} + +/// Compressed audit entry retained after cleanup. +#[derive(Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +#[derive(ink::storage::traits::StorageLayout)] +pub struct CompressedAuditEntry { + pub timestamp: u64, + pub actor: AccountId, + pub action_code: u8, + pub details_hash: Hash, + pub details_len: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode)] #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] #[derive(ink::storage::traits::StorageLayout)] From 194839b6ad8a66724317b7eeb2551ab111bc9463 Mon Sep 17 00:00:00 2001 From: Abdulmalik Ojo Date: Tue, 23 Jun 2026 18:41:43 +0000 Subject: [PATCH 2/3] fix(escrow): allow participant cleanup --- contracts/escrow/src/lib.rs | 6 ++++- contracts/escrow/src/tests.rs | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/contracts/escrow/src/lib.rs b/contracts/escrow/src/lib.rs index 2e4b24b3..da055fc8 100644 --- a/contracts/escrow/src/lib.rs +++ b/contracts/escrow/src/lib.rs @@ -1620,7 +1620,11 @@ mod propchain_escrow { return Err(Error::InvalidStatus); } - if caller != self.admin && caller != escrow.buyer && caller != escrow.seller { + if caller != self.admin + && caller != escrow.buyer + && caller != escrow.seller + && !escrow.participants.contains(&caller) + { return Err(Error::Unauthorized); } diff --git a/contracts/escrow/src/tests.rs b/contracts/escrow/src/tests.rs index afa47e30..4eddab51 100644 --- a/contracts/escrow/src/tests.rs +++ b/contracts/escrow/src/tests.rs @@ -716,6 +716,53 @@ pub mod escrow_tests { assert!(before_bytes > after_bytes); } + #[ink::test] + fn test_cleanup_allows_non_buyer_seller_participant() { + let accounts = default_accounts(); + set_caller(accounts.alice); + set_balance(accounts.alice, 2_000_000); + + let mut contract = AdvancedEscrow::new(1_000_000, None); + let participants = vec![accounts.alice, accounts.bob, accounts.charlie]; + + let escrow_id = contract + .create_escrow_advanced( + 13, + 1_000_000, + accounts.alice, + accounts.bob, + participants, + 2, + None, + ) + .expect("Escrow creation should succeed in test"); + + ink::env::test::set_value_transferred::(1_000_000); + contract + .deposit_funds(escrow_id) + .expect("Deposit should succeed in test"); + + contract + .sign_approval(escrow_id, ApprovalType::Release) + .expect("First approval should succeed in test"); + set_caller(accounts.bob); + contract + .sign_approval(escrow_id, ApprovalType::Release) + .expect("Second approval should succeed in test"); + set_caller(accounts.alice); + contract + .release_funds(escrow_id) + .expect("Release should succeed in test"); + + set_caller(accounts.charlie); + assert!(contract.cleanup_escrow(escrow_id).is_ok()); + + let summary = contract + .get_escrow_summary(escrow_id) + .expect("Summary should remain after cleanup"); + assert_eq!(summary.status, EscrowStatus::Released); + } + #[ink::test] fn test_storage_savings_are_positive_for_typical_lifecycle() { let accounts = default_accounts(); From d6b81d4842d1003db405c288727bd296e4b1460a Mon Sep 17 00:00:00 2001 From: Abdulmalik Ojo Date: Tue, 23 Jun 2026 18:47:53 +0000 Subject: [PATCH 3/3] feat(fractional): add auction reentrancy safeguards --- contracts/fractional/src/lib.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/contracts/fractional/src/lib.rs b/contracts/fractional/src/lib.rs index 1251d0e4..138492ac 100644 --- a/contracts/fractional/src/lib.rs +++ b/contracts/fractional/src/lib.rs @@ -5,6 +5,7 @@ mod fractional { use ink::prelude::vec::Vec; use ink::storage::Mapping; use propchain_traits; + use propchain_traits::{non_reentrant, ReentrancyError, ReentrancyGuard}; #[derive( Debug, @@ -132,8 +133,11 @@ mod fractional { pub enum FractionalError { InsufficientShares, ListingNotFound, + AuctionNotFound, + AuctionAlreadyBid, InsufficientPayment, Unauthorized, + ReentrantCall, ZeroAmount, PoolNotFound, PoolAlreadyExists, @@ -254,6 +258,7 @@ mod fractional { auction_id: u64, #[ink(topic)] seller: AccountId, + } // ── Admin Key Rotation Events (Issue #496) ──────────────────────────────── @@ -279,7 +284,6 @@ mod fractional { #[ink(topic)] old_admin: AccountId, cancelled_by: AccountId, - } #[ink(storage)] @@ -295,12 +299,24 @@ mod fractional { amm_pools: Mapping, /// LP token balances per (provider, token_id) lp_balances: Mapping<(AccountId, u64), u128>, + /// Active Dutch auctions indexed by auction id + dutch_auctions: Mapping, + /// Monotonic counter for Dutch auction ids + auction_counter: u64, + /// Reentrancy protection for state-changing entry points + reentrancy_guard: ReentrancyGuard, /// Contract administrator (Issue #496) admin: AccountId, /// Pending admin key rotation request (Issue #496) pending_admin_rotation: Option, } + impl From for FractionalError { + fn from(_: ReentrancyError) -> Self { + FractionalError::ReentrantCall + } + } + impl Fractional { #[ink(constructor)] pub fn new() -> Self { @@ -313,6 +329,7 @@ mod fractional { lp_balances: Mapping::default(), dutch_auctions: Mapping::default(), auction_counter: 0, + reentrancy_guard: ReentrancyGuard::new(), admin: Self::env().caller(), pending_admin_rotation: None, } @@ -329,7 +346,6 @@ mod fractional { #[ink(message)] pub fn set_last_price(&mut self, token_id: u64, price_per_share: u128) { self.last_prices.insert(token_id, &price_per_share); - } self.last_prices.insert(token_id, &price_per_share); } #[ink(message)] @@ -1729,6 +1745,7 @@ mod fractional { let a2 = f.get_dutch_auction(1).unwrap(); assert_eq!(a1.shares, 50); assert_eq!(a2.shares, 100); + } // ── Issue #493: Reentrancy guard tests ─────────────────────────────── @@ -1863,7 +1880,6 @@ mod fractional { } } -} // ========================================================================= // ADMIN KEY ROTATION TESTS (Issue #496) — Fractional @@ -1964,3 +1980,5 @@ mod fractional { ); } } + +}