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
7 changes: 7 additions & 0 deletions app/contract/contracts/Folder/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
126 changes: 126 additions & 0 deletions app/contract/contracts/Folder/src/escrow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>,
threshold: u32,
) -> Result<BytesN<32>, 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
// ---------------------------------------------------------------------------
Expand Down
36 changes: 36 additions & 0 deletions app/contract/contracts/Folder/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>,
threshold: u32,
) -> Result<BytesN<32>, 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
Expand Down
104 changes: 104 additions & 0 deletions app/contract/contracts/Folder/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address> = 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
// ============================================================================
Expand Down
Loading