diff --git a/app/contract/contracts/Folder/src/errors.rs b/app/contract/contracts/Folder/src/errors.rs index a25d12348..401a0b9c0 100644 --- a/app/contract/contracts/Folder/src/errors.rs +++ b/app/contract/contracts/Folder/src/errors.rs @@ -63,6 +63,13 @@ pub enum RustAcademyError { InsufficientVotes = 321, /// Fee ratios or denominators are invalid for the configured payout split. InvalidFeeConfiguration = 322, + /// Dispute resolution threshold is zero, exceeds arbiter count, or the + /// arbiters list is empty. + InvalidThreshold = 326, + /// The arbiters list contains a duplicate address. + DuplicateArbiter = 327, + /// The arbiters list exceeds the maximum allowed count. + TooManyArbiters = 328, /// 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 9c5bd7aa8..944c37e0b 100644 --- a/app/contract/contracts/Folder/src/escrow.rs +++ b/app/contract/contracts/Folder/src/escrow.rs @@ -231,6 +231,132 @@ pub fn deposit( Ok(commitment) } +// --------------------------------------------------------------------------- +// deposit_with_arbiters +// --------------------------------------------------------------------------- + +/// Maximum number of arbiters allowed in a multi-sig escrow. +pub const MAX_ARBITERS: u32 = 10; + +/// Create a multi-signature (M-of-N) escrow where `threshold` of `arbiters` must +/// vote to resolve any dispute. +/// +/// - Transfers `amount` from `owner` to the contract. +/// - Sets `arbiters` and `arbiter_threshold` on the entry (multi-sig mode). +/// - `arbiter` field is set to `None`; resolution goes through `vote_for_dispute`. +/// +/// # Errors +/// - [`InvalidAmount`] – amount ≤ 0. +/// - [`InvalidSalt`] – salt > 1024 bytes. +/// - [`InvalidThreshold`] – threshold is 0, empty arbiters list, or threshold > len(arbiters). +/// - [`DuplicateArbiter`] – the arbiters list contains a duplicate address. +/// - [`TooManyArbiters`] – the arbiters list exceeds [`MAX_ARBITERS`]. +/// - [`CommitmentAlreadyExists`] – a commitment with the same key already exists. +#[allow(clippy::too_many_arguments)] +pub fn deposit_with_arbiters( + env: &Env, + token: Address, + amount: i128, + owner: Address, + salt: Bytes, + timeout_secs: u64, + arbiters: Vec
, + threshold: u32, +) -> Result, RustAcademyError> { + if amount <= 0 { + return Err( RustAcademyError::InvalidAmount); + } + if arbiters.is_empty() || threshold == 0 { + return Err( RustAcademyError::InvalidThreshold); + } + let arbiter_count = arbiters.len(); + if threshold > arbiter_count { + return Err( RustAcademyError::InvalidThreshold); + } + if arbiter_count > MAX_ARBITERS { + return Err( RustAcademyError::TooManyArbiters); + } + + // Reject duplicate arbiters (O(n²) is fine for small n ≤ MAX_ARBITERS). + for i in 0..arbiter_count { + for j in (i + 1)..arbiter_count { + if arbiters.get_unchecked(i) == arbiters.get_unchecked(j) { + return Err( RustAcademyError::DuplicateArbiter); + } + } + } + + owner.require_auth(); + + let expires_at = compute_expires_at(env, timeout_secs)?; + + // Use a sentinel None arbiter for escrow_id derivation — the arbiters vec + // is stored in the entry but not part of the dedup key (matching `deposit`). + let escrow_id = + escrow_id::derive_escrow_id(env, &token, amount, &owner, &salt, timeout_secs, &None)?; + if let Some(existing) = get_escrow_id_mapping(env, &escrow_id) { + return Ok(existing); + } + + let (commitment, legacy_commitment) = + commitment::amount_commitment_hashes(env, &owner, amount, &salt)?; + let commitment_bytes: Bytes = commitment.clone().into(); + if has_escrow(env, &commitment_bytes) { + return Err( RustAcademyError::CommitmentAlreadyExists); + } + if legacy_commitment != commitment { + let legacy_bytes: Bytes = legacy_commitment.into(); + if has_escrow(env, &legacy_bytes) { + return Err( RustAcademyError::CommitmentAlreadyExists); + } + } + + let now = env.ledger().timestamp(); + let token_client = token::Client::new(env, &token); + let commitment_bytes_ref = commitment_bytes.clone(); + + let entry = EscrowEntry { + token, // moved + amount_due: amount, + amount_paid: amount, + owner: owner.clone(), + status: EscrowStatus::Pending, + created_at: now, + expires_at, + arbiter: None, + arbiters, + arbiter_threshold: threshold, + }; + + put_escrow(env, &commitment_bytes, &entry); + put_escrow_id_mapping(env, &escrow_id, &commitment); + put_commitment_escrow_id(env, &commitment_bytes_ref, &escrow_id); + token_client.transfer(&owner, env.current_contract_address(), &amount); + + let token_addr = token_client.address.clone(); + events::publish_escrow_deposited( + env, + commitment.clone(), + owner.clone(), + token_addr.clone(), + amount, + amount, + expires_at, + ); + + hook::invoke_hooks( + env, + HookEventKind::Create, + &commitment, + owner, + token_addr, + amount, + 0, + ); + + Ok(commitment) +} + // --------------------------------------------------------------------------- // deposit_with_commitment // --------------------------------------------------------------------------- diff --git a/app/contract/contracts/Folder/src/lib.rs b/app/contract/contracts/Folder/src/lib.rs index 6a4bee5ef..fef4afd25 100644 --- a/app/contract/contracts/Folder/src/lib.rs +++ b/app/contract/contracts/Folder/src/lib.rs @@ -453,6 +453,42 @@ impl RustAcademyContract { ) } + /// Create a multi-signature (M-of-N) arbitration escrow. + /// + /// Like `deposit`, but accepts a list of arbiters and a threshold: `threshold` + /// of the `arbiters` must vote to resolve any dispute. + /// + /// # Arguments + /// * `token` - Token contract address + /// * `amount` - Amount to escrow (must be > 0) + /// * `owner` - Depositor address (must authorize) + /// * `salt` - Uniqueness salt (max 1024 bytes) + /// * `timeout_secs` - Seconds until expiry; 0 = no expiry + /// * `arbiters` - Non-empty list of arbiter addresses (max 10, no duplicates) + /// * `threshold` - Number of arbiter votes required to resolve (1 ≤ threshold ≤ len(arbiters)) + /// + /// # Errors + /// * `InvalidAmount` - Amount is zero or negative + /// * `InvalidSalt` - Salt exceeds 1024 bytes + /// * `InvalidThreshold` - Threshold is 0, exceeds arbiter count, or arbiters is empty + /// * `DuplicateArbiter` - The arbiters list contains duplicate addresses + /// * `TooManyArbiters` - More than 10 arbiters provided + /// * `CommitmentAlreadyExists` - An escrow with the same commitment already exists + #[allow(clippy::too_many_arguments)] + pub fn deposit_with_arbiters( + env: Env, + token: Address, + amount: i128, + owner: Address, + salt: Bytes, + timeout_secs: u64, + arbiters: Vec
, + threshold: u32, + ) -> Result, RustAcademyError> { + admin::guard_deposit(&env, PauseFlag::Deposit)?; + escrow::deposit_with_arbiters(&env, token, amount, owner, salt, timeout_secs, arbiters, threshold) + } + /// Make a partial payment towards an existing escrow. /// /// Transfers `payment_amount` from `payer` to the contract and increments the diff --git a/app/contract/contracts/Folder/src/test.rs b/app/contract/contracts/Folder/src/test.rs index 4a5e50894..e94143905 100644 --- a/app/contract/contracts/Folder/src/test.rs +++ b/app/contract/contracts/Folder/src/test.rs @@ -3636,6 +3636,110 @@ fn test_multi_payment_sequence() { assert_eq!(details.amount_due, Some(1000)); } +// ============================================================================ +// deposit_with_arbiters — multi-sig escrow creation API (Issue #14) +// ============================================================================ + +#[test] +fn test_deposit_with_arbiters_creates_escrow_and_is_disputable() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let a1 = Address::generate(&env); + let a2 = Address::generate(&env); + let a3 = Address::generate(&env); + let amount: i128 = 3000; + let salt = Bytes::from_slice(&env, b"multisig_creation"); + + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &amount); + + let mut arbiters = Vec::new(&env); + arbiters.push_back(a1.clone()); + arbiters.push_back(a2.clone()); + arbiters.push_back(a3.clone()); + + let commitment = client.deposit_with_arbiters(&token, &amount, &owner, &salt, &0, &arbiters, &2); + + // Escrow exists and is Pending. + assert_eq!(client.get_commitment_state(&commitment), Some(EscrowStatus::Pending)); +} + +#[test] +fn test_deposit_with_arbiters_rejects_zero_threshold() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let amount: i128 = 1000; + let salt = Bytes::from_slice(&env, b"zero_threshold"); + + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &amount); + + let mut arbiters = Vec::new(&env); + arbiters.push_back(Address::generate(&env)); + + let result = client.try_deposit_with_arbiters(&token, &amount, &owner, &salt, &0, &arbiters, &0); + assert_eq!(result.unwrap_err().unwrap(), crate::errors:: RustAcademyError::InvalidThreshold); +} + +#[test] +fn test_deposit_with_arbiters_rejects_threshold_exceeding_count() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let amount: i128 = 1000; + let salt = Bytes::from_slice(&env, b"exceed_threshold"); + + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &amount); + + let mut arbiters = Vec::new(&env); + arbiters.push_back(Address::generate(&env)); + arbiters.push_back(Address::generate(&env)); + + // threshold=3 exceeds arbiter count=2 + let result = client.try_deposit_with_arbiters(&token, &amount, &owner, &salt, &0, &arbiters, &3); + assert_eq!(result.unwrap_err().unwrap(), crate::errors:: RustAcademyError::InvalidThreshold); +} + +#[test] +fn test_deposit_with_arbiters_rejects_duplicate_arbiters() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let amount: i128 = 1000; + let salt = Bytes::from_slice(&env, b"dup_arbiter"); + + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &amount); + + let dup = Address::generate(&env); + let mut arbiters = Vec::new(&env); + arbiters.push_back(dup.clone()); + arbiters.push_back(dup.clone()); // duplicate + + let result = client.try_deposit_with_arbiters(&token, &amount, &owner, &salt, &0, &arbiters, &1); + assert_eq!(result.unwrap_err().unwrap(), crate::errors:: RustAcademyError::DuplicateArbiter); +} + +#[test] +fn test_deposit_with_arbiters_rejects_empty_arbiters() { + let (env, client) = setup(); + let token = create_test_token(&env); + let owner = Address::generate(&env); + let amount: i128 = 1000; + let salt = Bytes::from_slice(&env, b"empty_arbiters"); + + let token_client = token::StellarAssetClient::new(&env, &token); + token_client.mint(&owner, &amount); + + let arbiters: Vec
= Vec::new(&env); + + let result = client.try_deposit_with_arbiters(&token, &amount, &owner, &salt, &0, &arbiters, &1); + assert_eq!(result.unwrap_err().unwrap(), crate::errors:: RustAcademyError::InvalidThreshold); +} + // ============================================================================ // Multi-Sig Arbiter Tests // ============================================================================