From adce80f3111ff94fc348df41d55b533fceded3ba Mon Sep 17 00:00:00 2001 From: Iora <4404227+xotatera@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:17:24 +0300 Subject: [PATCH 1/6] feat: add related_contracts() WASM export and full RelatedContracts API Add WASM FFI export for related_contracts() so the runtime can query a contract's declared dependencies before validation. Also includes RelatedContract with timeout/ttl fields, RelatedContractsContainer with cycle detection and depth limiting, and ContractInterface default method for declaring dependencies upfront. Co-Authored-By: Claude Opus 4.6 --- rust-macros/src/contract_impl.rs | 14 ++ rust/src/contract_composition.rs | 9 +- rust/src/contract_interface/encoding.rs | 155 +++++++++++++++++- rust/src/contract_interface/trait_def.rs | 8 +- rust/src/contract_interface/update.rs | 127 +++++++++++++- rust/src/contract_interface/wasm_interface.rs | 31 ++++ rust/src/memory.rs | 9 + 7 files changed, 346 insertions(+), 7 deletions(-) diff --git a/rust-macros/src/contract_impl.rs b/rust-macros/src/contract_impl.rs index 6e81fa8..6e9c850 100644 --- a/rust-macros/src/contract_impl.rs +++ b/rust-macros/src/contract_impl.rs @@ -489,11 +489,13 @@ impl ImplTrait { let update_fn = self.gen_update_state_fn(); let summarize_fn = self.gen_summarize_state_fn(); let get_delta_fn = self.gen_get_state_delta(); + let related_contracts_fn = self.gen_related_contracts_fn(); quote! { #validate_state_fn #update_fn #summarize_fn #get_delta_fn + #related_contracts_fn } } @@ -544,4 +546,16 @@ impl ImplTrait { } } } + + fn gen_related_contracts_fn(&self) -> TokenStream { + let type_name = &self.type_name; + let ret = self.ffi_ret_type(); + quote! { + #[no_mangle] + #[cfg(feature = "freenet-main-contract")] + pub extern "C" fn related_contracts() -> #ret { + ::freenet_stdlib::memory::wasm_interface::inner_related_contracts::<#type_name>() + } + } + } } diff --git a/rust/src/contract_composition.rs b/rust/src/contract_composition.rs index d2e4fc2..182480f 100644 --- a/rust/src/contract_composition.rs +++ b/rust/src/contract_composition.rs @@ -1,6 +1,7 @@ use crate::{ contract_interface::{ - ContractError, ContractInstanceId, RelatedContracts, State, UpdateData, ValidateResult, + ContractError, ContractInstanceId, RelatedContract, RelatedContracts, State, UpdateData, + ValidateResult, }, typed_contract::{MergeResult, RelatedContractsContainer}, }; @@ -59,6 +60,12 @@ pub trait ContractComponent: std::any::Any + Sized { parameters: &Self::Parameters, summary: &Self::Summary, ) -> Result; + + /// Declare all related contract dependencies upfront so the runtime can + /// pre-fetch them before calling `verify`. + fn related_contracts() -> Vec { + vec![] + } } pub enum TypedUpdateData { diff --git a/rust/src/contract_interface/encoding.rs b/rust/src/contract_interface/encoding.rs index 401f354..be222b7 100644 --- a/rust/src/contract_interface/encoding.rs +++ b/rust/src/contract_interface/encoding.rs @@ -20,6 +20,15 @@ pub struct RelatedContractsContainer { contracts: HashMap>, pending: HashSet, not_found: HashSet, + /// Tracks how many rounds of RequestRelated have occurred. + request_depth: u32, + /// All contract IDs ever requested, for cycle detection. + seen: HashSet, +} + +impl RelatedContractsContainer { + /// Maximum number of RequestRelated rounds before erroring. + pub const MAX_REQUEST_DEPTH: u32 = 10; } impl From> for RelatedContractsContainer { @@ -40,6 +49,8 @@ impl From> for RelatedContractsContainer { contracts, pending: HashSet::new(), not_found, + request_depth: 0, + seen: HashSet::new(), } } } @@ -49,10 +60,7 @@ impl From for Vec bool { + !self.pending.is_empty() + } + + /// Iterate over pending contract IDs. + pub fn pending_ids(&self) -> impl Iterator { + self.pending.iter() + } + + /// Iterate over not-found contract IDs. + pub fn not_found_ids(&self) -> impl Iterator { + self.not_found.iter() + } + + /// Number of resolved contracts. + pub fn resolved_count(&self) -> usize { + self.contracts.len() + } + + /// Number of pending contracts. + pub fn pending_count(&self) -> usize { + self.pending.len() + } + + /// Number of not-found contracts. + pub fn not_found_count(&self) -> usize { + self.not_found.len() + } + + /// Current request depth (number of RequestRelated rounds). + pub fn request_depth(&self) -> u32 { + self.request_depth + } + + /// Request a related contract with cycle detection and depth limiting. + /// Returns an error if the contract was already requested (cycle) or + /// the maximum request depth has been exceeded. + pub fn request_with_tracking( + &mut self, + id: ContractInstanceId, + ) -> Result<(), ContractError> { + if self.request_depth >= Self::MAX_REQUEST_DEPTH { + return Err(ContractError::InvalidState); + } + if !self.seen.insert(id) { + return Err(ContractError::InvalidUpdateWithInfo { + reason: format!("cycle detected: contract {id} was already requested"), + }); + } + self.pending.insert(id); + Ok(()) + } + + /// Increment the request depth. Returns an error if the max depth is exceeded. + pub fn increment_depth(&mut self) -> Result<(), ContractError> { + self.request_depth += 1; + if self.request_depth > Self::MAX_REQUEST_DEPTH { + return Err(ContractError::InvalidUpdateWithInfo { + reason: format!( + "max request depth ({}) exceeded", + Self::MAX_REQUEST_DEPTH + ), + }); + } + Ok(()) } } @@ -377,3 +456,71 @@ where let encoded = <::DeltaEncoder>::serialize(&summary)?; Ok(encoded.into()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::contract_interface::CONTRACT_KEY_SIZE; + + fn make_id(byte: u8) -> ContractInstanceId { + let bytes = [byte; CONTRACT_KEY_SIZE]; + let encoded = bs58::encode(bytes) + .with_alphabet(bs58::Alphabet::BITCOIN) + .into_string(); + ContractInstanceId::from_bytes(encoded.as_bytes()).unwrap() + } + + #[test] + fn container_query_methods() { + let mut c = RelatedContractsContainer::default(); + assert!(!c.has_pending()); + assert_eq!(c.resolved_count(), 0); + assert_eq!(c.pending_count(), 0); + assert_eq!(c.not_found_count(), 0); + + c.pending.insert(make_id(1)); + c.pending.insert(make_id(2)); + c.not_found.insert(make_id(3)); + c.contracts + .insert(make_id(4), State::from(vec![1, 2, 3])); + + assert!(c.has_pending()); + assert_eq!(c.pending_count(), 2); + assert_eq!(c.not_found_count(), 1); + assert_eq!(c.resolved_count(), 1); + } + + #[test] + fn cycle_detection() { + let mut c = RelatedContractsContainer::default(); + let id = make_id(1); + + // First request should succeed + assert!(c.request_with_tracking(id).is_ok()); + + // Same ID again should fail (cycle) + let err = c.request_with_tracking(id); + assert!(err.is_err()); + } + + #[test] + fn depth_limiting() { + let mut c = RelatedContractsContainer::default(); + + for _ in 0..RelatedContractsContainer::MAX_REQUEST_DEPTH { + assert!(c.increment_depth().is_ok()); + } + + // One more should fail + assert!(c.increment_depth().is_err()); + } + + #[test] + fn request_with_tracking_respects_depth() { + let mut c = RelatedContractsContainer::default(); + c.request_depth = RelatedContractsContainer::MAX_REQUEST_DEPTH; + + let err = c.request_with_tracking(make_id(1)); + assert!(err.is_err()); + } +} diff --git a/rust/src/contract_interface/trait_def.rs b/rust/src/contract_interface/trait_def.rs index 8970773..605ad18 100644 --- a/rust/src/contract_interface/trait_def.rs +++ b/rust/src/contract_interface/trait_def.rs @@ -5,7 +5,7 @@ use crate::parameters::Parameters; use super::{ - ContractError, RelatedContracts, State, StateDelta, StateSummary, UpdateData, + ContractError, RelatedContract, RelatedContracts, State, StateDelta, StateSummary, UpdateData, UpdateModification, ValidateResult, }; @@ -96,5 +96,11 @@ pub trait ContractInterface { state: State<'static>, summary: StateSummary<'static>, ) -> Result, ContractError>; + + /// Declare all related contract dependencies upfront so the runtime can + /// pre-fetch them before calling `validate_state`. + fn related_contracts() -> Vec { + vec![] + } } // ANCHOR_END: contractifce diff --git a/rust/src/contract_interface/update.rs b/rust/src/contract_interface/update.rs index 0b094c5..4c1720c 100644 --- a/rust/src/contract_interface/update.rs +++ b/rust/src/contract_interface/update.rs @@ -4,6 +4,7 @@ //! and validation results. use std::collections::HashMap; +use std::time::Duration; use serde::{Deserialize, Serialize}; @@ -130,6 +131,46 @@ impl<'a> RelatedContracts<'a> { self.map.entry(key).or_default(); } } + + /// Returns the number of entries (both resolved and pending). + pub fn len(&self) -> usize { + self.map.len() + } + + /// Returns true if there are no entries. + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } + + /// Check if a contract is tracked. + pub fn contains(&self, id: &ContractInstanceId) -> bool { + self.map.contains_key(id) + } + + /// Get a specific contract's state (Some(state) if resolved, None if pending). + pub fn get(&self, id: &ContractInstanceId) -> Option<&Option>> { + self.map.get(id) + } + + /// Add or update an entry. + pub fn insert(&mut self, id: ContractInstanceId, state: Option>) { + self.map.insert(id, state); + } + + /// Number of entries with a resolved state. + pub fn resolved_count(&self) -> usize { + self.map.values().filter(|v| v.is_some()).count() + } + + /// Number of entries still pending (state is None). + pub fn pending_count(&self) -> usize { + self.map.values().filter(|v| v.is_none()).count() + } + + /// True if all entries have resolved states (no None values). + pub fn all_resolved(&self) -> bool { + !self.map.is_empty() && self.map.values().all(|v| v.is_some()) + } } impl<'a> TryFromFbs<&FbsRelatedContracts<'a>> for RelatedContracts<'a> { @@ -158,7 +199,36 @@ impl<'a> From>>> for RelatedContrac pub struct RelatedContract { pub contract_instance_id: ContractInstanceId, pub mode: RelatedMode, - // todo: add a timeout so we stop listening/subscribing eventually + /// Max time to wait for the related contract's state to be fetched. + #[serde(default)] + pub timeout: Option, + /// How long a cached state for this related contract remains valid. + #[serde(default)] + pub ttl: Option, +} + +impl RelatedContract { + /// Create a new related contract with the given id and mode. + pub fn new(id: ContractInstanceId, mode: RelatedMode) -> Self { + Self { + contract_instance_id: id, + mode, + timeout: None, + ttl: None, + } + } + + /// Set the maximum time to wait for fetching this contract's state. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = Some(timeout); + self + } + + /// Set how long a cached state is considered valid. + pub fn with_ttl(mut self, ttl: Duration) -> Self { + self.ttl = Some(ttl); + self + } } /// Specification of the notifications of interest from a related contract. @@ -343,3 +413,58 @@ impl<'a> TryFromFbs<&FbsUpdateData<'a>> for UpdateData<'a> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_id(byte: u8) -> ContractInstanceId { + let bytes = [byte; CONTRACT_KEY_SIZE]; + let encoded = bs58::encode(bytes).with_alphabet(bs58::Alphabet::BITCOIN).into_string(); + ContractInstanceId::from_bytes(encoded.as_bytes()).unwrap() + } + + #[test] + fn related_contracts_query_methods() { + let mut rc = RelatedContracts::new(); + assert!(rc.is_empty()); + assert_eq!(rc.len(), 0); + + let id1 = make_id(1); + let id2 = make_id(2); + rc.insert(id1, Some(State::from(vec![1, 2, 3]))); + rc.insert(id2, None); + + assert_eq!(rc.len(), 2); + assert!(!rc.is_empty()); + assert!(rc.contains(&id1)); + assert!(!rc.contains(&make_id(99))); + assert_eq!(rc.resolved_count(), 1); + assert_eq!(rc.pending_count(), 1); + assert!(!rc.all_resolved()); + + // resolve the pending one + rc.insert(id2, Some(State::from(vec![4, 5]))); + assert!(rc.all_resolved()); + } + + #[test] + fn related_contract_builder() { + let id = make_id(1); + let rc = RelatedContract::new(id, RelatedMode::StateThenSubscribe) + .with_timeout(Duration::from_secs(30)) + .with_ttl(Duration::from_secs(300)); + + assert_eq!(rc.timeout, Some(Duration::from_secs(30))); + assert_eq!(rc.ttl, Some(Duration::from_secs(300))); + assert!(matches!(rc.mode, RelatedMode::StateThenSubscribe)); + } + + #[test] + fn related_contract_defaults_no_timeout_ttl() { + let id = make_id(1); + let rc = RelatedContract::new(id, RelatedMode::StateOnce); + assert!(rc.timeout.is_none()); + assert!(rc.ttl.is_none()); + } +} diff --git a/rust/src/contract_interface/wasm_interface.rs b/rust/src/contract_interface/wasm_interface.rs index 043a740..47443ae 100644 --- a/rust/src/contract_interface/wasm_interface.rs +++ b/rust/src/contract_interface/wasm_interface.rs @@ -10,6 +10,7 @@ enum ResultKind { UpdateState = 2, SummarizeState = 3, StateDelta = 4, + RelatedContracts = 5, } impl From for ResultKind { @@ -20,6 +21,7 @@ impl From for ResultKind { 2 => ResultKind::UpdateState, 3 => ResultKind::SummarizeState, 4 => ResultKind::StateDelta, + 5 => ResultKind::RelatedContracts, _ => panic!(), } } @@ -149,6 +151,33 @@ impl ContractInterfaceResult { } } + /// Deserialize a related contracts result from WASM memory. + /// + /// # Safety + /// + /// The caller must ensure that `mem` is a valid WASM linear memory containing + /// the serialized result at the offset specified by `self.ptr`, with at least + /// `self.size` bytes available. + pub unsafe fn unwrap_related_contracts( + self, + mem: WasmLinearMem, + ) -> Result, ContractError> { + let kind = ResultKind::from(self.kind); + match kind { + ResultKind::RelatedContracts => { + let ptr = crate::memory::buf::compute_ptr(self.ptr as *mut u8, &mem); + let serialized = std::slice::from_raw_parts(ptr as *const u8, self.size as _); + let value: Result, ContractError> = + bincode::deserialize(serialized) + .map_err(|e| ContractError::Other(format!("{e}")))?; + #[cfg(feature = "trace")] + self.log_input(serialized, &value, ptr); + value + } + _ => unreachable!(), + } + } + #[cfg(feature = "contract")] pub fn into_raw(self) -> i64 { #[cfg(feature = "trace")] @@ -233,3 +262,5 @@ conversion!(Result, ContractError>: ResultKind::Upda conversion!(Result, ContractError>: ResultKind::SummarizeState); #[cfg(feature = "contract")] conversion!(Result, ContractError>: ResultKind::StateDelta); +#[cfg(feature = "contract")] +conversion!(Result, ContractError>: ResultKind::RelatedContracts); diff --git a/rust/src/memory.rs b/rust/src/memory.rs index a9aac7a..0bea6d0 100644 --- a/rust/src/memory.rs +++ b/rust/src/memory.rs @@ -168,4 +168,13 @@ pub mod wasm_interface { let new_delta = ::get_state_delta(parameters, state, summary); ContractInterfaceResult::from(new_delta).into_raw() } + + pub fn inner_related_contracts() -> i64 { + if let Err(e) = set_logger().map_err(|e| e.into_raw()) { + return e; + } + let result: Result, crate::prelude::ContractError> = + Ok(::related_contracts()); + ContractInterfaceResult::from(result).into_raw() + } } From da9372c1de75f92895a58afcae87f4fcf079e758 Mon Sep 17 00:00:00 2001 From: Iora <4404227+xotatera@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:17:24 +0300 Subject: [PATCH 2/6] feat: add set_initial_depth and derive Copy on RelatedMode set_initial_depth allows sharing depth budget across nested dependency resolution. RelatedMode derives Copy+Clone for ergonomic use in mocks. Co-Authored-By: Claude Opus 4.6 --- rust/src/contract_interface/encoding.rs | 7 +++++++ rust/src/contract_interface/update.rs | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/rust/src/contract_interface/encoding.rs b/rust/src/contract_interface/encoding.rs index be222b7..2a289a9 100644 --- a/rust/src/contract_interface/encoding.rs +++ b/rust/src/contract_interface/encoding.rs @@ -159,6 +159,13 @@ impl RelatedContractsContainer { self.request_depth } + /// Set the initial request depth. Use this when a container is created + /// inside an already-nested dependency resolution to share the global + /// depth budget and prevent unbounded recursion. + pub fn set_initial_depth(&mut self, depth: u32) { + self.request_depth = depth; + } + /// Request a related contract with cycle detection and depth limiting. /// Returns an error if the contract was already requested (cycle) or /// the maximum request depth has been exceeded. diff --git a/rust/src/contract_interface/update.rs b/rust/src/contract_interface/update.rs index 4c1720c..17db72a 100644 --- a/rust/src/contract_interface/update.rs +++ b/rust/src/contract_interface/update.rs @@ -232,7 +232,7 @@ impl RelatedContract { } /// Specification of the notifications of interest from a related contract. -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub enum RelatedMode { /// Retrieve the state once, don't be concerned with subsequent changes. StateOnce, From e3d1c63137c7cb2d050279c55d2f8d178152b512 Mon Sep 17 00:00:00 2001 From: Iora <4404227+xotatera@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:17:24 +0300 Subject: [PATCH 3/6] fix: harmonize depth thresholds, preserve depth on merge, document reserved fields - increment_depth now uses >= (consistent with request_with_tracking) - merge() preserves max(self.depth, other.depth) instead of discarding - timeout/ttl on RelatedContract documented as reserved for future use Co-Authored-By: Claude Opus 4.6 --- rust/src/contract_interface/encoding.rs | 12 ++++++++---- rust/src/contract_interface/update.rs | 4 ++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/rust/src/contract_interface/encoding.rs b/rust/src/contract_interface/encoding.rs index 2a289a9..b0164b1 100644 --- a/rust/src/contract_interface/encoding.rs +++ b/rust/src/contract_interface/encoding.rs @@ -116,12 +116,15 @@ impl RelatedContractsContainer { pending, not_found, seen, - request_depth: _, + request_depth, } = other; self.pending.extend(pending); self.not_found.extend(not_found); self.contracts.extend(contracts); self.seen.extend(seen); + // Preserve the higher depth so the merged container respects the + // tighter budget from whichever resolution path is deeper. + self.request_depth = self.request_depth.max(request_depth); } /// Returns true if there are any pending requests. @@ -188,7 +191,7 @@ impl RelatedContractsContainer { /// Increment the request depth. Returns an error if the max depth is exceeded. pub fn increment_depth(&mut self) -> Result<(), ContractError> { self.request_depth += 1; - if self.request_depth > Self::MAX_REQUEST_DEPTH { + if self.request_depth >= Self::MAX_REQUEST_DEPTH { return Err(ContractError::InvalidUpdateWithInfo { reason: format!( "max request depth ({}) exceeded", @@ -514,11 +517,12 @@ mod tests { fn depth_limiting() { let mut c = RelatedContractsContainer::default(); - for _ in 0..RelatedContractsContainer::MAX_REQUEST_DEPTH { + // Can increment up to MAX_REQUEST_DEPTH - 1 (depth goes from 1 to MAX-1) + for _ in 0..RelatedContractsContainer::MAX_REQUEST_DEPTH - 1 { assert!(c.increment_depth().is_ok()); } - // One more should fail + // Next increment reaches MAX_REQUEST_DEPTH, which fails assert!(c.increment_depth().is_err()); } diff --git a/rust/src/contract_interface/update.rs b/rust/src/contract_interface/update.rs index 17db72a..dbebc68 100644 --- a/rust/src/contract_interface/update.rs +++ b/rust/src/contract_interface/update.rs @@ -200,9 +200,13 @@ pub struct RelatedContract { pub contract_instance_id: ContractInstanceId, pub mode: RelatedMode, /// Max time to wait for the related contract's state to be fetched. + /// + /// **Reserved for future use** — not yet enforced by the runtime. #[serde(default)] pub timeout: Option, /// How long a cached state for this related contract remains valid. + /// + /// **Reserved for future use** — not yet enforced by the runtime. #[serde(default)] pub ttl: Option, } From 970e9eb970632f0ef4ce374a03518bc60fc6a72a Mon Sep 17 00:00:00 2001 From: Iora <4404227+xotatera@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:17:24 +0300 Subject: [PATCH 4/6] docs: document effective depth budget on MAX_REQUEST_DEPTH Co-Authored-By: Claude Opus 4.6 --- rust/src/contract_interface/encoding.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rust/src/contract_interface/encoding.rs b/rust/src/contract_interface/encoding.rs index b0164b1..61b0271 100644 --- a/rust/src/contract_interface/encoding.rs +++ b/rust/src/contract_interface/encoding.rs @@ -28,6 +28,8 @@ pub struct RelatedContractsContainer { impl RelatedContractsContainer { /// Maximum number of RequestRelated rounds before erroring. + /// Since `increment_depth` runs before each validation attempt, the effective + /// retry budget is `MAX_REQUEST_DEPTH - 1 - initial_depth` rounds. pub const MAX_REQUEST_DEPTH: u32 = 10; } From dcca98fb898b2e04df70bb25eb14291ea17f4499 Mon Sep 17 00:00:00 2001 From: Iora <4404227+xotatera@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:17:24 +0300 Subject: [PATCH 5/6] test: add proptest fuzzing for RelatedContracts serialization and container logic Property-based tests found and fixed a bug where increment_depth() could exceed MAX_REQUEST_DEPTH when set_initial_depth was called first. Also clamps set_initial_depth to MAX and derives Clone on ValidateResult. Co-Authored-By: Claude Opus 4.6 --- rust/Cargo.toml | 1 + rust/src/contract_interface/encoding.rs | 152 +++++++++++++++++++++++- rust/src/contract_interface/update.rs | 147 ++++++++++++++++++++++- 3 files changed, 295 insertions(+), 5 deletions(-) diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 29d2179..9833518 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -60,6 +60,7 @@ bincode = "1" wasmer = { version = "5.0.4", features = [ "sys-default"] } rand = { version = "0.9", features = ["small_rng"] } arbitrary = { version = "1", features = ["derive"] } +proptest = "1" [features] default = [] diff --git a/rust/src/contract_interface/encoding.rs b/rust/src/contract_interface/encoding.rs index 61b0271..3766887 100644 --- a/rust/src/contract_interface/encoding.rs +++ b/rust/src/contract_interface/encoding.rs @@ -168,7 +168,7 @@ impl RelatedContractsContainer { /// inside an already-nested dependency resolution to share the global /// depth budget and prevent unbounded recursion. pub fn set_initial_depth(&mut self, depth: u32) { - self.request_depth = depth; + self.request_depth = depth.min(Self::MAX_REQUEST_DEPTH); } /// Request a related contract with cycle detection and depth limiting. @@ -190,10 +190,10 @@ impl RelatedContractsContainer { Ok(()) } - /// Increment the request depth. Returns an error if the max depth is exceeded. + /// Increment the request depth. Returns an error if the max depth would be + /// reached, leaving the depth unchanged. pub fn increment_depth(&mut self) -> Result<(), ContractError> { - self.request_depth += 1; - if self.request_depth >= Self::MAX_REQUEST_DEPTH { + if self.request_depth + 1 >= Self::MAX_REQUEST_DEPTH { return Err(ContractError::InvalidUpdateWithInfo { reason: format!( "max request depth ({}) exceeded", @@ -201,6 +201,7 @@ impl RelatedContractsContainer { ), }); } + self.request_depth += 1; Ok(()) } } @@ -536,4 +537,147 @@ mod tests { let err = c.request_with_tracking(make_id(1)); assert!(err.is_err()); } + + mod proptest_container { + use super::*; + use proptest::prelude::*; + + fn arb_id() -> impl Strategy { + prop::array::uniform32(any::()).prop_map(|bytes| { + let encoded = bs58::encode(bytes) + .with_alphabet(bs58::Alphabet::BITCOIN) + .into_string(); + ContractInstanceId::from_bytes(encoded.as_bytes()).unwrap() + }) + } + + fn arb_state() -> impl Strategy> { + prop::collection::vec(any::(), 0..128).prop_map(State::from) + } + + #[derive(Debug, Clone)] + enum Op { + IncrementDepth, + SetInitialDepth(u32), + RequestWithTracking(ContractInstanceId), + } + + fn arb_op() -> impl Strategy { + prop_oneof![ + Just(Op::IncrementDepth), + (0u32..15).prop_map(Op::SetInitialDepth), + arb_id().prop_map(Op::RequestWithTracking), + ] + } + + proptest! { + #[test] + fn depth_never_exceeds_max(ops in prop::collection::vec(arb_op(), 0..50)) { + let mut c = RelatedContractsContainer::default(); + for op in ops { + match op { + Op::IncrementDepth => { let _ = c.increment_depth(); } + Op::SetInitialDepth(d) => c.set_initial_depth(d), + Op::RequestWithTracking(id) => { let _ = c.request_with_tracking(id); } + } + } + // After any sequence of ops, depth should be bounded + assert!(c.request_depth() <= RelatedContractsContainer::MAX_REQUEST_DEPTH); + } + + #[test] + fn duplicate_request_always_fails( + id in arb_id(), + pre_ops in prop::collection::vec(arb_id(), 0..10) + ) { + let mut c = RelatedContractsContainer::default(); + // Request some other IDs first + for pre_id in pre_ops { + let _ = c.request_with_tracking(pre_id); + } + // First request of target ID + if c.request_with_tracking(id).is_ok() { + // Second request must fail (cycle) + assert!(c.request_with_tracking(id).is_err()); + } + } + + #[test] + fn merge_depth_is_max(a_depth in 0u32..15, b_depth in 0u32..15) { + let max = RelatedContractsContainer::MAX_REQUEST_DEPTH; + let mut a = RelatedContractsContainer::default(); + a.set_initial_depth(a_depth); + let mut b = RelatedContractsContainer::default(); + b.set_initial_depth(b_depth); + a.merge(b); + assert_eq!(a.request_depth(), a_depth.min(max).max(b_depth.min(max))); + } + + #[test] + fn merge_seen_is_union( + a_ids in prop::collection::vec(arb_id(), 0..8), + b_ids in prop::collection::vec(arb_id(), 0..8), + ) { + let mut a = RelatedContractsContainer::default(); + for id in &a_ids { + let _ = a.request_with_tracking(*id); + } + let mut b = RelatedContractsContainer::default(); + for id in &b_ids { + let _ = b.request_with_tracking(*id); + } + a.merge(b); + // All IDs from both should be detected as cycles + for id in a_ids.iter().chain(b_ids.iter()) { + assert!(a.request_with_tracking(*id).is_err()); + } + } + + #[test] + fn from_related_contracts_splits_correctly( + entries in prop::collection::vec( + (arb_id(), prop::option::of(arb_state())), + 0..16 + ) + ) { + let mut rc = RelatedContracts::from(std::collections::HashMap::new()); + for (id, state) in &entries { + rc.insert(*id, state.clone()); + } + let rc = rc.into_owned(); + let expected_resolved = rc.resolved_count(); + let expected_not_found = rc.pending_count(); + let container = RelatedContractsContainer::from(rc); + assert_eq!(container.resolved_count(), expected_resolved); + assert_eq!(container.not_found_count(), expected_not_found); + } + + #[test] + fn from_update_data_extracts_related_only( + updates in prop::collection::vec( + prop_oneof![ + arb_state().prop_map(|s| UpdateData::State(s)), + prop::collection::vec(any::(), 0..64) + .prop_map(|b| UpdateData::Delta(StateDelta::from(b))), + (arb_id(), arb_state()).prop_map(|(id, s)| + UpdateData::RelatedState { related_to: id, state: s }), + (arb_id(), arb_state(), prop::collection::vec(any::(), 0..64)) + .prop_map(|(id, s, d)| + UpdateData::RelatedStateAndDelta { + related_to: id, state: s, delta: StateDelta::from(d) + }), + ], + 0..20 + ) + ) { + let related_count = updates.iter().filter(|u| matches!(u, + UpdateData::RelatedState { .. } | UpdateData::RelatedStateAndDelta { .. } + )).count(); + let container = RelatedContractsContainer::from(updates); + // Container should have at most related_count entries + // (could be fewer due to duplicate IDs) + assert!(container.resolved_count() <= related_count); + } + } + } } diff --git a/rust/src/contract_interface/update.rs b/rust/src/contract_interface/update.rs index dbebc68..169e9d9 100644 --- a/rust/src/contract_interface/update.rs +++ b/rust/src/contract_interface/update.rs @@ -245,7 +245,7 @@ pub enum RelatedMode { } /// The result of calling the [`ContractInterface::validate_state`] function. -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ValidateResult { Valid, Invalid, @@ -471,4 +471,149 @@ mod tests { assert!(rc.timeout.is_none()); assert!(rc.ttl.is_none()); } + + mod proptest_roundtrips { + use super::*; + use proptest::prelude::*; + + fn arb_contract_instance_id() -> impl Strategy { + prop::array::uniform32(any::()).prop_map(|bytes| { + let encoded = bs58::encode(bytes) + .with_alphabet(bs58::Alphabet::BITCOIN) + .into_string(); + ContractInstanceId::from_bytes(encoded.as_bytes()).unwrap() + }) + } + + fn arb_state() -> impl Strategy> { + prop::collection::vec(any::(), 0..256).prop_map(State::from) + } + + fn arb_delta() -> impl Strategy> { + prop::collection::vec(any::(), 0..256).prop_map(StateDelta::from) + } + + fn arb_update_data() -> impl Strategy> { + prop_oneof![ + arb_state().prop_map(UpdateData::State), + arb_delta().prop_map(UpdateData::Delta), + (arb_state(), arb_delta()) + .prop_map(|(s, d)| UpdateData::StateAndDelta { state: s, delta: d }), + (arb_contract_instance_id(), arb_state()) + .prop_map(|(id, s)| UpdateData::RelatedState { + related_to: id, + state: s + }), + (arb_contract_instance_id(), arb_delta()) + .prop_map(|(id, d)| UpdateData::RelatedDelta { + related_to: id, + delta: d + }), + (arb_contract_instance_id(), arb_state(), arb_delta()).prop_map( + |(id, s, d)| UpdateData::RelatedStateAndDelta { + related_to: id, + state: s, + delta: d, + } + ), + ] + } + + fn arb_validate_result() -> impl Strategy { + prop_oneof![ + Just(ValidateResult::Valid), + Just(ValidateResult::Invalid), + prop::collection::vec(arb_contract_instance_id(), 0..8) + .prop_map(ValidateResult::RequestRelated), + ] + } + + fn arb_related_mode() -> impl Strategy { + prop_oneof![Just(RelatedMode::StateOnce), Just(RelatedMode::StateThenSubscribe),] + } + + proptest! { + #[test] + fn update_data_serde_roundtrip(data in arb_update_data()) { + let bytes = bincode::serialize(&data).unwrap(); + let decoded: UpdateData = bincode::deserialize(&bytes).unwrap(); + assert_eq!(data, decoded); + } + + #[test] + fn update_data_into_owned_preserves(data in arb_update_data()) { + let bytes_before = bincode::serialize(&data).unwrap(); + let owned = data.into_owned(); + let bytes_after = bincode::serialize(&owned).unwrap(); + assert_eq!(bytes_before, bytes_after); + } + + #[test] + fn validate_result_serde_roundtrip(result in arb_validate_result()) { + let bytes = bincode::serialize(&result).unwrap(); + let decoded: ValidateResult = bincode::deserialize(&bytes).unwrap(); + assert_eq!(result, decoded); + } + + #[test] + fn related_contracts_serde_roundtrip( + entries in prop::collection::vec( + (arb_contract_instance_id(), prop::option::of(arb_state())), + 0..16 + ) + ) { + let mut rc = RelatedContracts::new(); + for (id, state) in &entries { + rc.insert(*id, state.clone()); + } + let bytes = bincode::serialize(&rc).unwrap(); + let decoded: RelatedContracts = bincode::deserialize(&bytes).unwrap(); + assert_eq!(rc.len(), decoded.len()); + // Verify into_owned preserves + let owned = decoded.into_owned(); + let bytes2 = bincode::serialize(&owned).unwrap(); + let decoded2: RelatedContracts = bincode::deserialize(&bytes2).unwrap(); + assert_eq!(rc.len(), decoded2.len()); + } + + #[test] + fn related_contract_serde_roundtrip( + id in arb_contract_instance_id(), + mode in arb_related_mode(), + timeout_secs in prop::option::of(0u64..3600), + ttl_secs in prop::option::of(0u64..86400), + ) { + let mut rc = RelatedContract::new(id, mode); + if let Some(t) = timeout_secs { + rc = rc.with_timeout(Duration::from_secs(t)); + } + if let Some(t) = ttl_secs { + rc = rc.with_ttl(Duration::from_secs(t)); + } + let bytes = bincode::serialize(&rc).unwrap(); + let decoded: RelatedContract = bincode::deserialize(&bytes).unwrap(); + assert_eq!(rc.contract_instance_id, decoded.contract_instance_id); + assert_eq!(rc.timeout, decoded.timeout); + assert_eq!(rc.ttl, decoded.ttl); + } + + #[test] + fn related_contracts_query_invariants( + entries in prop::collection::vec( + (arb_contract_instance_id(), prop::option::of(arb_state())), + 0..32 + ) + ) { + let mut rc = RelatedContracts::new(); + for (id, state) in &entries { + rc.insert(*id, state.clone()); + } + // Deduplicated by HashMap + assert_eq!(rc.resolved_count() + rc.pending_count(), rc.len()); + if !rc.is_empty() && rc.pending_count() == 0 { + assert!(rc.all_resolved()); + } + } + } + } } From 9c7b23dad747aad0b94b918d6e28c8cc0e435aae Mon Sep 17 00:00:00 2001 From: Iora <4404227+xotatera@users.noreply.github.com> Date: Sat, 7 Mar 2026 10:19:12 +0300 Subject: [PATCH 6/6] chore: add proptest-regressions to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0a4bbdf..0d417b7 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ Cargo.lock .env .idea +proptest-regressions