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); } - -