From cbc05123329333e4ee7cc93f9ea7bccfea022458 Mon Sep 17 00:00:00 2001 From: Thompson <140930314+Godbrand0@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:20:49 -0400 Subject: [PATCH] Add slashing mechanics with test coverage (#20) Implements a new `trustflow` Soroban contract crate that introduces juror staking and a dispute-resolution system where minority voters are penalised via stake slashing. Adds 13 unit tests that simulate multiple dispute rounds with a malicious juror and verify their balance decreases monotonically after each round. --- contracts/Cargo.toml | 2 +- contracts/trustflow/Cargo.toml | 14 + contracts/trustflow/src/lib.rs | 958 +++++++++++++++++++++++++++++++++ 3 files changed, 973 insertions(+), 1 deletion(-) create mode 100644 contracts/trustflow/Cargo.toml create mode 100644 contracts/trustflow/src/lib.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 40b4576..84c753c 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -1,7 +1,7 @@ [workspace] resolver = "2" -members = ["abundance", "crowdfund", "mock-token"] +members = ["abundance", "crowdfund", "mock-token", "trustflow"] [workspace.package] rust-version = "1.74.0" diff --git a/contracts/trustflow/Cargo.toml b/contracts/trustflow/Cargo.toml new file mode 100644 index 0000000..bf047ef --- /dev/null +++ b/contracts/trustflow/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "trustflow" +version = "0.1.0" +edition = "2021" +rust-version.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +soroban-sdk = { workspace = true } + +[dev-dependencies] +soroban-sdk = { workspace = true, features = ["testutils"] } diff --git a/contracts/trustflow/src/lib.rs b/contracts/trustflow/src/lib.rs new file mode 100644 index 0000000..148e26a --- /dev/null +++ b/contracts/trustflow/src/lib.rs @@ -0,0 +1,958 @@ +#![cfg_attr(not(test), no_std)] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, token, vec, Address, Env, String, Vec, +}; + +/// Slash rate in basis points applied to minority voters (10% = 1000 bps) +const DEFAULT_SLASH_BPS: u32 = 1_000; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum TrustFlowError { + Unauthorized = 1, + EscrowNotFound = 2, + InvalidAmount = 3, + DisputeNotFound = 4, + DisputeAlreadyResolved = 5, + InvalidState = 6, + AlreadyVoted = 7, + InsufficientStake = 8, + NoVotesCast = 9, +} + +// --------------------------------------------------------------------------- +// Storage types +// --------------------------------------------------------------------------- + +/// Composite key used to store a single juror's vote for a specific dispute. +/// A separate struct is needed because contracttype enums only support single-element +/// tuple variants for storage keys. +#[contracttype] +#[derive(Clone, Debug)] +pub struct VoteKey { + pub escrow_id: u64, + pub juror: Address, +} + +#[contracttype] +pub enum DataKey { + /// Contract administrator + Admin, + /// Address of the stake/settlement token + Token, + /// Slash rate in basis points + SlashBps, + /// Counter for generating unique escrow IDs + EscrowCounter, + /// EscrowRecord keyed by escrow ID + Escrow(u64), + /// DisputeRecord keyed by escrow ID + Dispute(u64), + /// Staked token balance for a juror (i128) + JurorStake(Address), + /// Ordered list of jurors who voted on a dispute (Vec
) + DisputeVoters(u64), + /// A juror's vote direction: true = for depositor, false = for beneficiary + JurorVote(VoteKey), + /// How many times a juror has been slashed (u32) + JurorSlashCount(Address), +} + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum EscrowStatus { + Active, + Disputed, + Settled, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct EscrowRecord { + pub id: u64, + pub depositor: Address, + pub beneficiary: Address, + pub amount: i128, + pub status: EscrowStatus, +} + +#[contracttype] +#[derive(Clone, Debug)] +pub struct DisputeRecord { + pub escrow_id: u64, + pub raised_by: Address, + pub reason: String, + pub resolved: bool, + pub ruling_for_depositor: bool, +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct TrustFlow; + +#[contractimpl] +impl TrustFlow { + // ----------------------------------------------------------------------- + // Initialisation + // ----------------------------------------------------------------------- + + /// Initialise the contract. Must be called once before any other function. + /// + /// * `slash_bps` – basis points deducted from a minority voter's stake + /// each time they are slashed (e.g. `1000` = 10 %). + pub fn initialize(env: Env, admin: Address, token: Address, slash_bps: u32) { + admin.require_auth(); + if slash_bps > 10_000 { + panic!("slash_bps must be <= 10_000"); + } + env.storage().instance().set(&DataKey::Admin, &admin); + env.storage().instance().set(&DataKey::Token, &token); + env.storage().instance().set(&DataKey::SlashBps, &slash_bps); + env.storage().instance().set(&DataKey::EscrowCounter, &0u64); + } + + // ----------------------------------------------------------------------- + // Juror staking + // ----------------------------------------------------------------------- + + /// Stake `amount` tokens. Transfers tokens from `juror` to this contract. + pub fn stake(env: Env, juror: Address, amount: i128) -> Result<(), TrustFlowError> { + juror.require_auth(); + if amount <= 0 { + return Err(TrustFlowError::InvalidAmount); + } + let token: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + token::Client::new(&env, &token).transfer(&juror, &env.current_contract_address(), &amount); + + let prev: i128 = env + .storage() + .persistent() + .get(&DataKey::JurorStake(juror.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::JurorStake(juror), &(prev + amount)); + Ok(()) + } + + /// Withdraw `amount` tokens that have not been slashed. + pub fn unstake(env: Env, juror: Address, amount: i128) -> Result<(), TrustFlowError> { + juror.require_auth(); + let current: i128 = env + .storage() + .persistent() + .get(&DataKey::JurorStake(juror.clone())) + .unwrap_or(0); + if current < amount { + return Err(TrustFlowError::InsufficientStake); + } + let token: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + token::Client::new(&env, &token).transfer(&env.current_contract_address(), &juror, &amount); + env.storage() + .persistent() + .set(&DataKey::JurorStake(juror), &(current - amount)); + Ok(()) + } + + /// Return the current staked balance for `juror`. + pub fn get_stake(env: Env, juror: Address) -> i128 { + env.storage() + .persistent() + .get(&DataKey::JurorStake(juror)) + .unwrap_or(0) + } + + /// Return how many times `juror` has been slashed. + pub fn get_slash_count(env: Env, juror: Address) -> u32 { + env.storage() + .persistent() + .get(&DataKey::JurorSlashCount(juror)) + .unwrap_or(0) + } + + // ----------------------------------------------------------------------- + // Escrow lifecycle + // ----------------------------------------------------------------------- + + /// Create an escrow and lock `amount` tokens from `depositor`. + /// Returns the new escrow ID. + pub fn create_escrow( + env: Env, + depositor: Address, + beneficiary: Address, + amount: i128, + ) -> Result { + depositor.require_auth(); + if amount <= 0 { + return Err(TrustFlowError::InvalidAmount); + } + let token: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + token::Client::new(&env, &token).transfer( + &depositor, + &env.current_contract_address(), + &amount, + ); + + let counter: u64 = env + .storage() + .instance() + .get(&DataKey::EscrowCounter) + .unwrap_or(0); + let id = counter + 1; + env.storage().instance().set(&DataKey::EscrowCounter, &id); + + env.storage().persistent().set( + &DataKey::Escrow(id), + &EscrowRecord { + id, + depositor, + beneficiary, + amount, + status: EscrowStatus::Active, + }, + ); + Ok(id) + } + + // ----------------------------------------------------------------------- + // Dispute management + // ----------------------------------------------------------------------- + + /// Raise a dispute on an active escrow. Only the depositor or beneficiary + /// may call this. + pub fn raise_dispute( + env: Env, + escrow_id: u64, + caller: Address, + reason: String, + ) -> Result<(), TrustFlowError> { + caller.require_auth(); + let mut escrow: EscrowRecord = env + .storage() + .persistent() + .get(&DataKey::Escrow(escrow_id)) + .ok_or(TrustFlowError::EscrowNotFound)?; + + if escrow.status != EscrowStatus::Active { + return Err(TrustFlowError::InvalidState); + } + if caller != escrow.depositor && caller != escrow.beneficiary { + return Err(TrustFlowError::Unauthorized); + } + + escrow.status = EscrowStatus::Disputed; + env.storage() + .persistent() + .set(&DataKey::Escrow(escrow_id), &escrow); + env.storage().persistent().set( + &DataKey::Dispute(escrow_id), + &DisputeRecord { + escrow_id, + raised_by: caller, + reason, + resolved: false, + ruling_for_depositor: false, + }, + ); + Ok(()) + } + + /// Cast a vote on an open dispute. The calling juror must have a positive + /// stake balance before voting. + /// + /// * `vote_for_depositor` – `true` rules in favour of the depositor; + /// `false` rules in favour of the beneficiary. + pub fn cast_vote( + env: Env, + escrow_id: u64, + juror: Address, + vote_for_depositor: bool, + ) -> Result<(), TrustFlowError> { + juror.require_auth(); + + let stake: i128 = env + .storage() + .persistent() + .get(&DataKey::JurorStake(juror.clone())) + .unwrap_or(0); + if stake <= 0 { + return Err(TrustFlowError::InsufficientStake); + } + + let dispute: DisputeRecord = env + .storage() + .persistent() + .get(&DataKey::Dispute(escrow_id)) + .ok_or(TrustFlowError::DisputeNotFound)?; + if dispute.resolved { + return Err(TrustFlowError::DisputeAlreadyResolved); + } + + let vote_key = VoteKey { + escrow_id, + juror: juror.clone(), + }; + if env + .storage() + .persistent() + .has(&DataKey::JurorVote(vote_key.clone())) + { + return Err(TrustFlowError::AlreadyVoted); + } + + env.storage() + .persistent() + .set(&DataKey::JurorVote(vote_key), &vote_for_depositor); + + let mut voters: Vec
= env + .storage() + .persistent() + .get(&DataKey::DisputeVoters(escrow_id)) + .unwrap_or_else(|| vec![&env]); + voters.push_back(juror); + env.storage() + .persistent() + .set(&DataKey::DisputeVoters(escrow_id), &voters); + + Ok(()) + } + + /// Resolve a dispute by tallying juror votes. + /// + /// Jurors whose vote disagrees with the majority ruling are **slashed**: + /// `slash_bps / 10_000` of their current stake is burned from their + /// in-contract balance. In the event of a tie the ruling favours the + /// depositor. + /// + /// Returns `true` if the ruling is for the depositor, `false` otherwise. + pub fn resolve_dispute(env: Env, escrow_id: u64) -> Result { + let mut dispute: DisputeRecord = env + .storage() + .persistent() + .get(&DataKey::Dispute(escrow_id)) + .ok_or(TrustFlowError::DisputeNotFound)?; + if dispute.resolved { + return Err(TrustFlowError::DisputeAlreadyResolved); + } + + let voters: Vec
= env + .storage() + .persistent() + .get(&DataKey::DisputeVoters(escrow_id)) + .unwrap_or_else(|| vec![&env]); + if voters.is_empty() { + return Err(TrustFlowError::NoVotesCast); + } + + // Tally + let mut for_depositor: u32 = 0; + let mut for_beneficiary: u32 = 0; + for voter in voters.iter() { + let vote: bool = env + .storage() + .persistent() + .get(&DataKey::JurorVote(VoteKey { + escrow_id, + juror: voter.clone(), + })) + .unwrap_or(false); + if vote { + for_depositor += 1; + } else { + for_beneficiary += 1; + } + } + + // Tie breaks in favour of the depositor + let ruling = for_depositor >= for_beneficiary; + + // Slash minority voters + let slash_bps: u32 = env + .storage() + .instance() + .get(&DataKey::SlashBps) + .unwrap_or(DEFAULT_SLASH_BPS); + + for voter in voters.iter() { + let vote: bool = env + .storage() + .persistent() + .get(&DataKey::JurorVote(VoteKey { + escrow_id, + juror: voter.clone(), + })) + .unwrap_or(false); + + if vote != ruling { + let stake: i128 = env + .storage() + .persistent() + .get(&DataKey::JurorStake(voter.clone())) + .unwrap_or(0); + // slash_amount = stake * slash_bps / 10_000, saturating at stake + let slash = stake + .checked_mul(slash_bps as i128) + .unwrap_or(stake * DEFAULT_SLASH_BPS as i128) + .checked_div(10_000) + .unwrap_or(0) + .min(stake); + env.storage() + .persistent() + .set(&DataKey::JurorStake(voter.clone()), &(stake - slash)); + + let count: u32 = env + .storage() + .persistent() + .get(&DataKey::JurorSlashCount(voter.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&DataKey::JurorSlashCount(voter.clone()), &(count + 1)); + } + } + + // Finalise dispute + dispute.resolved = true; + dispute.ruling_for_depositor = ruling; + env.storage() + .persistent() + .set(&DataKey::Dispute(escrow_id), &dispute); + + // Settle escrow funds + let mut escrow: EscrowRecord = env + .storage() + .persistent() + .get(&DataKey::Escrow(escrow_id)) + .ok_or(TrustFlowError::EscrowNotFound)?; + let token: Address = env.storage().instance().get(&DataKey::Token).unwrap(); + let token_client = token::Client::new(&env, &token); + + let recipient = if ruling { + escrow.depositor.clone() + } else { + escrow.beneficiary.clone() + }; + token_client.transfer(&env.current_contract_address(), &recipient, &escrow.amount); + + escrow.status = EscrowStatus::Settled; + env.storage() + .persistent() + .set(&DataKey::Escrow(escrow_id), &escrow); + + Ok(ruling) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::{testutils::Address as _, token, Address, Env, String}; + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + fn create_token(env: &Env) -> (Address, token::StellarAssetClient<'_>) { + let admin = Address::random(env); + let contract = env.register_stellar_asset_contract(admin.clone()); + let sac = token::StellarAssetClient::new(env, &contract); + (contract, sac) + } + + fn setup( + env: &Env, + slash_bps: u32, + ) -> (TrustFlowClient<'_>, Address, token::StellarAssetClient<'_>) { + let (token_addr, sac) = create_token(env); + let id = env.register_contract(None, TrustFlow); + let client = TrustFlowClient::new(env, &id); + let admin = Address::random(env); + client.initialize(&admin, &token_addr, &slash_bps); + (client, token_addr, sac) + } + + fn mint(sac: &token::StellarAssetClient, to: &Address, amount: i128) { + sac.mint(to, &amount); + } + + fn balance(env: &Env, token_addr: &Address, addr: &Address) -> i128 { + token::Client::new(env, token_addr).balance(addr) + } + + fn dispute_round( + env: &Env, + client: &TrustFlowClient, + sac: &token::StellarAssetClient, + _token_addr: &Address, + honest_jurors: &[Address], + malicious_juror: &Address, + _slash_bps: u32, + ) -> u64 { + let depositor = Address::random(env); + let beneficiary = Address::random(env); + + mint(sac, &depositor, 1_000); + let escrow_id = client.create_escrow(&depositor, &beneficiary, &1_000); + + client.raise_dispute( + &escrow_id, + &depositor, + &String::from_slice(env, "test dispute"), + ); + + // Majority (honest jurors) votes for depositor + for j in honest_jurors { + client.cast_vote(&escrow_id, j, &true); + } + // Malicious juror votes with minority (against depositor) + client.cast_vote(&escrow_id, malicious_juror, &false); + + client.resolve_dispute(&escrow_id); + escrow_id + } + + // ----------------------------------------------------------------------- + // Basic staking + // ----------------------------------------------------------------------- + + #[test] + fn test_stake_and_unstake() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, token_addr, sac) = setup(&env, DEFAULT_SLASH_BPS); + let juror = Address::random(&env); + mint(&sac, &juror, 500); + + client.stake(&juror, &500); + assert_eq!(client.get_stake(&juror), 500); + assert_eq!(balance(&env, &token_addr, &juror), 0); + + client.unstake(&juror, &200); + assert_eq!(client.get_stake(&juror), 300); + assert_eq!(balance(&env, &token_addr, &juror), 200); + } + + #[test] + fn test_unstake_insufficient_funds() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _token_addr, sac) = setup(&env, DEFAULT_SLASH_BPS); + let juror = Address::random(&env); + mint(&sac, &juror, 100); + client.stake(&juror, &100); + + let result = client.try_unstake(&juror, &200); + assert!(result.is_err()); + } + + #[test] + fn test_stake_zero_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _token_addr, _sac) = setup(&env, DEFAULT_SLASH_BPS); + let juror = Address::random(&env); + + let result = client.try_stake(&juror, &0); + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // Dispute voting + // ----------------------------------------------------------------------- + + #[test] + fn test_cast_vote_requires_stake() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _token_addr, sac) = setup(&env, DEFAULT_SLASH_BPS); + let depositor = Address::random(&env); + let beneficiary = Address::random(&env); + let juror = Address::random(&env); // no stake + + mint(&sac, &depositor, 500); + let escrow_id = client.create_escrow(&depositor, &beneficiary, &500); + client.raise_dispute(&escrow_id, &depositor, &String::from_slice(&env, "test")); + + let result = client.try_cast_vote(&escrow_id, &juror, &true); + assert!(result.is_err()); + } + + #[test] + fn test_cast_vote_duplicate_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _token_addr, sac) = setup(&env, DEFAULT_SLASH_BPS); + let depositor = Address::random(&env); + let beneficiary = Address::random(&env); + let juror = Address::random(&env); + + mint(&sac, &depositor, 500); + mint(&sac, &juror, 200); + client.stake(&juror, &200); + + let escrow_id = client.create_escrow(&depositor, &beneficiary, &500); + client.raise_dispute(&escrow_id, &depositor, &String::from_slice(&env, "test")); + client.cast_vote(&escrow_id, &juror, &true); + + let result = client.try_cast_vote(&escrow_id, &juror, &true); + assert!(result.is_err()); + } + + // ----------------------------------------------------------------------- + // Single round slashing + // ----------------------------------------------------------------------- + + #[test] + fn test_single_round_malicious_juror_slashed() { + let env = Env::default(); + env.mock_all_auths(); + + let slash_bps: u32 = 1_000; // 10% + let (client, _token_addr, sac) = setup(&env, slash_bps); + + let honest1 = Address::random(&env); + let honest2 = Address::random(&env); + let malicious = Address::random(&env); + + mint(&sac, &honest1, 500); + mint(&sac, &honest2, 500); + mint(&sac, &malicious, 1_000); + + client.stake(&honest1, &500); + client.stake(&honest2, &500); + client.stake(&malicious, &1_000); + + let initial_stake = client.get_stake(&malicious); + assert_eq!(initial_stake, 1_000); + + dispute_round( + &env, + &client, + &sac, + &_token_addr, + &[honest1, honest2], + &malicious, + slash_bps, + ); + + let stake_after = client.get_stake(&malicious); + // slashed 10% of 1_000 = 100 + assert_eq!(stake_after, 900); + assert_eq!(client.get_slash_count(&malicious), 1); + } + + #[test] + fn test_honest_jurors_not_slashed() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _token_addr, sac) = setup(&env, 1_000); + + let honest1 = Address::random(&env); + let honest2 = Address::random(&env); + let malicious = Address::random(&env); + + mint(&sac, &honest1, 500); + mint(&sac, &honest2, 500); + mint(&sac, &malicious, 500); + + client.stake(&honest1, &500); + client.stake(&honest2, &500); + client.stake(&malicious, &500); + + dispute_round( + &env, + &client, + &sac, + &_token_addr, + &[honest1.clone(), honest2.clone()], + &malicious, + 1_000, + ); + + // Honest jurors should be untouched + assert_eq!(client.get_stake(&honest1), 500); + assert_eq!(client.get_stake(&honest2), 500); + assert_eq!(client.get_slash_count(&honest1), 0); + assert_eq!(client.get_slash_count(&honest2), 0); + } + + // ----------------------------------------------------------------------- + // Multiple dispute rounds – core acceptance criterion + // ----------------------------------------------------------------------- + + #[test] + fn test_multiple_rounds_malicious_juror_balance_decreases() { + let env = Env::default(); + env.mock_all_auths(); + + let slash_bps: u32 = 1_000; // 10% per round + let (client, token_addr, sac) = setup(&env, slash_bps); + + let honest1 = Address::random(&env); + let honest2 = Address::random(&env); + let malicious = Address::random(&env); + + mint(&sac, &honest1, 10_000); + mint(&sac, &honest2, 10_000); + mint(&sac, &malicious, 10_000); + + client.stake(&honest1, &10_000); + client.stake(&honest2, &10_000); + client.stake(&malicious, &10_000); + + let initial_stake = client.get_stake(&malicious); + + // Round 1 – malicious juror votes against the majority + dispute_round( + &env, + &client, + &sac, + &token_addr, + &[honest1.clone(), honest2.clone()], + &malicious, + slash_bps, + ); + let stake_r1 = client.get_stake(&malicious); + assert!( + stake_r1 < initial_stake, + "stake must decrease after round 1" + ); + assert_eq!(client.get_slash_count(&malicious), 1); + + // Round 2 – malicious juror repeats malicious behaviour + dispute_round( + &env, + &client, + &sac, + &token_addr, + &[honest1.clone(), honest2.clone()], + &malicious, + slash_bps, + ); + let stake_r2 = client.get_stake(&malicious); + assert!( + stake_r2 < stake_r1, + "stake must decrease further after round 2" + ); + assert_eq!(client.get_slash_count(&malicious), 2); + + // Round 3 – a third consecutive malicious vote + dispute_round( + &env, + &client, + &sac, + &token_addr, + &[honest1.clone(), honest2.clone()], + &malicious, + slash_bps, + ); + let stake_r3 = client.get_stake(&malicious); + assert!( + stake_r3 < stake_r2, + "stake must decrease further after round 3" + ); + assert_eq!(client.get_slash_count(&malicious), 3); + + // Verify the compounding slash: after 3 rounds of 10% each the + // remaining stake approximates 10_000 * 0.9^3 = 7_290. + // Due to integer truncation the result is >= 7_290 and < 7_300. + assert!( + stake_r3 >= 7_290 && stake_r3 <= 7_300, + "expected ~7290 after 3×10% slashes, got {stake_r3}" + ); + } + + #[test] + fn test_four_rounds_progressive_slashing() { + let env = Env::default(); + env.mock_all_auths(); + + let slash_bps: u32 = 2_000; // 20% per round + let (client, token_addr, sac) = setup(&env, slash_bps); + + let honest = Address::random(&env); + let malicious = Address::random(&env); + + mint(&sac, &honest, 5_000); + mint(&sac, &malicious, 10_000); + + // honest juror always forms the majority (needs only 1 honest > 0 malicious to win) + // We use 2 honest voters to ensure a clear majority even with 1 malicious voter. + let honest2 = Address::random(&env); + mint(&sac, &honest2, 5_000); + + client.stake(&honest, &5_000); + client.stake(&honest2, &5_000); + client.stake(&malicious, &10_000); + + let mut prev_stake = client.get_stake(&malicious); + + for round in 1u32..=4 { + dispute_round( + &env, + &client, + &sac, + &token_addr, + &[honest.clone(), honest2.clone()], + &malicious, + slash_bps, + ); + let current_stake = client.get_stake(&malicious); + assert!( + current_stake < prev_stake, + "round {round}: stake must decrease, prev={prev_stake} current={current_stake}", + ); + assert_eq!(client.get_slash_count(&malicious), round); + prev_stake = current_stake; + } + } + + // ----------------------------------------------------------------------- + // Tie-breaking + // ----------------------------------------------------------------------- + + #[test] + fn test_tie_favours_depositor() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, token_addr, sac) = setup(&env, 1_000); + + let juror_a = Address::random(&env); // votes for depositor + let juror_b = Address::random(&env); // votes for beneficiary + + mint(&sac, &juror_a, 500); + mint(&sac, &juror_b, 500); + + client.stake(&juror_a, &500); + client.stake(&juror_b, &500); + + let depositor = Address::random(&env); + let beneficiary = Address::random(&env); + mint(&sac, &depositor, 1_000); + + let escrow_id = client.create_escrow(&depositor, &beneficiary, &1_000); + client.raise_dispute( + &escrow_id, + &depositor, + &String::from_slice(&env, "tie-break test"), + ); + client.cast_vote(&escrow_id, &juror_a, &true); // for depositor + client.cast_vote(&escrow_id, &juror_b, &false); // for beneficiary + + let ruling = client.resolve_dispute(&escrow_id); + assert!(ruling, "tie should rule for depositor"); + + // juror_b voted with minority and must be slashed + assert_eq!(client.get_slash_count(&juror_b), 1); + assert!(client.get_stake(&juror_b) < 500); + + // juror_a voted with majority and must be unaffected + assert_eq!(client.get_slash_count(&juror_a), 0); + assert_eq!(client.get_stake(&juror_a), 500); + + // depositor must have received the escrow funds back + assert_eq!(balance(&env, &token_addr, &depositor), 1_000); + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + #[test] + fn test_resolve_dispute_no_votes_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _token_addr, sac) = setup(&env, 1_000); + let depositor = Address::random(&env); + let beneficiary = Address::random(&env); + + mint(&sac, &depositor, 500); + let escrow_id = client.create_escrow(&depositor, &beneficiary, &500); + client.raise_dispute(&escrow_id, &depositor, &String::from_slice(&env, "test")); + + let result = client.try_resolve_dispute(&escrow_id); + assert!(result.is_err()); + } + + #[test] + fn test_resolve_already_resolved_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _token_addr, sac) = setup(&env, 1_000); + let juror = Address::random(&env); + let depositor = Address::random(&env); + let beneficiary = Address::random(&env); + + mint(&sac, &juror, 500); + mint(&sac, &depositor, 500); + + client.stake(&juror, &500); + let escrow_id = client.create_escrow(&depositor, &beneficiary, &500); + client.raise_dispute(&escrow_id, &depositor, &String::from_slice(&env, "test")); + client.cast_vote(&escrow_id, &juror, &true); + client.resolve_dispute(&escrow_id); + + let result = client.try_resolve_dispute(&escrow_id); + assert!(result.is_err()); + } + + #[test] + fn test_slash_cannot_exceed_stake() { + let env = Env::default(); + env.mock_all_auths(); + + // Use 100% slash rate – stake should floor to 0, not go negative + let (client, token_addr, sac) = setup(&env, 10_000); + + let honest = Address::random(&env); + let honest2 = Address::random(&env); + let malicious = Address::random(&env); + + mint(&sac, &honest, 500); + mint(&sac, &honest2, 500); + mint(&sac, &malicious, 300); + + client.stake(&honest, &500); + client.stake(&honest2, &500); + client.stake(&malicious, &300); + + dispute_round( + &env, + &client, + &sac, + &token_addr, + &[honest, honest2], + &malicious, + 10_000, + ); + + // 100% slash: stake should be zero, not negative + assert_eq!(client.get_stake(&malicious), 0); + } +}