From 1e2d3dd9885db4276e7cba2fede5fe7b94525997 Mon Sep 17 00:00:00 2001 From: macbook Date: Wed, 24 Jun 2026 23:11:43 +0100 Subject: [PATCH] bounded escrow resource check --- .../contracts/Folder/BUILD_AND_TEST.md | 14 ++ app/contract/contracts/Folder/src/errors.rs | 6 + app/contract/contracts/Folder/src/escrow.rs | 184 +++++++++++++++++- app/contract/contracts/Folder/src/lib.rs | 45 ++++- app/contract/contracts/Folder/src/test.rs | 141 ++++++++++++++ app/contract/contracts/Folder/src/types.rs | 43 ++++ 6 files changed, 425 insertions(+), 8 deletions(-) diff --git a/app/contract/contracts/Folder/BUILD_AND_TEST.md b/app/contract/contracts/Folder/BUILD_AND_TEST.md index 739c0d868..19be92c80 100644 --- a/app/contract/contracts/Folder/BUILD_AND_TEST.md +++ b/app/contract/contracts/Folder/BUILD_AND_TEST.md @@ -73,6 +73,20 @@ cargo test test_cross_asset -- --nocapture cargo test --package RustAcademy -- --include-ignored ``` +## Supported Escrow Limits + +The escrow entry points are bounded so Soroban resource use stays predictable. + +- Deposit-family calls support 1 token transfer path. +- `deposit_with_arbiters` supports up to 10 arbiters. +- Deposit-family calls support 0 fee recipients. +- `withdraw` supports 1 token transfer path. +- `withdraw` supports up to 3 fee recipients: recipient, optional platform wallet, optional collector. +- Deposit and withdraw salts are supported up to 512 bytes for bounded execution. + +Clients can preflight these limits with `get_escrow_operation_limits`, +`estimate_deposit_resources`, and `estimate_withdraw_resources`. + ### Expected Output When the network is working properly, you should see: diff --git a/app/contract/contracts/Folder/src/errors.rs b/app/contract/contracts/Folder/src/errors.rs index 401a0b9c0..75ee036b8 100644 --- a/app/contract/contracts/Folder/src/errors.rs +++ b/app/contract/contracts/Folder/src/errors.rs @@ -70,6 +70,12 @@ pub enum RustAcademyError { DuplicateArbiter = 327, /// The arbiters list exceeds the maximum allowed count. TooManyArbiters = 328, + /// The request payload exceeds the supported bounded size for predictable execution. + PayloadTooLarge = 329, + /// The operation would fan out to more fee recipients than the supported limit. + TooManyFeeRecipients = 330, + /// The operation references more token transfer paths than the supported limit. + TooManyTokens = 331, /// The configured fee split exceeds the available fee budget. FeeSplitExceedsTotal = 323, /// Dispute resolution timeout has not yet elapsed. diff --git a/app/contract/contracts/Folder/src/escrow.rs b/app/contract/contracts/Folder/src/escrow.rs index 7f7f84527..d7fdf5738 100644 --- a/app/contract/contracts/Folder/src/escrow.rs +++ b/app/contract/contracts/Folder/src/escrow.rs @@ -68,12 +68,16 @@ use crate::{ escrow_id, events, fee_router, hook, storage::{ clear_dispute_state, count_dispute_votes, get_commitment_escrow_id, get_dispute_vote, - get_escrow, get_escrow_id_mapping, has_dispute_vote, has_escrow, put_commitment_escrow_id, - put_dispute_vote, put_escrow, put_escrow_id_mapping, remove_commitment_escrow_id, - remove_dispute_vote, remove_escrow, remove_escrow_id_mapping, LEDGER_THRESHOLD, - SIX_MONTHS_IN_LEDGERS, + get_escrow, get_escrow_id_mapping, get_fee_config, get_oracle_fee_config, + get_per_asset_fee, get_platform_wallet, has_dispute_vote, has_escrow, + put_commitment_escrow_id, put_dispute_vote, put_escrow, put_escrow_id_mapping, + remove_commitment_escrow_id, remove_dispute_vote, remove_escrow, + remove_escrow_id_mapping, LEDGER_THRESHOLD, SIX_MONTHS_IN_LEDGERS, + }, + types::{ + DisputeVote, EscrowEntry, EscrowOperationEstimate, EscrowOperationLimits, EscrowStatus, + HookEventKind, Role, }, - types::{DisputeVote, EscrowEntry, EscrowStatus, HookEventKind, Role}, }; // --------------------------------------------------------------------------- @@ -128,6 +132,171 @@ fn compute_expires_at(env: &Env, timeout_secs: u64) -> Result EscrowOperationLimits { + EscrowOperationLimits { + max_salt_bytes: MAX_OPERATION_SALT_BYTES, + deposit_max_token_count: MAX_SUPPORTED_TOKEN_COUNT, + deposit_max_arbiter_count: MAX_ARBITERS, + deposit_max_fee_recips: MAX_DEPOSIT_FEE_RECIPIENTS, + deposit_max_cpu_instructions: SUPPORTED_DEPOSIT_MAX_CPU_INSTRUCTIONS, + deposit_max_memory_bytes: SUPPORTED_DEPOSIT_MAX_MEMORY_BYTES, + withdraw_max_token_count: MAX_SUPPORTED_TOKEN_COUNT, + withdraw_max_arbiter_count: MAX_WITHDRAW_ARBITERS, + withdraw_max_fee_recips: MAX_WITHDRAW_FEE_RECIPIENTS, + withdraw_max_cpu_instructions: SUPPORTED_WITHDRAW_MAX_CPU_INSTRUCTIONS, + withdraw_max_memory_bytes: SUPPORTED_WITHDRAW_MAX_MEMORY_BYTES, + } +} + +pub fn estimate_deposit_resources_view( + salt_bytes: u32, + arbiter_count: u32, +) -> Result { + estimate_deposit_resources(salt_bytes, arbiter_count) +} + +pub fn estimate_withdraw_resources_view( + env: &Env, + token: Address, + salt_bytes: u32, +) -> Result { + estimate_withdraw_resources(salt_bytes, withdraw_fee_recipient_count(env, &token)) +} + +fn estimate_deposit_resources( + salt_bytes: u32, + arbiter_count: u32, +) -> Result { + if salt_bytes > MAX_OPERATION_SALT_BYTES { + return Err(RustAcademyError::PayloadTooLarge); + } + if arbiter_count > MAX_ARBITERS { + return Err(RustAcademyError::TooManyArbiters); + } + + Ok(EscrowOperationEstimate { + token_count: MAX_SUPPORTED_TOKEN_COUNT, + arbiter_count, + fee_recipient_count: MAX_DEPOSIT_FEE_RECIPIENTS, + salt_bytes, + estimated_cpu_instructions: DEPOSIT_BASE_CPU_INSTRUCTIONS + .saturating_add((salt_bytes as u64).saturating_mul(ESTIMATED_CPU_PER_SALT_BYTE)) + .saturating_add( + (arbiter_count as u64).saturating_mul(ESTIMATED_CPU_PER_DEPOSIT_ARBITER), + ), + estimated_memory_bytes: DEPOSIT_BASE_MEMORY_BYTES + .saturating_add((salt_bytes as u64).saturating_mul(ESTIMATED_MEMORY_PER_SALT_BYTE)) + .saturating_add( + (arbiter_count as u64).saturating_mul(ESTIMATED_MEMORY_PER_DEPOSIT_ARBITER), + ), + }) +} + +fn estimate_withdraw_resources( + salt_bytes: u32, + fee_recipient_count: u32, +) -> Result { + if salt_bytes > MAX_OPERATION_SALT_BYTES { + return Err(RustAcademyError::PayloadTooLarge); + } + if fee_recipient_count > MAX_WITHDRAW_FEE_RECIPIENTS { + return Err(RustAcademyError::TooManyFeeRecipients); + } + + Ok(EscrowOperationEstimate { + token_count: MAX_SUPPORTED_TOKEN_COUNT, + arbiter_count: MAX_WITHDRAW_ARBITERS, + fee_recipient_count, + salt_bytes, + estimated_cpu_instructions: WITHDRAW_BASE_CPU_INSTRUCTIONS + .saturating_add((salt_bytes as u64).saturating_mul(ESTIMATED_CPU_PER_SALT_BYTE)) + .saturating_add( + (fee_recipient_count as u64) + .saturating_mul(ESTIMATED_CPU_PER_WITHDRAW_FEE_RECIPIENT), + ), + estimated_memory_bytes: WITHDRAW_BASE_MEMORY_BYTES + .saturating_add((salt_bytes as u64).saturating_mul(ESTIMATED_MEMORY_PER_SALT_BYTE)) + .saturating_add( + (fee_recipient_count as u64) + .saturating_mul(ESTIMATED_MEMORY_PER_WITHDRAW_FEE_RECIPIENT), + ), + }) +} + +fn validate_deposit_resources(salt: &Bytes, arbiter_count: u32) -> Result<(), RustAcademyError> { + let estimate = estimate_deposit_resources(salt.len(), arbiter_count)?; + if estimate.token_count > MAX_SUPPORTED_TOKEN_COUNT { + return Err(RustAcademyError::TooManyTokens); + } + if estimate.estimated_cpu_instructions > SUPPORTED_DEPOSIT_MAX_CPU_INSTRUCTIONS + || estimate.estimated_memory_bytes > SUPPORTED_DEPOSIT_MAX_MEMORY_BYTES + { + return Err(RustAcademyError::PayloadTooLarge); + } + Ok(()) +} + +fn withdraw_fee_recipient_count(env: &Env, token: &Address) -> u32 { + let mut recipient_count = 1u32; + let per_asset = get_per_asset_fee(env, token); + let has_fees = per_asset + .map(|config| config.fee_bps > 0) + .unwrap_or_else(|| { + get_fee_config(env).fee_bps > 0 || get_oracle_fee_config(env).is_some() + }); + + if !has_fees { + return recipient_count; + } + + if get_platform_wallet(env).is_some() { + recipient_count = recipient_count.saturating_add(1); + } + if fee_router::active_collector(env).is_some() { + recipient_count = recipient_count.saturating_add(1); + } + + recipient_count +} + +fn validate_withdraw_resources( + env: &Env, + token: &Address, + salt: &Bytes, +) -> Result<(), RustAcademyError> { + let estimate = estimate_withdraw_resources(salt.len(), withdraw_fee_recipient_count(env, token))?; + if estimate.token_count > MAX_SUPPORTED_TOKEN_COUNT { + return Err(RustAcademyError::TooManyTokens); + } + if estimate.estimated_cpu_instructions > SUPPORTED_WITHDRAW_MAX_CPU_INSTRUCTIONS + || estimate.estimated_memory_bytes > SUPPORTED_WITHDRAW_MAX_MEMORY_BYTES + { + return Err(RustAcademyError::PayloadTooLarge); + } + Ok(()) +} + // --------------------------------------------------------------------------- // deposit // --------------------------------------------------------------------------- @@ -156,6 +325,7 @@ pub fn deposit( if amount <= 0 { return Err( RustAcademyError::InvalidAmount); } + validate_deposit_resources(&salt, 0)?; owner.require_auth(); @@ -266,6 +436,7 @@ pub fn deposit_with_arbiters( if amount <= 0 { return Err( RustAcademyError::InvalidAmount); } + validate_deposit_resources(&salt, arbiters.len())?; if arbiters.is_empty() || threshold == 0 { return Err( RustAcademyError::InvalidThreshold); } @@ -383,6 +554,7 @@ pub fn deposit_with_commitment( if amount <= 0 { return Err( RustAcademyError::InvalidAmount); } + validate_deposit_resources(&Bytes::new(env), 0)?; from.require_auth(); @@ -472,6 +644,7 @@ pub fn deposit_partial( if amount_due <= 0 { return Err( RustAcademyError::InvalidAmount); } + validate_deposit_resources(&salt, 0)?; owner.require_auth(); @@ -705,6 +878,7 @@ pub fn withdraw(env: &Env, amount: i128, to: Address, salt: Bytes) -> Result EscrowOperationLimits { + escrow::operation_limits() + } + + /// Estimate the bounded resource envelope for a deposit-shaped payload. + pub fn estimate_deposit_resources( + _env: Env, + salt_bytes: u32, + arbiter_count: u32, + ) -> Result { + escrow::estimate_deposit_resources_view(salt_bytes, arbiter_count) + } + + /// Estimate the bounded resource envelope for a withdraw-shaped payload. + pub fn estimate_withdraw_resources( + env: Env, + token: Address, + salt_bytes: u32, + ) -> Result { + escrow::estimate_withdraw_resources_view(&env, token, salt_bytes) + } + /// Run any pending data migrations for the current contract code (**Admin only**). /// /// This entrypoint is intended to be called immediately after upgrading the contract WASM diff --git a/app/contract/contracts/Folder/src/test.rs b/app/contract/contracts/Folder/src/test.rs index f61ba6d0f..d940bda15 100644 --- a/app/contract/contracts/Folder/src/test.rs +++ b/app/contract/contracts/Folder/src/test.rs @@ -762,11 +762,114 @@ fn test_canonical_error_code_ranges() { assert_eq!( RustAcademyError::EscrowExpired as u32, 307); assert_eq!( RustAcademyError::EscrowNotExpired as u32, 308); assert_eq!( RustAcademyError::InvalidOwner as u32, 309); + assert_eq!( RustAcademyError::PayloadTooLarge as u32, 329); + assert_eq!( RustAcademyError::TooManyFeeRecipients as u32, 330); + assert_eq!( RustAcademyError::TooManyTokens as u32, 331); // Internal/unexpected conditions (900-999) assert_eq!( RustAcademyError::InternalError as u32, 900); } +#[test] +fn test_get_escrow_operation_limits_reports_supported_bounds() { + let (env, client) = setup(); + let limits = client.get_escrow_operation_limits(); + + assert_eq!(limits.max_salt_bytes, 512); + assert_eq!(limits.deposit_max_token_count, 1); + assert_eq!(limits.deposit_max_arbiter_count, 10); + assert_eq!(limits.deposit_max_fee_recips, 0); + assert_eq!(limits.withdraw_max_token_count, 1); + assert_eq!(limits.withdraw_max_arbiter_count, 0); + assert_eq!(limits.withdraw_max_fee_recips, 3); + assert!(limits.deposit_max_cpu_instructions >= 465_139); + assert!(limits.withdraw_max_cpu_instructions >= 452_582); + + let token = create_test_token(&env); + let deposit_estimate = client + .try_estimate_deposit_resources(&512, &10) + .unwrap() + .unwrap(); + assert_eq!(deposit_estimate.token_count, 1); + assert_eq!(deposit_estimate.arbiter_count, 10); + assert_eq!(deposit_estimate.fee_recipient_count, 0); + + let withdraw_estimate = client + .try_estimate_withdraw_resources(&token, &512) + .unwrap() + .unwrap(); + assert_eq!(withdraw_estimate.token_count, 1); + assert_eq!(withdraw_estimate.arbiter_count, 0); + assert_eq!(withdraw_estimate.salt_bytes, 512); +} + +#[test] +fn test_deposit_rejects_large_but_otherwise_valid_salt_payloads() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &2_000); + + let salt = Bytes::from_array(&env, &[7u8; 513]); + let result = client.try_deposit(&token, &1_000i128, &owner, &salt, &0u64, &None); + assert_contract_error(result, RustAcademyError::PayloadTooLarge); +} + +#[test] +fn test_withdraw_rejects_large_but_otherwise_valid_salt_payloads() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let amount = 1_000i128; + let salt = Bytes::from_array(&env, &[9u8; 513]); + let commitment = client.create_amount_commitment(&owner, &amount, &salt); + + setup_escrow( + &env, + &client.address, + &token, + amount, + commitment.clone(), + 0, + ); + + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&client.address, &amount); + + let result = client.try_withdraw(&token, &amount, &commitment, &owner, &salt); + assert_contract_error(result, RustAcademyError::PayloadTooLarge); +} + +#[test] +fn test_budget_envelopes_cover_supported_deposit_and_withdraw_paths() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let admin = Address::generate(&env); + let collector = Address::generate(&env); + let amount = 1_000_000i128; + let salt = Bytes::from_slice(&env, b"budget-envelope-salt"); + let limits = client.get_escrow_operation_limits(); + + client.initialize(&admin); + client.set_platform_wallet(&admin, &collector); + client.rotate_fee_collector(&admin, &collector); + + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &(amount * 2)); + + env.cost_estimate().budget().reset_default(); + let commitment = client.deposit(&token, &amount, &owner, &salt, &0u64, &None); + assert!(env.cost_estimate().budget().cpu_instruction_cost() <= limits.deposit_max_cpu_instructions); + assert!(env.cost_estimate().budget().memory_bytes_cost() <= limits.deposit_max_memory_bytes); + + env.cost_estimate().budget().reset_default(); + client.withdraw(&token, &amount, &commitment, &owner, &salt); + assert!(env.cost_estimate().budget().cpu_instruction_cost() <= limits.withdraw_max_cpu_instructions); + assert!(env.cost_estimate().budget().memory_bytes_cost() <= limits.withdraw_max_memory_bytes); +} + /// Regression suite: deposit with commitment — create escrow (golden path). #[test] fn test_deposit() { @@ -3665,6 +3768,25 @@ fn test_deposit_with_arbiters_creates_escrow_and_is_disputable() { assert_eq!(client.get_commitment_state(&commitment), Some(EscrowStatus::Pending)); } +#[test] +fn test_deposit_with_arbiters_accepts_supported_upper_bound() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &5_000); + let salt = Bytes::from_slice(&env, b"max-arbiters-supported"); + + let mut arbiters = Vec::new(&env); + for _ in 0..10 { + arbiters.push_back(Address::generate(&env)); + } + + let commitment = + client.deposit_with_arbiters(&token, &1_000i128, &owner, &salt, &0u64, &arbiters, &10); + assert_eq!(client.get_commitment_state(&commitment), Some(EscrowStatus::Pending)); +} + #[test] fn test_deposit_with_arbiters_rejects_zero_threshold() { let (env, client) = setup(); @@ -3723,6 +3845,25 @@ fn test_deposit_with_arbiters_rejects_duplicate_arbiters() { assert_eq!(result.unwrap_err().unwrap(), crate::errors:: RustAcademyError::DuplicateArbiter); } +#[test] +fn test_deposit_with_arbiters_rejects_above_supported_upper_bound() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &5_000); + let salt = Bytes::from_slice(&env, b"too-many-arbiters"); + + let mut arbiters = Vec::new(&env); + for _ in 0..11 { + arbiters.push_back(Address::generate(&env)); + } + + let result = + client.try_deposit_with_arbiters(&token, &1_000i128, &owner, &salt, &0u64, &arbiters, &11); + assert_contract_error(result, RustAcademyError::TooManyArbiters); +} + #[test] fn test_deposit_with_arbiters_rejects_empty_arbiters() { let (env, client) = setup(); diff --git a/app/contract/contracts/Folder/src/types.rs b/app/contract/contracts/Folder/src/types.rs index 92a4fc4eb..7537a41aa 100644 --- a/app/contract/contracts/Folder/src/types.rs +++ b/app/contract/contracts/Folder/src/types.rs @@ -292,6 +292,49 @@ pub struct OracleFeeConfig { pub stale_threshold_secs: u64, } +/// Supported escrow operation bounds and published worst-case budget envelopes. +/// +/// These limits are part of the public contract surface so integrators can +/// preflight deposits and withdrawals before submitting transactions. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EscrowOperationLimits { + /// Maximum salt bytes accepted for resource-bounded deposit and withdraw flows. + pub max_salt_bytes: u32, + /// Maximum token transfer paths supported by a deposit call. + pub deposit_max_token_count: u32, + /// Maximum arbiters supported by the deposit family (`deposit_with_arbiters`). + pub deposit_max_arbiter_count: u32, + /// Maximum fee recipients touched by deposit paths. + pub deposit_max_fee_recips: u32, + /// Published worst-case CPU budget envelope for supported deposit payloads. + pub deposit_max_cpu_instructions: u64, + /// Published worst-case memory budget envelope for supported deposit payloads. + pub deposit_max_memory_bytes: u64, + /// Maximum token transfer paths supported by a withdraw call. + pub withdraw_max_token_count: u32, + /// Maximum arbiters consulted by the standard withdraw path. + pub withdraw_max_arbiter_count: u32, + /// Maximum fee recipients touched by a withdraw call. + pub withdraw_max_fee_recips: u32, + /// Published worst-case CPU budget envelope for supported withdraw payloads. + pub withdraw_max_cpu_instructions: u64, + /// Published worst-case memory budget envelope for supported withdraw payloads. + pub withdraw_max_memory_bytes: u64, +} + +/// Resource estimate for a concrete escrow operation shape. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct EscrowOperationEstimate { + pub token_count: u32, + pub arbiter_count: u32, + pub fee_recipient_count: u32, + pub salt_bytes: u32, + pub estimated_cpu_instructions: u64, + pub estimated_memory_bytes: u64, +} + /// Deployment metadata returned by [`crate:: RustAcademyContract::get_deployment_metadata`]. /// /// Clients and indexers can call this view to validate compatibility without