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
// ============================================================================