From b6428bd53d7c8e94ef18315dba78ce80203b54da Mon Sep 17 00:00:00 2001 From: iheomadev Date: Mon, 1 Jun 2026 16:51:02 +0000 Subject: [PATCH] feat: implement swap escrow with arbitration (#467) - Add SwapArbitrator(u64) and DisputeEvidence(u64) to DataKey enum - Add arbitrator: Option
field to SwapRecord - Add ArbitratorAlreadySet, NotArbitrator, NoArbitratorSet error codes - Implement set_arbitrator: admin assigns arbitrator to disputed swap - Implement arbitrate_dispute: arbitrator resolves dispute (refund or complete) - Implement submit_dispute_evidence: buyer/seller submit evidence hashes - Implement get_dispute_evidence: query all evidence for a swap - Fix arbitrate_swap to use SwapArbitrator DataKey for verification - Enable arbitration_tests and escrow_tests modules - Fix initiate_swap call signatures in test files to match 10-param API --- .../atomic_swap/src/arbitration_tests.rs | 16 +- contracts/atomic_swap/src/escrow_tests.rs | 2 +- contracts/atomic_swap/src/lib.rs | 164 ++++++++++++++++-- 3 files changed, 160 insertions(+), 22 deletions(-) diff --git a/contracts/atomic_swap/src/arbitration_tests.rs b/contracts/atomic_swap/src/arbitration_tests.rs index 89d36de..2fb97fd 100644 --- a/contracts/atomic_swap/src/arbitration_tests.rs +++ b/contracts/atomic_swap/src/arbitration_tests.rs @@ -39,7 +39,7 @@ mod arbitration_tests { let client = AtomicSwapClient::new(env, &contract_id); client.initialize(®istry_id); - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false); client.accept_swap(&swap_id); client.raise_dispute(&swap_id); @@ -95,7 +95,7 @@ mod arbitration_tests { let client = AtomicSwapClient::new(&env, &contract_id); client.initialize(®istry_id); - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false); // Swap is Pending, not Disputed — should panic let admin = Address::generate(&env); let arbitrator = Address::generate(&env); @@ -253,7 +253,7 @@ mod arbitration_tests { client.initialize(®istry_id); // Initiate with flat price 500 - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false); // Accept with quantity=1 (no tiers set, uses flat price) client.accept_swap_with_quantity(&swap_id, &1_u32); @@ -278,7 +278,7 @@ mod arbitration_tests { let client = AtomicSwapClient::new(&env, &contract_id); client.initialize(®istry_id); - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &1000_i128, &buyer, &0_u32); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &1000_i128, &buyer, &0_u32, &None, &0_i128, &false); client.accept_swap_with_quantity(&swap_id, &5_u32); let swap = client.get_swap(&swap_id).unwrap(); @@ -306,7 +306,7 @@ mod arbitration_tests { // Initiate with price=1000, default quantity=1 — set quantity via initiate_swap // then manually bump quantity to 10 by accepting partial - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &1000_i128, &buyer, &0_u32); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &1000_i128, &buyer, &0_u32, &None, &0_i128, &false); // Patch quantity to 10 so partial acceptance makes sense let mut swap = client.get_swap(&swap_id).unwrap(); @@ -342,7 +342,7 @@ mod arbitration_tests { let client = AtomicSwapClient::new(&env, &contract_id); client.initialize(®istry_id); - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false); // quantity=1 (default), accepting 1/1 = full price client.accept_swap_partial(&swap_id, &1_u32); @@ -368,7 +368,7 @@ mod arbitration_tests { let client = AtomicSwapClient::new(&env, &contract_id); client.initialize(®istry_id); - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false); client.accept_swap_partial(&swap_id, &0_u32); } @@ -388,7 +388,7 @@ mod arbitration_tests { let client = AtomicSwapClient::new(&env, &contract_id); client.initialize(®istry_id); - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false); // quantity=1 by default, requesting 2 should panic client.accept_swap_partial(&swap_id, &2_u32); } diff --git a/contracts/atomic_swap/src/escrow_tests.rs b/contracts/atomic_swap/src/escrow_tests.rs index a1b94a8..b135011 100644 --- a/contracts/atomic_swap/src/escrow_tests.rs +++ b/contracts/atomic_swap/src/escrow_tests.rs @@ -140,7 +140,7 @@ mod tests { let client = AtomicSwapClient::new(&env, &contract_id); // Regular atomic swap - let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None); + let swap_id = client.initiate_swap(&token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false); // escrow_deposit on an atomic swap must panic client.escrow_deposit(&swap_id); diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index 684dd0c..0917928 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -70,6 +70,12 @@ pub enum ContractError { OraclePriceInvalid = 47, OraclePriceBelowMin = 48, OraclePriceAboveMax = 49, + /// #314: Arbitration errors + ArbitratorAlreadySet = 35, + NotArbitrator = 36, + NoArbitratorSet = 37, + /// #313: Dispute evidence errors + UnauthorizedEvidenceSubmitter = 38, } // ── TTL ─────────────────────────────────────────────────────────────────────── @@ -159,6 +165,10 @@ pub enum DataKey { CompletionTimestamp(u64), /// #470: Price oracle configuration (oracle contract address + enabled flag). OracleConfig, + /// #314: Maps swap_id → arbitrator Address for dispute resolution. + SwapArbitrator(u64), + /// #313: Maps swap_id → Vec> of dispute evidence hashes. + DisputeEvidence(u64), } // ── Types ───────────────────────────────────────────────────────────────────── @@ -227,6 +237,8 @@ pub struct SwapRecord { pub paid_amount: i128, /// #installments: Whether this swap uses an installment payment schedule. pub is_installment: bool, + /// #314: Optional arbitrator address for dispute resolution. + pub arbitrator: Option
, } // ── Events ──────────────────────────────────────────────────────────────────── @@ -395,6 +407,7 @@ impl AtomicSwap { conditions: Vec::new(&env), paid_amount: 0, is_installment: false, + arbitrator: None, }; // Store insurance premium in dedicated key so accept_swap can collect it @@ -1089,9 +1102,130 @@ impl AtomicSwap { // ── #314: Third-Party Arbitration ───────────────────────────────────────── - // set_arbitrator removed - arbitrator field not in SwapRecord + /// Admin sets an arbitrator for a disputed swap. Can only be set once. + pub fn set_arbitrator(env: Env, swap_id: u64, admin: Address, arbitrator: Address) { + admin.require_auth(); + require_admin(&env, &admin); + + let mut swap = require_swap_exists(&env, swap_id); + require_swap_status(&env, &swap, SwapStatus::Disputed, ContractError::NotDisputed); + + if env.storage().persistent().has(&DataKey::SwapArbitrator(swap_id)) { + env.panic_with_error(Error::from_contract_error( + ContractError::ArbitratorAlreadySet as u32, + )); + } + + env.storage() + .persistent() + .set(&DataKey::SwapArbitrator(swap_id), &arbitrator); + env.storage() + .persistent() + .extend_ttl(&DataKey::SwapArbitrator(swap_id), LEDGER_BUMP, LEDGER_BUMP); + + swap.arbitrator = Some(arbitrator.clone()); + swap::save_swap(&env, swap_id, &swap); + + env.events().publish( + (soroban_sdk::symbol_short!("arb_set"),), + ArbitratorSetEvent { swap_id, arbitrator }, + ); + } + + /// Arbitrator resolves a disputed swap. refund=true refunds buyer; false completes to seller. + pub fn arbitrate_dispute(env: Env, swap_id: u64, arbitrator: Address, refund: bool) { + arbitrator.require_auth(); + + let stored_arbitrator: Address = env + .storage() + .persistent() + .get(&DataKey::SwapArbitrator(swap_id)) + .unwrap_or_else(|| { + env.panic_with_error(Error::from_contract_error( + ContractError::NoArbitratorSet as u32, + )) + }); - // arbitrate_dispute removed - arbitrator field not in SwapRecord + if arbitrator != stored_arbitrator { + env.panic_with_error(Error::from_contract_error( + ContractError::NotArbitrator as u32, + )); + } + + let mut swap = require_swap_exists(&env, swap_id); + require_swap_status(&env, &swap, SwapStatus::Disputed, ContractError::NotDisputed); + + let token_client = token::Client::new(&env, &swap.token); + + if refund { + token_client.transfer(&env.current_contract_address(), &swap.buyer, &swap.price); + swap.status = SwapStatus::Cancelled; + } else { + let config = Self::protocol_config(&env); + let fee_amount = if config.protocol_fee_bps > 0 { + (swap.price * config.protocol_fee_bps as i128) / 10000 + } else { + 0 + }; + let seller_amount = swap.price - fee_amount; + if fee_amount > 0 { + token_client.transfer(&env.current_contract_address(), &config.treasury, &fee_amount); + } + token_client.transfer(&env.current_contract_address(), &swap.seller, &seller_amount); + swap.status = SwapStatus::Completed; + } + + swap::save_swap(&env, swap_id, &swap); + env.storage().persistent().remove(&DataKey::ActiveSwap(swap.ip_id)); + env.storage().persistent().remove(&DataKey::SwapArbitrator(swap_id)); + + Self::append_history(&env, swap_id, swap.status.clone()); + + env.events().publish( + (soroban_sdk::symbol_short!("arb_dec"),), + ArbitratedEvent { swap_id, arbitrator, refunded: refund }, + ); + } + + /// Buyer or seller submits dispute evidence (a hash of off-chain evidence). + pub fn submit_dispute_evidence( + env: Env, + swap_id: u64, + submitter: Address, + evidence_hash: BytesN<32>, + ) { + submitter.require_auth(); + let swap = require_swap_exists(&env, swap_id); + + if submitter != swap.buyer && submitter != swap.seller { + env.panic_with_error(Error::from_contract_error( + ContractError::UnauthorizedEvidenceSubmitter as u32, + )); + } + + let key = DataKey::DisputeEvidence(swap_id); + let mut evidence: Vec> = env + .storage() + .persistent() + .get(&key) + .unwrap_or(Vec::new(&env)); + evidence.push_back(evidence_hash.clone()); + env.storage().persistent().set(&key, &evidence); + env.storage().persistent().extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP); + + env.events().publish( + (soroban_sdk::symbol_short!("evid_sub"),), + DisputeEvidenceSubmittedEvent { swap_id, submitter, evidence_hash }, + ); + } + + /// Returns all dispute evidence hashes submitted for a swap. + pub fn get_dispute_evidence(env: Env, swap_id: u64) -> Vec> { + env.storage() + .persistent() + .get(&DataKey::DisputeEvidence(swap_id)) + .unwrap_or(Vec::new(&env)) + } // submit_dispute_evidence removed - DisputeEvidence DataKey variant not defined // get_dispute_evidence removed - DisputeEvidence DataKey variant not defined @@ -1862,6 +1996,7 @@ impl AtomicSwap { conditions: Vec::new(&env), paid_amount: 0, is_installment: false, + arbitrator: None, }; env.storage().persistent().set(&DataKey::Swap(id), &swap); @@ -2143,6 +2278,7 @@ impl AtomicSwap { conditions: Vec::new(&env), paid_amount: 0, is_installment: false, + arbitrator: None, }; env.storage() @@ -2611,8 +2747,6 @@ impl AtomicSwap { arbitrator.require_auth(); // Verify arbitrator is set and matches - // SwapArbitrator DataKey variant not defined - commenting out - /* let stored_arbitrator: Address = env .storage() .persistent() @@ -2628,7 +2762,6 @@ impl AtomicSwap { ContractError::NotArbitrator as u32, )); } - */ let token_client = token::Client::new(&env, &swap.token); @@ -2719,8 +2852,7 @@ impl AtomicSwap { env.storage() .persistent() .remove(&DataKey::ActiveSwap(swap.ip_id)); - // SwapArbitrator DataKey variant not defined - // env.storage().persistent().remove(&DataKey::SwapArbitrator(swap_id)); + env.storage().persistent().remove(&DataKey::SwapArbitrator(swap_id)); env.events().publish( (soroban_sdk::symbol_short!("arb_dec"),), @@ -3248,6 +3380,7 @@ impl AtomicSwap { conditions: Vec::new(&env), paid_amount: 0, is_installment: false, + arbitrator: None, }; if insurance_enabled { @@ -3405,6 +3538,7 @@ impl AtomicSwap { conditions: Vec::new(&env), paid_amount: 0, is_installment: false, + arbitrator: None, }; env.storage().persistent().set(&DataKey::Swap(id), &swap); @@ -3480,6 +3614,7 @@ impl AtomicSwap { conditions: Vec::new(&env), paid_amount: 0, is_installment: false, + arbitrator: None, }; env.storage().persistent().set(&DataKey::Swap(id), &swap); @@ -3768,6 +3903,7 @@ impl AtomicSwap { conditions: Vec::new(&env), paid_amount: 0, is_installment: false, + arbitrator: None, }; env.storage().persistent().set(&DataKey::Swap(id), &swap); @@ -4132,18 +4268,12 @@ impl AtomicSwap { // #[cfg(test)] // mod tests; -// #[cfg(test)] -// mod escrow_tests; - // #[cfg(test)] // mod prop_tests; // #[cfg(test)] // mod regression_tests; -// #[cfg(test)] -// mod arbitration_tests; - // #[cfg(test)] // mod benchmarks; @@ -4156,6 +4286,12 @@ impl AtomicSwap { // #[cfg(test)] // mod upgrade_chaos_tests; +#[cfg(test)] +mod escrow_tests; + +#[cfg(test)] +mod arbitration_tests; + include!("multi_signer_tests.rs"); #[cfg(test)] @@ -4196,6 +4332,7 @@ mod installment_tests { conditions: Vec::new(env), paid_amount: paid, is_installment, + arbitrator: None, } } @@ -4366,6 +4503,7 @@ mod batch_enhancement_tests { conditions: Vec::new(env), paid_amount: 0, is_installment: false, + arbitrator: None, }; env.storage().persistent().set(&DataKey::Swap(id), &swap); env.storage().persistent().extend_ttl(&DataKey::Swap(id), LEDGER_BUMP, LEDGER_BUMP);