Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/contract/contracts/Folder/BUILD_AND_TEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions app/contract/contracts/Folder/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
184 changes: 179 additions & 5 deletions app/contract/contracts/Folder/src/escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -128,6 +132,171 @@ fn compute_expires_at(env: &Env, timeout_secs: u64) -> Result<u64, RustAcademyE
Ok(expires_at)
}

pub const MAX_OPERATION_SALT_BYTES: u32 = 512;
pub const MAX_SUPPORTED_TOKEN_COUNT: u32 = 1;
pub const MAX_DEPOSIT_FEE_RECIPIENTS: u32 = 0;
pub const MAX_WITHDRAW_ARBITERS: u32 = 0;
pub const MAX_WITHDRAW_FEE_RECIPIENTS: u32 = 3;

const DEPOSIT_BASE_CPU_INSTRUCTIONS: u64 = 465_139;
const DEPOSIT_BASE_MEMORY_BYTES: u64 = 72_430;
const WITHDRAW_BASE_CPU_INSTRUCTIONS: u64 = 452_582;
const WITHDRAW_BASE_MEMORY_BYTES: u64 = 68_096;
const ESTIMATED_CPU_PER_SALT_BYTE: u64 = 32;
const ESTIMATED_MEMORY_PER_SALT_BYTE: u64 = 8;
const ESTIMATED_CPU_PER_DEPOSIT_ARBITER: u64 = 20_000;
const ESTIMATED_MEMORY_PER_DEPOSIT_ARBITER: u64 = 4_096;
const ESTIMATED_CPU_PER_WITHDRAW_FEE_RECIPIENT: u64 = 15_000;
const ESTIMATED_MEMORY_PER_WITHDRAW_FEE_RECIPIENT: u64 = 4_096;
const SUPPORTED_DEPOSIT_MAX_CPU_INSTRUCTIONS: u64 = 700_000;
const SUPPORTED_DEPOSIT_MAX_MEMORY_BYTES: u64 = 120_000;
const SUPPORTED_WITHDRAW_MAX_CPU_INSTRUCTIONS: u64 = 620_000;
const SUPPORTED_WITHDRAW_MAX_MEMORY_BYTES: u64 = 96_000;

pub fn operation_limits() -> 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<EscrowOperationEstimate, RustAcademyError> {
estimate_deposit_resources(salt_bytes, arbiter_count)
}

pub fn estimate_withdraw_resources_view(
env: &Env,
token: Address,
salt_bytes: u32,
) -> Result<EscrowOperationEstimate, RustAcademyError> {
estimate_withdraw_resources(salt_bytes, withdraw_fee_recipient_count(env, &token))
}

fn estimate_deposit_resources(
salt_bytes: u32,
arbiter_count: u32,
) -> Result<EscrowOperationEstimate, RustAcademyError> {
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<EscrowOperationEstimate, RustAcademyError> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -156,6 +325,7 @@ pub fn deposit(
if amount <= 0 {
return Err( RustAcademyError::InvalidAmount);
}
validate_deposit_resources(&salt, 0)?;

owner.require_auth();

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -472,6 +644,7 @@ pub fn deposit_partial(
if amount_due <= 0 {
return Err( RustAcademyError::InvalidAmount);
}
validate_deposit_resources(&salt, 0)?;

owner.require_auth();

Expand Down Expand Up @@ -705,6 +878,7 @@ pub fn withdraw(env: &Env, amount: i128, to: Address, salt: Bytes) -> Result<boo
if entry.amount_paid < entry.amount_due {
return Err( RustAcademyError::Overpayment);
}
validate_withdraw_resources(env, &entry.token, &salt)?;

// optimized: destructure what we need, move entry instead of cloning
let token_ref = entry.token.clone();
Expand Down
45 changes: 42 additions & 3 deletions app/contract/contracts/Folder/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ mod upgrade_test;
use errors::RustAcademyError;
use storage::*;
use types::{
ContractHealth, DeploymentMetadata, DisputeExpiryAction, EscrowEntry, EscrowStatus,
FeatureFlags, FeeConfig, OracleFeeConfig, PerAssetFeeConfig, PrivacyAwareEscrowView, Role,
SchemaCompatibility, StealthDepositParams, SupportedVersions, UpgradeState,
ContractHealth, DeploymentMetadata, DisputeExpiryAction, EscrowEntry,
EscrowOperationEstimate, EscrowOperationLimits, EscrowStatus, FeatureFlags, FeeConfig,
OracleFeeConfig, PerAssetFeeConfig, PrivacyAwareEscrowView, Role, SchemaCompatibility,
StealthDepositParams, SupportedVersions, UpgradeState,
};

pub use types::FeeRatio;
Expand All @@ -73,6 +74,21 @@ pub use types::FeeRatio;
/// The contract uses Soroban's standardized token interface which works uniformly across
/// all asset types. No special wrap/unwrap logic is required from users.
///
/// ## Supported Escrow Limits
///
/// The contract publishes bounded escrow limits through
/// [`RustAcademyContract::get_escrow_operation_limits`]. The current supported
/// envelopes are:
/// - deposit token transfers: 1
/// - deposit arbiters: up to 10
/// - deposit fee recipients: 0
/// - withdraw token transfers: 1
/// - withdraw fee recipients: up to 3
/// - deposit/withdraw salt bytes: up to 512 for predictable execution
///
/// Requests outside those bounds fail with explicit contract errors instead of
/// consuming unbounded Soroban resources.
///
/// ## Escrow State Machine
///
/// ```text
Expand Down Expand Up @@ -810,6 +826,29 @@ impl RustAcademyContract {
metadata::pause_flags(&env)
}

/// Return the supported escrow operation limits and published budget envelopes.
pub fn get_escrow_operation_limits(_env: Env) -> 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<EscrowOperationEstimate, RustAcademyError> {
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<EscrowOperationEstimate, RustAcademyError> {
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
Expand Down
Loading
Loading