diff --git a/contracts/finchippay-contract/src/escrow.rs b/contracts/finchippay-contract/src/escrow.rs
index c4aa3c3b..392acaf0 100644
--- a/contracts/finchippay-contract/src/escrow.rs
+++ b/contracts/finchippay-contract/src/escrow.rs
@@ -8,11 +8,32 @@ use soroban_sdk::{token, Address, Env, Symbol, Vec};
use crate::{
contract_transfer_out, decrease_locked_balance, get_admin, get_token_client,
increase_locked_balance, require_initialized, require_not_paused, require_transfer_succeeded,
- ContractError, DataKey, Escrow, EscrowStatus, MAX_ESCROW_AMOUNT, MAX_ESCROW_LEDGERS,
- MAX_USER_ESCROWS, MIN_ESCROW_AMOUNT,
+ ContractError, DataKey, Escrow, EscrowStatus, ARBITRATOR_SLASH_BPS, DISPUTE_REVIEW_LEDGERS,
+ MAX_APPEAL_DEPTH, MAX_ESCROW_AMOUNT, MAX_ESCROW_LEDGERS, MAX_USER_ESCROWS,
+ MIN_ARBITRATOR_STAKE, MIN_ESCROW_AMOUNT,
};
use crate::storage::*;
+
+fn increase_arbitrator_assignments(env: &Env, arbitrator: &Address) {
+ let key = DataKey::ArbitratorActiveEscrows(arbitrator.clone());
+ let current: u32 = env.storage().persistent().get(&key).unwrap_or(0);
+ let updated = current.checked_add(1).expect("assignment count overflow");
+ env.storage().persistent().set(&key, &updated);
+ bump_to_floor(env, &key);
+}
+
+fn decrease_arbitrator_assignments(env: &Env, arbitrator: &Address) {
+ let key = DataKey::ArbitratorActiveEscrows(arbitrator.clone());
+ let current: u32 = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .expect("Arbitrator assignment count not found");
+ let updated = current.checked_sub(1).expect("assignment count underflow");
+ env.storage().persistent().set(&key, &updated);
+ bump(env, &key);
+}
/// Lock `amount` tokens from `from` until `release_ledger`. Returns the escrow ID.
///
/// Funds are held by the contract itself until `claim_escrow` or `cancel_escrow`.
@@ -86,6 +107,9 @@ pub fn create_escrow(
disputed: false,
dispute_raised_by: Option::None,
dispute_raised_at: 0,
+ appeal_depth: 0,
+ appeal_arbitrator: Option::None,
+ resolution_ledger: 0,
};
env.storage()
@@ -145,6 +169,9 @@ pub fn claim_escrow_partial(env: Env, id: u32, claim_amount: i128) -> i128 {
if escrow.status != EscrowStatus::Pending {
panic!("escrow is not pending");
}
+ if escrow.disputed {
+ panic!("disputed escrow must be resolved by its arbitrator");
+ }
if env.ledger().sequence() < escrow.release_ledger {
panic!("release_ledger not reached");
}
@@ -163,6 +190,9 @@ pub fn claim_escrow_partial(env: Env, id: u32, claim_amount: i128) -> i128 {
let remaining = escrow.amount.checked_sub(claim_amount).expect("overflow");
if remaining == 0 {
escrow.status = EscrowStatus::Released;
+ if let Some(arbitrator) = escrow.arbitrator.clone() {
+ decrease_arbitrator_assignments(&env, &arbitrator);
+ }
}
escrow.amount = remaining;
@@ -229,6 +259,9 @@ pub fn claim_escrow(env: Env, id: u32) {
if escrow.status != EscrowStatus::Pending {
panic!("escrow is not pending");
}
+ if escrow.disputed {
+ panic!("disputed escrow must be resolved by its arbitrator");
+ }
if env.ledger().sequence() < escrow.release_ledger {
panic!("release_ledger not reached");
}
@@ -239,6 +272,9 @@ pub fn claim_escrow(env: Env, id: u32) {
decrease_locked_balance(&env, &escrow.token, escrow.amount);
escrow.status = EscrowStatus::Released;
+ if let Some(arbitrator) = escrow.arbitrator.clone() {
+ decrease_arbitrator_assignments(&env, &arbitrator);
+ }
r_escrows.set(idx, escrow.clone());
env.storage().persistent().set(&rkey, &r_escrows);
bump(&env, &rkey);
@@ -283,6 +319,9 @@ pub fn cancel_escrow(env: Env, id: u32) {
if escrow.status != EscrowStatus::Pending {
panic!("escrow is not pending");
}
+ if escrow.disputed {
+ panic!("disputed escrow must be resolved by its arbitrator");
+ }
if env.ledger().sequence() >= escrow.release_ledger {
panic!("release_ledger already reached — cancellation is no longer allowed");
}
@@ -293,6 +332,9 @@ pub fn cancel_escrow(env: Env, id: u32) {
decrease_locked_balance(&env, &escrow.token, escrow.amount);
escrow.status = EscrowStatus::Cancelled;
+ if let Some(arbitrator) = escrow.arbitrator.clone() {
+ decrease_arbitrator_assignments(&env, &arbitrator);
+ }
r_escrows.set(idx, escrow.clone());
env.storage().persistent().set(&rkey, &r_escrows);
bump(&env, &rkey);
@@ -350,12 +392,46 @@ pub fn escrow_count(env: Env) -> u32 {
// ─── Dispute resolution ──────────────────────────────────────────────────
-pub fn add_arbitrator(env: Env, admin: Address, arbitrator: Address) {
+pub fn add_arbitrator(
+ env: Env,
+ admin: Address,
+ arbitrator: Address,
+ stake_token: Address,
+ stake_amount: i128,
+ tier: u32,
+) {
admin.require_auth();
let stored = get_admin(&env);
if admin != stored {
panic!("Unauthorized");
}
+ register_arbitrator_with_tier(env, arbitrator, stake_amount, stake_token, tier);
+}
+
+/// Register a primary arbitrator by locking the caller's own stake.
+pub fn register_arbitrator(
+ env: Env,
+ arbitrator: Address,
+ stake_amount: i128,
+ stake_token: Address,
+) {
+ register_arbitrator_with_tier(env, arbitrator, stake_amount, stake_token, 0);
+}
+
+fn register_arbitrator_with_tier(
+ env: Env,
+ arbitrator: Address,
+ stake_amount: i128,
+ stake_token: Address,
+ tier: u32,
+) {
+ arbitrator.require_auth();
+ if stake_amount < MIN_ARBITRATOR_STAKE {
+ panic!("Arbitrator stake is below the minimum");
+ }
+ if tier > MAX_APPEAL_DEPTH {
+ panic!("Invalid arbitrator tier");
+ }
let mut arbitrators: Vec
= env
.storage()
@@ -367,6 +443,25 @@ pub fn add_arbitrator(env: Env, admin: Address, arbitrator: Address) {
panic!("Arbitrator already registered");
}
+ let token = get_token_client(&env, &stake_token);
+ let contract = env.current_contract_address();
+ require_transfer_succeeded(&env, &token, &arbitrator, &contract, &stake_amount);
+ increase_locked_balance(&env, &stake_token, stake_amount);
+
+ env.storage()
+ .persistent()
+ .set(&DataKey::ArbitratorStake(arbitrator.clone()), &stake_amount);
+ bump_to_floor(&env, &DataKey::ArbitratorStake(arbitrator.clone()));
+ env.storage().persistent().set(
+ &DataKey::ArbitratorStakeToken(arbitrator.clone()),
+ &stake_token,
+ );
+ bump_to_floor(&env, &DataKey::ArbitratorStakeToken(arbitrator.clone()));
+ env.storage()
+ .persistent()
+ .set(&DataKey::ArbitratorTier(arbitrator.clone()), &tier);
+ bump_to_floor(&env, &DataKey::ArbitratorTier(arbitrator.clone()));
+
arbitrators.push_back(arbitrator.clone());
env.storage()
.persistent()
@@ -379,8 +474,10 @@ pub fn add_arbitrator(env: Env, admin: Address, arbitrator: Address) {
.set(&DataKey::ArbitratorCount, &count);
bump_to_floor(&env, &DataKey::ArbitratorCount);
- env.events()
- .publish((Symbol::new(&env, "arbitrator_added"),), arbitrator);
+ env.events().publish(
+ (Symbol::new(&env, "arbitrator_added"),),
+ (arbitrator, stake_token, stake_amount, tier),
+ );
}
/// Admin: remove an arbitrator from the global arbitrator list.
@@ -400,6 +497,12 @@ pub fn remove_arbitrator(env: Env, admin: Address, arbitrator: Address) {
if !arbitrators.contains(&arbitrator) {
panic!("Arbitrator not found");
}
+ let assignment_key = DataKey::ArbitratorActiveEscrows(arbitrator.clone());
+ let active_escrows: u32 = env.storage().persistent().get(&assignment_key).unwrap_or(0);
+ bump_if_present(&env, &assignment_key);
+ if active_escrows > 0 {
+ panic!("Arbitrator still has active escrows");
+ }
let mut new_list = Vec::new(&env);
for arb in arbitrators.iter() {
@@ -419,8 +522,35 @@ pub fn remove_arbitrator(env: Env, admin: Address, arbitrator: Address) {
.set(&DataKey::ArbitratorCount, &count);
bump_to_floor(&env, &DataKey::ArbitratorCount);
- env.events()
- .publish((Symbol::new(&env, "arbitrator_removed"),), arbitrator);
+ let stake_key = DataKey::ArbitratorStake(arbitrator.clone());
+ let stake: i128 = env
+ .storage()
+ .persistent()
+ .get(&stake_key)
+ .expect("Arbitrator stake not found");
+ bump(&env, &stake_key);
+ let token_key = DataKey::ArbitratorStakeToken(arbitrator.clone());
+ let stake_token: Address = env
+ .storage()
+ .persistent()
+ .get(&token_key)
+ .expect("Arbitrator stake token not found");
+ bump(&env, &token_key);
+
+ let token = get_token_client(&env, &stake_token);
+ contract_transfer_out(&env, &token, &arbitrator, &stake);
+ decrease_locked_balance(&env, &stake_token, stake);
+ env.storage().persistent().remove(&stake_key);
+ env.storage().persistent().remove(&token_key);
+ env.storage()
+ .persistent()
+ .remove(&DataKey::ArbitratorTier(arbitrator.clone()));
+ env.storage().persistent().remove(&assignment_key);
+
+ env.events().publish(
+ (Symbol::new(&env, "arbitrator_removed"),),
+ (arbitrator, stake),
+ );
}
/// Create a disputable escrow with a designated arbitrator.
@@ -458,6 +588,15 @@ pub fn create_disputable_escrow(
if !arbitrators.contains(&arbitrator) {
panic!("Arbitrator is not registered");
}
+ let stake: i128 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::ArbitratorStake(arbitrator.clone()))
+ .unwrap_or(0);
+ bump_if_present(&env, &DataKey::ArbitratorStake(arbitrator.clone()));
+ if stake < MIN_ARBITRATOR_STAKE {
+ panic!("Arbitrator does not have the minimum stake");
+ }
let rkey = DataKey::EscrowByRecipient(to.clone());
let mut r_escrows: Vec = env
@@ -473,6 +612,7 @@ pub fn create_disputable_escrow(
let contract_address = env.current_contract_address();
require_transfer_succeeded(&env, &token, &from, &contract_address, &amount);
increase_locked_balance(&env, &token_address, amount);
+ increase_arbitrator_assignments(&env, &arbitrator);
let next_id: u32 = env
.storage()
@@ -492,6 +632,9 @@ pub fn create_disputable_escrow(
disputed: false,
dispute_raised_by: Option::None,
dispute_raised_at: 0,
+ appeal_depth: 0,
+ appeal_arbitrator: Option::None,
+ resolution_ledger: 0,
};
env.storage()
@@ -569,13 +712,179 @@ pub fn raise_dispute(env: Env, escrow_id: u32, by: Address) {
escrow.disputed = true;
escrow.dispute_raised_by = Some(by.clone());
escrow.dispute_raised_at = env.ledger().sequence();
+ let review_period_end = escrow
+ .dispute_raised_at
+ .checked_add(DISPUTE_REVIEW_LEDGERS)
+ .expect("review period overflow");
+
+ r_escrows.set(idx, escrow);
+ env.storage().persistent().set(&rkey, &r_escrows);
+ bump(&env, &rkey);
+
+ env.events().publish(
+ (Symbol::new(&env, "dispute_raised"),),
+ (escrow_id, by, review_period_end),
+ );
+}
+
+/// Escalate a pending dispute to a registered higher-tier arbitrator.
+pub fn appeal_dispute(env: Env, escrow_id: u32, appellant: Address, appeal_to: Address) {
+ appellant.require_auth();
+
+ let recipient: Address = env
+ .storage()
+ .persistent()
+ .get(&DataKey::EscrowRecipient(escrow_id))
+ .unwrap_or_else(|| panic!("Escrow not found"));
+ bump(&env, &DataKey::EscrowRecipient(escrow_id));
+
+ let rkey = DataKey::EscrowByRecipient(recipient);
+ let mut r_escrows: Vec = env
+ .storage()
+ .persistent()
+ .get(&rkey)
+ .expect("escrow list not found");
+
+ let mut found_index = None;
+ let mut escrow = None;
+ for i in 0..r_escrows.len() {
+ let e = r_escrows.get(i).unwrap();
+ if e.id == escrow_id {
+ found_index = Some(i);
+ escrow = Some(e);
+ break;
+ }
+ }
+
+ let mut escrow = escrow.expect("escrow not found");
+ let idx = found_index.unwrap();
+
+ if escrow.status != EscrowStatus::Pending || !escrow.disputed {
+ panic!("Only an unresolved dispute can be appealed");
+ }
+ if appellant != escrow.from && appellant != escrow.to {
+ panic!("Only escrow participants can appeal");
+ }
+ if escrow.appeal_depth >= MAX_APPEAL_DEPTH {
+ panic!("Maximum appeal depth reached");
+ }
+
+ let now = env.ledger().sequence();
+ let review_period_end = escrow
+ .dispute_raised_at
+ .checked_add(DISPUTE_REVIEW_LEDGERS)
+ .expect("review period overflow");
+ if now >= review_period_end {
+ panic!("Appeal period has elapsed");
+ }
+
+ let current_arbitrator = escrow
+ .arbitrator
+ .clone()
+ .unwrap_or_else(|| panic!("Escrow is not disputable"));
+ if current_arbitrator == appeal_to {
+ panic!("Appeal arbitrator must be different");
+ }
+
+ let arbitrators: Vec = env
+ .storage()
+ .persistent()
+ .get(&DataKey::Arbitrators)
+ .unwrap_or_else(|| panic!("No arbitrators registered"));
+ bump(&env, &DataKey::Arbitrators);
+ if !arbitrators.contains(&appeal_to) {
+ panic!("Appeal arbitrator is not registered");
+ }
+
+ let current_tier = get_arbitrator_tier(env.clone(), current_arbitrator.clone());
+ let appeal_tier = get_arbitrator_tier(env.clone(), appeal_to.clone());
+ if appeal_tier <= current_tier {
+ panic!("Appeals require a higher-tier arbitrator");
+ }
+ let appeal_stake = get_arbitrator_stake(env.clone(), appeal_to.clone());
+ if appeal_stake < MIN_ARBITRATOR_STAKE {
+ panic!("Appeal arbitrator does not have the minimum stake");
+ }
+
+ increase_arbitrator_assignments(&env, &appeal_to);
+ escrow.appeal_depth = escrow.appeal_depth.checked_add(1).expect("appeal overflow");
+ escrow.arbitrator = Some(appeal_to.clone());
+ escrow.appeal_arbitrator = Some(appeal_to.clone());
+ escrow.dispute_raised_at = now;
+ let new_review_period_end = now
+ .checked_add(DISPUTE_REVIEW_LEDGERS)
+ .expect("review period overflow");
+
+ env.storage().persistent().set(
+ &DataKey::AppealedArbitrator(escrow_id, escrow.appeal_depth),
+ ¤t_arbitrator,
+ );
+ bump_to_floor(
+ &env,
+ &DataKey::AppealedArbitrator(escrow_id, escrow.appeal_depth),
+ );
+ env.storage().persistent().set(
+ &DataKey::AppealAppellant(escrow_id, escrow.appeal_depth),
+ &appellant,
+ );
+ bump_to_floor(
+ &env,
+ &DataKey::AppealAppellant(escrow_id, escrow.appeal_depth),
+ );
r_escrows.set(idx, escrow);
env.storage().persistent().set(&rkey, &r_escrows);
bump(&env, &rkey);
- env.events()
- .publish((Symbol::new(&env, "dispute_raised"),), (escrow_id, by));
+ env.events().publish(
+ (Symbol::new(&env, "dispute_appealed"),),
+ (
+ escrow_id,
+ appellant,
+ current_arbitrator,
+ appeal_to,
+ new_review_period_end,
+ ),
+ );
+}
+
+fn slash_arbitrator(env: &Env, arbitrator: &Address, beneficiary: &Address) -> i128 {
+ let stake_key = DataKey::ArbitratorStake(arbitrator.clone());
+ let stake: i128 = env.storage().persistent().get(&stake_key).unwrap_or(0);
+ if stake <= 0 {
+ return 0;
+ }
+ bump(env, &stake_key);
+
+ let slash_amount = stake
+ .checked_mul(ARBITRATOR_SLASH_BPS)
+ .expect("slash amount overflow")
+ / 10_000;
+ if slash_amount <= 0 {
+ return 0;
+ }
+
+ let token_key = DataKey::ArbitratorStakeToken(arbitrator.clone());
+ let stake_token: Address = env
+ .storage()
+ .persistent()
+ .get(&token_key)
+ .expect("Arbitrator stake token not found");
+ bump(env, &token_key);
+
+ let token = get_token_client(env, &stake_token);
+ contract_transfer_out(env, &token, beneficiary, &slash_amount);
+ decrease_locked_balance(env, &stake_token, slash_amount);
+
+ let remaining = stake.checked_sub(slash_amount).expect("stake underflow");
+ env.storage().persistent().set(&stake_key, &remaining);
+ bump(env, &stake_key);
+
+ env.events().publish(
+ (Symbol::new(env, "arbitrator_slashed"),),
+ (arbitrator.clone(), beneficiary.clone(), slash_amount),
+ );
+ slash_amount
}
/// Resolve a dispute. Only the designated arbitrator can call this.
@@ -627,43 +936,77 @@ pub fn resolve_dispute(
if escrow.arbitrator != Some(arbitrator.clone()) {
panic!("Only the designated arbitrator can resolve this dispute");
}
+ let review_period_end = escrow
+ .dispute_raised_at
+ .checked_add(DISPUTE_REVIEW_LEDGERS)
+ .expect("review period overflow");
+ if env.ledger().sequence() < review_period_end {
+ panic!("Review period has not elapsed");
+ }
let client = token::Client::new(&env, &escrow.token);
- let contract = env.current_contract_address();
let transferred;
+ let prevailing_party;
if resolution == Symbol::new(&env, "release") {
- if amount <= 0 || amount > escrow.amount {
+ if to != escrow.to || amount != escrow.amount {
panic!("Invalid release amount");
}
- client.transfer(&contract, &to, &amount);
+ contract_transfer_out(&env, &client, &escrow.to, &amount);
transferred = amount;
+ prevailing_party = Some(escrow.to.clone());
} else if resolution == Symbol::new(&env, "refund") {
// Refund the full escrow amount to the original sender
- client.transfer(&contract, &escrow.from, &escrow.amount);
+ contract_transfer_out(&env, &client, &escrow.from, &escrow.amount);
transferred = escrow.amount;
+ prevailing_party = Some(escrow.from.clone());
} else if resolution == Symbol::new(&env, "split") {
- if amount <= 0 || amount >= escrow.amount {
+ if to != escrow.to || amount <= 0 || amount >= escrow.amount {
panic!("Invalid split amount");
}
- let refund = escrow.amount - amount;
- client.transfer(&contract, &to, &amount);
- client.transfer(&contract, &escrow.from, &refund);
+ let refund = escrow.amount.checked_sub(amount).expect("split underflow");
+ contract_transfer_out(&env, &client, &escrow.to, &amount);
+ contract_transfer_out(&env, &client, &escrow.from, &refund);
transferred = escrow.amount;
+ prevailing_party = Option::None;
} else {
panic!("Invalid resolution type");
}
escrow.status = EscrowStatus::Released;
+ escrow.resolution_ledger = env.ledger().sequence();
decrease_locked_balance(&env, &escrow.token, transferred);
+ decrease_arbitrator_assignments(&env, &arbitrator);
+ let appeal_depth = escrow.appeal_depth;
r_escrows.set(idx, escrow);
env.storage().persistent().set(&rkey, &r_escrows);
bump(&env, &rkey);
+ let mut slashed: i128 = 0;
+ for depth in 1..=appeal_depth {
+ let appellant_key = DataKey::AppealAppellant(escrow_id, depth);
+ let displaced_key = DataKey::AppealedArbitrator(escrow_id, depth);
+ let appellant: Option = env.storage().persistent().get(&appellant_key);
+ let displaced: Option = env.storage().persistent().get(&displaced_key);
+ bump_if_present(&env, &appellant_key);
+ bump_if_present(&env, &displaced_key);
+
+ if let Some(displaced) = displaced {
+ if let (Some(winner), Some(appellant)) = (&prevailing_party, &appellant) {
+ if appellant == winner {
+ slashed = slashed
+ .checked_add(slash_arbitrator(&env, &displaced, winner))
+ .expect("total slash overflow");
+ }
+ }
+ decrease_arbitrator_assignments(&env, &displaced);
+ }
+ }
+
env.events().publish(
(Symbol::new(&env, "dispute_resolved"),),
- (escrow_id, resolution, to, amount),
+ (escrow_id, resolution, to, amount, slashed),
);
}
@@ -678,3 +1021,23 @@ pub fn get_arbitrators(env: Env) -> Vec {
bump_if_present(&env, &key);
arbitrators
}
+
+/// Return an arbitrator's remaining locked stake.
+pub fn get_arbitrator_stake(env: Env, arbitrator: Address) -> i128 {
+ let key = DataKey::ArbitratorStake(arbitrator);
+ let stake = env.storage().persistent().get(&key).unwrap_or(0);
+ bump_if_present(&env, &key);
+ stake
+}
+
+/// Return an arbitrator's registered hierarchy tier.
+pub fn get_arbitrator_tier(env: Env, arbitrator: Address) -> u32 {
+ let key = DataKey::ArbitratorTier(arbitrator);
+ let tier = env
+ .storage()
+ .persistent()
+ .get(&key)
+ .unwrap_or_else(|| panic!("Arbitrator tier not found"));
+ bump(&env, &key);
+ tier
+}
diff --git a/contracts/finchippay-contract/src/lib.rs b/contracts/finchippay-contract/src/lib.rs
index 7221c3db..e8cd13a2 100644
--- a/contracts/finchippay-contract/src/lib.rs
+++ b/contracts/finchippay-contract/src/lib.rs
@@ -187,6 +187,12 @@ pub struct Escrow {
pub dispute_raised_by: Option,
/// Ledger at which the dispute was raised.
pub dispute_raised_at: u32,
+ /// Number of times the dispute has been escalated.
+ pub appeal_depth: u32,
+ /// Current appellate arbitrator, when the dispute has been appealed.
+ pub appeal_arbitrator: Option,
+ /// Ledger at which the final resolution was executed.
+ pub resolution_ledger: u32,
}
/// Maximum number of escrows tracked per recipient index (prevents state bloat).
@@ -400,6 +406,14 @@ const MAX_ESCROW_AMOUNT: i128 = 1_000_000_000_000_000_000;
const MAX_MULTISIG_AMOUNT: i128 = 1_000_000_000_000_000_000;
/// Minimum amount for a single escrow deposit (prevents dust attacks).
const MIN_ESCROW_AMOUNT: i128 = 1_000;
+/// Mandatory review delay before a disputed escrow can be resolved.
+pub const DISPUTE_REVIEW_LEDGERS: u32 = 1_000;
+/// Maximum number of appellate escalations for one dispute.
+pub const MAX_APPEAL_DEPTH: u32 = 2;
+/// Minimum stake required to register an arbitrator.
+pub const MIN_ARBITRATOR_STAKE: i128 = 100_000_000;
+/// Share of a displaced arbitrator's remaining stake paid on a successful appeal.
+pub const ARBITRATOR_SLASH_BPS: i128 = 5_000;
/// Minimum amount for a single multi-sig proposal.
const MIN_MULTISIG_AMOUNT: i128 = 1_000;
/// Maximum signers allowed in a multi-sig proposal.
@@ -425,7 +439,7 @@ const MAX_ADMIN_SIGNERS: u32 = 20;
/// or any persistent struct field layout changes. The admin must call
/// `validate_storage_compatibility` before upgrading to ensure the new WASM
/// declares a layout version >= this value, preventing bricked storage.
-const STORAGE_LAYOUT_VERSION: u32 = 3;
+const STORAGE_LAYOUT_VERSION: u32 = 4;
// ─── Storage TTL classes ──────────────────────────────────────────────────────
@@ -509,6 +523,18 @@ pub enum DataKey {
ArbitratorCount,
/// Registered arbitrators for disputable escrows.
Arbitrators,
+ /// Amount of stake currently locked for an arbitrator.
+ ArbitratorStake(Address),
+ /// Token contract used for an arbitrator's stake.
+ ArbitratorStakeToken(Address),
+ /// Hierarchy tier assigned to an arbitrator.
+ ArbitratorTier(Address),
+ /// Number of unsettled disputable escrows assigned to an arbitrator.
+ ArbitratorActiveEscrows(Address),
+ /// Arbitrator displaced at a specific appeal depth for an escrow.
+ AppealedArbitrator(u32, u32),
+ /// Participant who filed an appeal at a specific depth for an escrow.
+ AppealAppellant(u32, u32),
// Streaming
StreamCount,
Stream(u32),
@@ -2232,9 +2258,26 @@ impl FinchippayContract {
// ─── Dispute resolution ──────────────────────────────────────────────────
- /// Admin: add an arbitrator to the global arbitrator list.
- pub fn add_arbitrator(env: Env, admin: Address, arbitrator: Address) {
- escrow::add_arbitrator(env, admin, arbitrator)
+ /// Admin: register a tiered arbitrator and lock their stake.
+ pub fn add_arbitrator(
+ env: Env,
+ admin: Address,
+ arbitrator: Address,
+ stake_token: Address,
+ stake_amount: i128,
+ tier: u32,
+ ) {
+ escrow::add_arbitrator(env, admin, arbitrator, stake_token, stake_amount, tier)
+ }
+
+ /// Register a primary arbitrator by locking the caller's own stake.
+ pub fn register_arbitrator(
+ env: Env,
+ arbitrator: Address,
+ stake_amount: i128,
+ stake_token: Address,
+ ) {
+ escrow::register_arbitrator(env, arbitrator, stake_amount, stake_token)
}
/// Admin: remove an arbitrator from the global arbitrator list.
@@ -2270,6 +2313,16 @@ impl FinchippayContract {
escrow::raise_dispute(env, escrow_id, by)
}
+ /// Escalate a dispute to a registered higher-tier arbitrator.
+ pub fn appeal_dispute(
+ env: Env,
+ escrow_id: u32,
+ appellant: Address,
+ appeal_to: Address,
+ ) {
+ escrow::appeal_dispute(env, escrow_id, appellant, appeal_to)
+ }
+
/// Resolve a dispute. Only the designated arbitrator can call this.
/// Resolution types: "release" (to recipient), "refund" (to sender),
/// "split" (amount to recipient, rest to sender).
@@ -2289,6 +2342,16 @@ impl FinchippayContract {
escrow::get_arbitrators(env)
}
+ /// Return an arbitrator's remaining locked stake.
+ pub fn get_arbitrator_stake(env: Env, arbitrator: Address) -> i128 {
+ escrow::get_arbitrator_stake(env, arbitrator)
+ }
+
+ /// Return an arbitrator's registered hierarchy tier.
+ pub fn get_arbitrator_tier(env: Env, arbitrator: Address) -> u32 {
+ escrow::get_arbitrator_tier(env, arbitrator)
+ }
+
// ─── Streaming payments ───────────────────────────────────────────────────
/// Open a new payment stream. `payer` deposits `deposit` tokens that will
diff --git a/contracts/finchippay-contract/src/storage.rs b/contracts/finchippay-contract/src/storage.rs
index 1537c80f..4079d936 100644
--- a/contracts/finchippay-contract/src/storage.rs
+++ b/contracts/finchippay-contract/src/storage.rs
@@ -136,7 +136,7 @@ pub fn ttl_class_symbol(env: &Env, class: &TtlClass) -> Symbol {
/// counter itself, so the count is `1 + items` and is never zero.
pub fn ttl_class_len(env: &Env, class: &TtlClass) -> u32 {
match class {
- TtlClass::Config => TTL_CONFIG_KEYS,
+ TtlClass::Config => TTL_CONFIG_KEYS + counter(env, &DataKey::ArbitratorCount),
TtlClass::Receipts => 1 + counter(env, &DataKey::TotalReceiptCount),
TtlClass::Escrows => 1 + counter(env, &DataKey::EscrowCount),
TtlClass::Streams => 1 + counter(env, &DataKey::StreamCount),
@@ -168,7 +168,21 @@ pub fn bump_config_key(env: &Env, index: u32) -> u32 {
5 => bump_to_floor_if_present(env, &DataKey::AdminSigners),
6 => bump_to_floor_if_present(env, &DataKey::AdminSignersThreshold),
7 => bump_to_floor_if_present(env, &DataKey::Arbitrators),
- _ => bump_to_floor_if_present(env, &DataKey::ArbitratorCount),
+ 8 => bump_to_floor_if_present(env, &DataKey::ArbitratorCount),
+ _ => {
+ let arbitrators: Option> =
+ env.storage().persistent().get(&DataKey::Arbitrators);
+ let arbitrator = match arbitrators
+ .and_then(|items| items.get(index.saturating_sub(TTL_CONFIG_KEYS)))
+ {
+ Some(arbitrator) => arbitrator,
+ None => return 0,
+ };
+ bump_to_floor_if_present(env, &DataKey::ArbitratorStake(arbitrator.clone()))
+ + bump_to_floor_if_present(env, &DataKey::ArbitratorStakeToken(arbitrator.clone()))
+ + bump_to_floor_if_present(env, &DataKey::ArbitratorTier(arbitrator.clone()))
+ + bump_to_floor_if_present(env, &DataKey::ArbitratorActiveEscrows(arbitrator))
+ }
}
}
@@ -208,7 +222,12 @@ pub fn bump_ttl_class_item(env: &Env, class: &TtlClass, index: u32) -> u32 {
match recipient {
Some(recipient) => {
bump_to_floor(env, &recipient_key);
+ let escrow_id = index - 1;
1 + bump_to_floor_if_present(env, &DataKey::EscrowByRecipient(recipient))
+ + bump_to_floor_if_present(env, &DataKey::AppealedArbitrator(escrow_id, 1))
+ + bump_to_floor_if_present(env, &DataKey::AppealAppellant(escrow_id, 1))
+ + bump_to_floor_if_present(env, &DataKey::AppealedArbitrator(escrow_id, 2))
+ + bump_to_floor_if_present(env, &DataKey::AppealAppellant(escrow_id, 2))
}
None => 0,
}
diff --git a/contracts/finchippay-contract/tests/integration.rs b/contracts/finchippay-contract/tests/integration.rs
index 4981a770..4f757da5 100644
--- a/contracts/finchippay-contract/tests/integration.rs
+++ b/contracts/finchippay-contract/tests/integration.rs
@@ -2,6 +2,7 @@
use finchippay_contract::{
ContractError, EscrowStatus, FinchippayContract, FinchippayContractClient, MultiSigStatus,
+ DISPUTE_REVIEW_LEDGERS, MIN_ARBITRATOR_STAKE,
};
use soroban_sdk::{
testutils::{Address as _, Events, Ledger},
@@ -44,6 +45,25 @@ fn advance_ledger(env: &Env, to: u32) {
env.ledger().with_mut(|l| l.sequence_number = to);
}
+fn register_arbitrator(
+ env: &Env,
+ client: &FinchippayContractClient<'_>,
+ admin: &Address,
+ token_id: &Address,
+ arbitrator: &Address,
+ tier: u32,
+) {
+ token::StellarAssetClient::new(env, token_id)
+ .mint(arbitrator, &MIN_ARBITRATOR_STAKE);
+ client.add_arbitrator(
+ admin,
+ arbitrator,
+ token_id,
+ &MIN_ARBITRATOR_STAKE,
+ &tier,
+ );
+}
+
// ─── Admin & Initialization ─────────────────────────────────────────────────
#[test]
@@ -480,6 +500,463 @@ fn test_claim_escrow_partial() {
assert_eq!(escrow.status, EscrowStatus::Pending);
}
+// ─── Escrow Disputes ────────────────────────────────────────────────────────
+
+#[test]
+fn test_arbitrator_registration_requires_minimum_stake() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let arbitrator = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &arbitrator, MIN_ARBITRATOR_STAKE);
+ let result = client.try_add_arbitrator(
+ &admin,
+ &arbitrator,
+ &token_id,
+ &(MIN_ARBITRATOR_STAKE - 1),
+ &0,
+ );
+
+ assert!(result.is_err());
+ assert_eq!(client.get_arbitrator_stake(&arbitrator), 0);
+}
+
+#[test]
+fn test_arbitrator_registration_locks_stake_and_tier() {
+ let env = Env::default();
+ let (contract_id, client) = deploy(&env);
+ let admin = client.get_admin();
+ let arbitrator = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &arbitrator, MIN_ARBITRATOR_STAKE);
+ client.add_arbitrator(
+ &admin,
+ &arbitrator,
+ &token_id,
+ &MIN_ARBITRATOR_STAKE,
+ &1,
+ );
+
+ assert_eq!(
+ client.get_arbitrator_stake(&arbitrator),
+ MIN_ARBITRATOR_STAKE
+ );
+ assert_eq!(client.get_arbitrator_tier(&arbitrator), 1);
+ assert_eq!(
+ token::Client::new(&env, &token_id).balance(&contract_id),
+ MIN_ARBITRATOR_STAKE
+ );
+}
+
+#[test]
+fn test_self_registration_creates_primary_arbitrator() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let arbitrator = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &arbitrator, MIN_ARBITRATOR_STAKE);
+ client.register_arbitrator(&arbitrator, &MIN_ARBITRATOR_STAKE, &token_id);
+
+ assert_eq!(client.get_arbitrator_tier(&arbitrator), 0);
+ assert_eq!(
+ client.get_arbitrator_stake(&arbitrator),
+ MIN_ARBITRATOR_STAKE
+ );
+}
+
+#[test]
+fn test_arbitrator_cannot_unstake_with_active_escrow() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let arbitrator = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &arbitrator, 0);
+ let release = env.ledger().sequence() + 10_000;
+ client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &arbitrator);
+
+ assert!(client
+ .try_remove_arbitrator(&admin, &arbitrator)
+ .is_err());
+}
+
+#[test]
+fn test_dispute_resolution_requires_review_period() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let arbitrator = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &arbitrator, 0);
+ let release = env.ledger().sequence() + 10_000;
+ let id =
+ client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &arbitrator);
+ client.raise_dispute(&id, &from);
+
+ let result = client.try_resolve_dispute(
+ &id,
+ &arbitrator,
+ &Symbol::new(&env, "release"),
+ &to,
+ &2_000,
+ );
+ assert!(result.is_err());
+
+ let raised_at = client.get_escrow(&id).dispute_raised_at;
+ advance_ledger(&env, raised_at + DISPUTE_REVIEW_LEDGERS);
+ client.resolve_dispute(
+ &id,
+ &arbitrator,
+ &Symbol::new(&env, "release"),
+ &to,
+ &2_000,
+ );
+ assert_eq!(client.get_escrow(&id).resolution_ledger, env.ledger().sequence());
+}
+
+#[test]
+fn test_sender_can_appeal_to_higher_tier() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let appellate = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &appellate, 1);
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &to);
+ client.appeal_dispute(&id, &from, &appellate);
+
+ let escrow = client.get_escrow(&id);
+ assert_eq!(escrow.arbitrator, Some(appellate.clone()));
+ assert_eq!(escrow.appeal_arbitrator, Some(appellate));
+ assert_eq!(escrow.appeal_depth, 1);
+}
+
+#[test]
+fn test_recipient_can_appeal_to_higher_tier() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let appellate = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &appellate, 1);
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &from);
+ client.appeal_dispute(&id, &to, &appellate);
+
+ assert_eq!(client.get_escrow(&id).appeal_depth, 1);
+}
+
+#[test]
+fn test_appeal_rejects_same_arbitrator() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let arbitrator = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &arbitrator, 0);
+ let release = env.ledger().sequence() + 10_000;
+ let id =
+ client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &arbitrator);
+ client.raise_dispute(&id, &from);
+
+ assert!(client
+ .try_appeal_dispute(&id, &from, &arbitrator)
+ .is_err());
+}
+
+#[test]
+fn test_appeal_requires_registered_higher_tier_arbitrator() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let peer = Address::generate(&env);
+ let unregistered = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &peer, 0);
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &from);
+
+ assert!(client
+ .try_appeal_dispute(&id, &from, &unregistered)
+ .is_err());
+ assert!(client.try_appeal_dispute(&id, &from, &peer).is_err());
+}
+
+#[test]
+fn test_appeal_resets_review_period() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let appellate = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &appellate, 1);
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &from);
+
+ let initial_raised_at = client.get_escrow(&id).dispute_raised_at;
+ advance_ledger(&env, initial_raised_at + DISPUTE_REVIEW_LEDGERS - 1);
+ client.appeal_dispute(&id, &from, &appellate);
+ let appealed_at = client.get_escrow(&id).dispute_raised_at;
+
+ assert!(client
+ .try_resolve_dispute(
+ &id,
+ &appellate,
+ &Symbol::new(&env, "refund"),
+ &from,
+ &2_000,
+ )
+ .is_err());
+
+ advance_ledger(&env, appealed_at + DISPUTE_REVIEW_LEDGERS);
+ client.resolve_dispute(
+ &id,
+ &appellate,
+ &Symbol::new(&env, "refund"),
+ &from,
+ &2_000,
+ );
+}
+
+#[test]
+fn test_appeal_rejects_after_review_window() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let appellate = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &appellate, 1);
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &from);
+
+ let raised_at = client.get_escrow(&id).dispute_raised_at;
+ advance_ledger(&env, raised_at + DISPUTE_REVIEW_LEDGERS);
+ assert!(client
+ .try_appeal_dispute(&id, &from, &appellate)
+ .is_err());
+}
+
+#[test]
+fn test_appeal_depth_is_limited_to_two() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let appellate = Address::generate(&env);
+ let final_arbitrator = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &appellate, 1);
+ register_arbitrator(
+ &env,
+ &client,
+ &admin,
+ &token_id,
+ &final_arbitrator,
+ 2,
+ );
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &from);
+ client.appeal_dispute(&id, &from, &appellate);
+ client.appeal_dispute(&id, &to, &final_arbitrator);
+
+ assert_eq!(client.get_escrow(&id).appeal_depth, 2);
+ assert!(client
+ .try_appeal_dispute(&id, &from, &primary)
+ .is_err());
+}
+
+#[test]
+fn test_disputed_escrow_cannot_bypass_arbitration() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let arbitrator = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &arbitrator, 0);
+ let release = env.ledger().sequence() + 10;
+ let id =
+ client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &arbitrator);
+ client.raise_dispute(&id, &from);
+
+ assert!(client.try_cancel_escrow(&id).is_err());
+ advance_ledger(&env, release);
+ assert!(client.try_claim_escrow(&id).is_err());
+}
+
+#[test]
+fn test_successful_appeal_slashes_displaced_arbitrator() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let appellate = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &appellate, 1);
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &to);
+ client.appeal_dispute(&id, &from, &appellate);
+
+ let appealed_at = client.get_escrow(&id).dispute_raised_at;
+ advance_ledger(&env, appealed_at + DISPUTE_REVIEW_LEDGERS);
+ client.resolve_dispute(
+ &id,
+ &appellate,
+ &Symbol::new(&env, "refund"),
+ &from,
+ &2_000,
+ );
+
+ let slash_amount = MIN_ARBITRATOR_STAKE / 2;
+ assert_eq!(
+ client.get_arbitrator_stake(&primary),
+ MIN_ARBITRATOR_STAKE - slash_amount
+ );
+ assert_eq!(
+ token::Client::new(&env, &token_id).balance(&from),
+ 10_000 + slash_amount
+ );
+}
+
+#[test]
+fn test_displaced_arbitrator_stake_remains_locked_until_resolution() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let appellate = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &appellate, 1);
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &to);
+ client.appeal_dispute(&id, &from, &appellate);
+
+ assert!(client.try_remove_arbitrator(&admin, &primary).is_err());
+
+ let appealed_at = client.get_escrow(&id).dispute_raised_at;
+ advance_ledger(&env, appealed_at + DISPUTE_REVIEW_LEDGERS);
+ client.resolve_dispute(
+ &id,
+ &appellate,
+ &Symbol::new(&env, "refund"),
+ &from,
+ &2_000,
+ );
+
+ client.remove_arbitrator(&admin, &primary);
+ assert_eq!(client.get_arbitrator_stake(&primary), 0);
+}
+
+#[test]
+fn test_unsuccessful_appeal_does_not_slash_arbitrator() {
+ let env = Env::default();
+ let (_, client) = deploy(&env);
+ let admin = client.get_admin();
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let primary = Address::generate(&env);
+ let appellate = Address::generate(&env);
+ env.mock_all_auths();
+
+ let token_id = create_token(&env, &admin, &from, 10_000);
+ register_arbitrator(&env, &client, &admin, &token_id, &primary, 0);
+ register_arbitrator(&env, &client, &admin, &token_id, &appellate, 1);
+ let release = env.ledger().sequence() + 10_000;
+ let id = client.create_disputable_escrow(&token_id, &from, &to, &2_000, &release, &primary);
+ client.raise_dispute(&id, &to);
+ client.appeal_dispute(&id, &from, &appellate);
+
+ let appealed_at = client.get_escrow(&id).dispute_raised_at;
+ advance_ledger(&env, appealed_at + DISPUTE_REVIEW_LEDGERS);
+ client.resolve_dispute(
+ &id,
+ &appellate,
+ &Symbol::new(&env, "release"),
+ &to,
+ &2_000,
+ );
+
+ assert_eq!(
+ client.get_arbitrator_stake(&primary),
+ MIN_ARBITRATOR_STAKE
+ );
+ assert_eq!(token::Client::new(&env, &token_id).balance(&from), 8_000);
+}
+
// ─── Stream Flow ─────────────────────────────────────────────────────────────
#[test]
@@ -1239,5 +1716,3 @@ fn test_approve_emergency_withdrawal() {
let withdrawal = client.get_emergency_withdrawal(&wid);
assert_eq!(withdrawal.approvals.len(), 1);
}
-
-