From e0921b2132121b1646b2f2511235395a65caa1fb Mon Sep 17 00:00:00 2001 From: liamscroxx-svg Date: Tue, 25 Aug 2026 02:29:57 +0000 Subject: [PATCH 1/2] Harden dispute arbitration: M-of-N committee, evidence binding, time-lock, bond set_arbitrator/arbitrate_dispute previously let a single trusted key move disputed funds instantly with no binding to submitted evidence, contradicting docs/threat-model.md's claimed 2-of-3 multisig, 48h time-lock, and non-refundable dispute bond mitigations. This closes that gap: - set_arbitrator now designates an M-of-N committee (min 2-of-3) instead of a single Address. - arbitrate_dispute becomes ruling entry, not fund movement: requires threshold committee co-authorization and requires evidence to already be submitted (read from storage, not signer-supplied). - New execute_ruling moves funds only after a 48h delay past ruling entry; new cancel_pending_ruling lets the committee void a ruling within that window. - submit_dispute_evidence now charges a non-refundable bond (max of 1 XLM in stroops or 10% of price) on first submission per party, settled in execute_ruling: winner refunded, loser forfeited. - resolve_dispute, auto_refund_timeout, and admin_rollback_swap (bypass paths that can move a swap out of Disputed without a committee ruling) now refund any outstanding bond and clear in-flight committee/ruling state instead of orphaning it. - batch_arbitrate_swaps never validated its arbitrator argument at all and is now disabled unconditionally, since leaving it live would bypass everything above; arbitrate_swap becomes unreachable now that set_arbitrator no longer populates the legacy single-key storage it read, which also closes a pre-existing double-payout path (it never checked swap status). - docs/threat-model.md's Admin Collusion / False Dispute Submission sections updated to reflect exactly what's implemented vs. still open (contract Admin role is still single-key; resolve_dispute remains an admin-direct bypass of the new safeguards). Also re-enables arbitration_tests.rs (disabled since a prior merge conflict over unrelated compile errors) with tests for the new committee/evidence/ timelock/bond behavior plus the bypass-path bond cleanup. --- .../atomic_swap/src/arbitration_tests.rs | 455 ++++++++++--- contracts/atomic_swap/src/errors.rs | 11 + contracts/atomic_swap/src/lib.rs | 642 ++++++++++++++---- contracts/atomic_swap/src/types.rs | 91 +++ docs/threat-model.md | 63 +- 5 files changed, 1036 insertions(+), 226 deletions(-) diff --git a/contracts/atomic_swap/src/arbitration_tests.rs b/contracts/atomic_swap/src/arbitration_tests.rs index 42cd63b..fc53a5f 100644 --- a/contracts/atomic_swap/src/arbitration_tests.rs +++ b/contracts/atomic_swap/src/arbitration_tests.rs @@ -4,11 +4,13 @@ mod arbitration_tests { use soroban_sdk::{ testutils::{Address as _, Ledger}, token::StellarAssetClient, - Address, BytesN, Env, + Address, BytesN, Env, Vec, }; use crate::{AtomicSwap, AtomicSwapClient, SwapStatus}; + const RULING_DELAY: u64 = 48 * 3600; + fn setup_registry(env: &Env, owner: &Address) -> (Address, u64, BytesN<32>, BytesN<32>) { let registry_id = env.register(IpRegistry, ()); let registry = IpRegistryClient::new(env, ®istry_id); @@ -18,7 +20,7 @@ mod arbitration_tests { preimage.append(&soroban_sdk::Bytes::from(secret.clone())); preimage.append(&soroban_sdk::Bytes::from(blinding.clone())); let commitment_hash: BytesN<32> = env.crypto().sha256(&preimage).into(); - let ip_id = registry.commit_ip(owner, &commitment_hash); + let ip_id = registry.commit_ip(owner, &commitment_hash, &0u32); (registry_id, ip_id, secret, blinding) } @@ -30,19 +32,29 @@ mod arbitration_tests { token_id } + /// Mints enough of the swap token to both buyer and seller to cover the + /// swap price plus `MIN_DISPUTE_BOND` (10_000_000, #781), so either party + /// can submit evidence (and pay the resulting bond) in these tests. fn setup_disputed_swap(env: &Env) -> (AtomicSwapClient, u64, Address, Address) { let seller = Address::generate(env); let buyer = Address::generate(env); - let admin = Address::generate(env); + let token_admin = Address::generate(env); let (registry_id, ip_id, _, _) = setup_registry(env, &seller); - let token_id = setup_token(env, &admin, &buyer, 1000); + let token_id = setup_token(env, &token_admin, &buyer, 20_000_000); + StellarAssetClient::new(env, &token_id).mint(&seller, &20_000_000); let contract_id = env.register(AtomicSwap, ()); let client = AtomicSwapClient::new(env, &contract_id); client.initialize(®istry_id); + // Price is kept small (well under 40) so protocol_fee_bps's fee + // floors to 0 and the "complete to seller" ruling path never has to + // transfer a fee to protocol_config().treasury — that address is a + // pre-existing hardcoded placeholder with no trustline for this + // test's token, a separate storage bug (see docs/threat-model.md's + // #781 update) this PR does not fix. let swap_id = client.initiate_swap( - &token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false, + &token_id, &ip_id, &seller, &20_i128, &buyer, &0_u32, &None, &0_i128, &false, ); client.accept_swap(&swap_id); client.raise_dispute(&swap_id); @@ -50,7 +62,27 @@ mod arbitration_tests { (client, swap_id, seller, buyer) } - // ── #314: set_arbitrator ────────────────────────────────────────────────── + /// A 3-signer, 2-of-3 committee (the threat model's stated minimum). + fn committee(env: &Env) -> Vec
{ + let mut signers = Vec::new(env); + signers.push_back(Address::generate(env)); + signers.push_back(Address::generate(env)); + signers.push_back(Address::generate(env)); + signers + } + + fn two_of(signers: &Vec
, env: &Env) -> Vec
{ + let mut two = Vec::new(env); + two.push_back(signers.get(0).unwrap()); + two.push_back(signers.get(1).unwrap()); + two + } + + fn skip_ruling_delay(env: &Env) { + env.ledger().with_mut(|l| l.timestamp += RULING_DELAY); + } + + // ── #781: set_arbitrator (M-of-N committee) ───────────────────────────── #[test] fn test_set_arbitrator_on_disputed_swap() { @@ -58,14 +90,14 @@ mod arbitration_tests { env.mock_all_auths(); let (client, swap_id, _, _) = setup_disputed_swap(&env); - let arbitrator = Address::generate(&env); + let signers = committee(&env); let admin = Address::generate(&env); - // Admin sets arbitrator - client.set_arbitrator(&swap_id, &admin, &arbitrator); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); let swap = client.get_swap(&swap_id).unwrap(); - assert_eq!(swap.arbitrator, Some(arbitrator)); + assert_eq!(swap.arbitrator, Some(signers.get(0).unwrap())); } #[test] @@ -75,12 +107,14 @@ mod arbitration_tests { env.mock_all_auths(); let (client, swap_id, _, _) = setup_disputed_swap(&env); - let arbitrator = Address::generate(&env); + let signers = committee(&env); let admin = Address::generate(&env); - client.set_arbitrator(&swap_id, &admin, &arbitrator); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); // Second call should panic with ArbitratorAlreadySet - client.set_arbitrator(&swap_id, &admin, &arbitrator); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); } #[test] @@ -103,40 +137,88 @@ mod arbitration_tests { &token_id, &ip_id, &seller, &500_i128, &buyer, &0_u32, &None, &0_i128, &false, ); // Swap is Pending, not Disputed — should panic + let signers = committee(&env); let admin = Address::generate(&env); - let arbitrator = Address::generate(&env); - client.set_arbitrator(&swap_id, &admin, &arbitrator); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + } + + #[test] + #[should_panic] + fn test_set_arbitrator_committee_too_small_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, _) = setup_disputed_swap(&env); + let admin = Address::generate(&env); + // Only 2 signers — below the 2-of-3 minimum committee size. + let mut signers = Vec::new(&env); + signers.push_back(Address::generate(&env)); + signers.push_back(Address::generate(&env)); + + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + } + + #[test] + #[should_panic] + fn test_set_arbitrator_duplicate_signer_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, _) = setup_disputed_swap(&env); + let admin = Address::generate(&env); + let dup = Address::generate(&env); + let mut signers = Vec::new(&env); + signers.push_back(dup.clone()); + signers.push_back(dup); + signers.push_back(Address::generate(&env)); + + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); } - // ── #314: arbitrate_dispute ─────────────────────────────────────────────── + // ── #781: arbitrate_dispute (ruling entry) + execute_ruling ───────────── #[test] - fn test_arbitrate_dispute_refunds_buyer() { + fn test_ruling_refunds_buyer_after_delay() { let env = Env::default(); env.mock_all_auths(); let (client, swap_id, _, buyer) = setup_disputed_swap(&env); - let arbitrator = Address::generate(&env); + let signers = committee(&env); let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + + let hash = BytesN::from_array(&env, &[0xabu8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash); - client.set_arbitrator(&swap_id, &admin, &arbitrator); - client.arbitrate_dispute(&swap_id, &arbitrator, &true); + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &true); + skip_ruling_delay(&env); + client.execute_ruling(&swap_id); let swap = client.get_swap(&swap_id).unwrap(); assert_eq!(swap.status, SwapStatus::Cancelled); } #[test] - fn test_arbitrate_dispute_completes_to_seller() { + fn test_ruling_completes_to_seller_after_delay() { let env = Env::default(); env.mock_all_auths(); - let (client, swap_id, _, _) = setup_disputed_swap(&env); - let arbitrator = Address::generate(&env); + let (client, swap_id, seller, _) = setup_disputed_swap(&env); + let signers = committee(&env); let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); - client.set_arbitrator(&swap_id, &admin, &arbitrator); - client.arbitrate_dispute(&swap_id, &arbitrator, &false); + let hash = BytesN::from_array(&env, &[0xcdu8; 32]); + client.submit_dispute_evidence(&swap_id, &seller, &hash); + + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &false); + skip_ruling_delay(&env); + client.execute_ruling(&swap_id); let swap = client.get_swap(&swap_id).unwrap(); assert_eq!(swap.status, SwapStatus::Completed); @@ -144,30 +226,227 @@ mod arbitration_tests { #[test] #[should_panic] - fn test_wrong_arbitrator_cannot_arbitrate() { + fn test_ruling_without_evidence_rejected() { let env = Env::default(); env.mock_all_auths(); let (client, swap_id, _, _) = setup_disputed_swap(&env); - let arbitrator = Address::generate(&env); - let impostor = Address::generate(&env); + let signers = committee(&env); let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); - client.set_arbitrator(&swap_id, &admin, &arbitrator); - // impostor is not the assigned arbitrator — should panic - client.arbitrate_dispute(&swap_id, &impostor, &true); + // No evidence submitted — should panic with EvidenceRequired. + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &true); } #[test] #[should_panic] - fn test_arbitrate_without_arbitrator_set_rejected() { + fn test_ruling_with_insufficient_signers_rejected() { let env = Env::default(); env.mock_all_auths(); - let (client, swap_id, _, _) = setup_disputed_swap(&env); - let anyone = Address::generate(&env); - // No arbitrator set — should panic with NoArbitratorSet - client.arbitrate_dispute(&swap_id, &anyone, &true); + let (client, swap_id, _, buyer) = setup_disputed_swap(&env); + let signers = committee(&env); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + + let hash = BytesN::from_array(&env, &[0x11u8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash); + + let mut one = Vec::new(&env); + one.push_back(signers.get(0).unwrap()); + // Only 1 of 3 signers, threshold is 2 — should panic InsufficientSignatures. + client.arbitrate_dispute(&swap_id, &one, &true); + } + + #[test] + #[should_panic] + fn test_ruling_by_non_committee_signer_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, buyer) = setup_disputed_swap(&env); + let signers = committee(&env); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + + let hash = BytesN::from_array(&env, &[0x22u8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash); + + let mut impostors = Vec::new(&env); + impostors.push_back(signers.get(0).unwrap()); + impostors.push_back(Address::generate(&env)); // not a committee member + client.arbitrate_dispute(&swap_id, &impostors, &true); + } + + #[test] + #[should_panic] + fn test_double_pending_ruling_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, buyer) = setup_disputed_swap(&env); + let signers = committee(&env); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + + let hash = BytesN::from_array(&env, &[0x33u8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash); + + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &true); + // A ruling is already pending — should panic RulingAlreadyPending. + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &true); + } + + #[test] + #[should_panic] + fn test_execute_ruling_before_delay_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, buyer) = setup_disputed_swap(&env); + let signers = committee(&env); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + + let hash = BytesN::from_array(&env, &[0x44u8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash); + + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &true); + // No time skip — should panic TimelockNotElapsed. + client.execute_ruling(&swap_id); + } + + #[test] + fn test_cancel_pending_ruling_within_window() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, buyer) = setup_disputed_swap(&env); + let signers = committee(&env); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + + let hash = BytesN::from_array(&env, &[0x55u8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash); + + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &true); + client.cancel_pending_ruling(&swap_id, &two_of(&signers, &env)); + + // A fresh ruling can be entered after cancellation. + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &false); + skip_ruling_delay(&env); + client.execute_ruling(&swap_id); + + let swap = client.get_swap(&swap_id).unwrap(); + assert_eq!(swap.status, SwapStatus::Completed); + } + + #[test] + #[should_panic] + fn test_cancel_pending_ruling_after_window_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, buyer) = setup_disputed_swap(&env); + let signers = committee(&env); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + + let hash = BytesN::from_array(&env, &[0x66u8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash); + + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &true); + skip_ruling_delay(&env); + // Window closed — should panic RulingFinalized. + client.cancel_pending_ruling(&swap_id, &two_of(&signers, &env)); + } + + // ── #781: dispute bond ──────────────────────────────────────────────────── + + #[test] + fn test_bond_charged_once_per_submitter() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, buyer) = setup_disputed_swap(&env); + let hash1 = BytesN::from_array(&env, &[0x77u8; 32]); + let hash2 = BytesN::from_array(&env, &[0x78u8; 32]); + + client.submit_dispute_evidence(&swap_id, &buyer, &hash1); + // Second submission by the same buyer must not re-charge the bond — + // asserted indirectly: this must not panic on insufficient balance + // even though the buyer only started with 20_000_000. + client.submit_dispute_evidence(&swap_id, &buyer, &hash2); + + let evidence = client.get_dispute_evidence(&swap_id); + assert_eq!(evidence.len(), 2); + } + + #[test] + fn test_winning_bond_refunded_losing_bond_forfeited() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, seller, buyer) = setup_disputed_swap(&env); + let signers = committee(&env); + let admin = Address::generate(&env); + client.set_admin(&admin); + client.set_arbitrator(&swap_id, &admin, &signers, &2u32); + + let hash1 = BytesN::from_array(&env, &[0x81u8; 32]); + let hash2 = BytesN::from_array(&env, &[0x82u8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash1); + client.submit_dispute_evidence(&swap_id, &seller, &hash2); + + // Ruling favors the buyer (refund=true): buyer's bond is refunded, + // seller's bond is forfeited to the admin. + client.arbitrate_dispute(&swap_id, &two_of(&signers, &env), &true); + skip_ruling_delay(&env); + client.execute_ruling(&swap_id); + + let token_client = + soroban_sdk::token::Client::new(&env, &client.get_swap(&swap_id).unwrap().token); + // Buyer paid price(20) + bond(10_000_000) and, having won the + // ruling, gets both back in full: fully whole again at 20_000_000. + assert_eq!(token_client.balance(&buyer), 20_000_000); + // Seller's bond (10_000_000 of their 20_000_000) was forfeited; + // seller never received the price either way (buyer won). + assert_eq!(token_client.balance(&seller), 20_000_000 - 10_000_000); + } + + #[test] + fn test_uncontested_resolution_refunds_outstanding_bond() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, swap_id, _, buyer) = setup_disputed_swap(&env); + let hash = BytesN::from_array(&env, &[0x91u8; 32]); + client.submit_dispute_evidence(&swap_id, &buyer, &hash); + + let token_client = + soroban_sdk::token::Client::new(&env, &client.get_swap(&swap_id).unwrap().token); + assert_eq!(token_client.balance(&buyer), 20_000_000 - 20 - 10_000_000); + + // resolve_dispute bypasses the committee ruling flow entirely — the + // outstanding bond must be refunded in full, not orphaned. + let admin_caller = Address::generate(&env); + env.as_contract(&client.address, || { + env.storage() + .instance() + .set(&crate::DataKey::Admin, &admin_caller); + }); + client.resolve_dispute(&swap_id, &admin_caller, &true); + + // Price and bond both refunded — fully whole again. + assert_eq!(token_client.balance(&buyer), 20_000_000); } // ── #313: submit_dispute_evidence ──────────────────────────────────────── @@ -241,62 +520,6 @@ mod arbitration_tests { assert_eq!(evidence.len(), 0); } - // ── #312: tiered pricing ────────────────────────────────────────────────── - - #[test] - fn test_accept_swap_with_quantity_applies_tier() { - let env = Env::default(); - env.mock_all_auths(); - - let seller = Address::generate(&env); - let buyer = Address::generate(&env); - let admin = Address::generate(&env); - let (registry_id, ip_id, _, _) = setup_registry(&env, &seller); - let token_id = setup_token(&env, &admin, &buyer, 10_000); - - let contract_id = env.register(AtomicSwap, ()); - let client = AtomicSwapClient::new(&env, &contract_id); - 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, &None, &0_i128, &false, - ); - - // Accept with quantity=1 (no tiers set, uses flat price) - client.accept_swap_with_quantity(&swap_id, &1_u32); - - let swap = client.get_swap(&swap_id).unwrap(); - assert_eq!(swap.status, SwapStatus::Accepted); - assert_eq!(swap.price, 500); - } - - #[test] - fn test_accept_swap_flat_price_when_no_tiers() { - let env = Env::default(); - env.mock_all_auths(); - - let seller = Address::generate(&env); - let buyer = Address::generate(&env); - let admin = Address::generate(&env); - let (registry_id, ip_id, _, _) = setup_registry(&env, &seller); - let token_id = setup_token(&env, &admin, &buyer, 10_000); - - let contract_id = env.register(AtomicSwap, ()); - 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, &None, &0_i128, &false, - ); - client.accept_swap_with_quantity(&swap_id, &5_u32); - - let swap = client.get_swap(&swap_id).unwrap(); - // No tiers: price stays at flat 1000 - assert_eq!(swap.price, 1000); - assert_eq!(swap.status, SwapStatus::Accepted); - } - // ── accept_swap_partial ─────────────────────────────────────────────────── #[test] @@ -410,4 +633,52 @@ mod arbitration_tests { // quantity=1 by default, requesting 2 should panic client.accept_swap_partial(&swap_id, &2_u32); } + + // ── #781: batch_arbitrate_swaps (disabled) ────────────────────────────── + // + // batch_arbitrate_swaps never validated its `arbitrator` argument against + // any stored arbitrator/committee — any caller could drain any disputed + // swap through it, fully bypassing the M-of-N committee/evidence/ + // timelock/bond system added in #781. It is now disabled unconditionally + // pending a follow-up migration onto the committee model. + + #[test] + #[should_panic] + fn test_batch_arbitrate_swaps_disabled() { + let env = Env::default(); + env.mock_all_auths(); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + let arbitrator = Address::generate(&env); + let token_admin = Address::generate(&env); + + let registry_id = env.register(IpRegistry, ()); + let registry = IpRegistryClient::new(&env, ®istry_id); + let hash1 = BytesN::from_array(&env, &[0x01u8; 32]); + let hash2 = BytesN::from_array(&env, &[0x02u8; 32]); + let ip1 = registry.commit_ip(&seller, &hash1, &0u32); + let ip2 = registry.commit_ip(&seller, &hash2, &0u32); + + let token_id = setup_token(&env, &token_admin, &buyer, 10_000_000); + let contract_id = env.register(AtomicSwap, ()); + let client = AtomicSwapClient::new(&env, &contract_id); + client.initialize(®istry_id); + + let mut ip_ids = Vec::new(&env); + ip_ids.push_back(ip1); + ip_ids.push_back(ip2); + let mut prices = Vec::new(&env); + prices.push_back(20i128); + prices.push_back(30i128); + + let swap_ids = + client.batch_initiate_swap(&token_id, &ip_ids, &seller, &prices, &buyer, &0u32, &None); + client.batch_accept_swaps(&swap_ids, &buyer); + client.raise_dispute(&swap_ids.get(0).unwrap()); + client.raise_dispute(&swap_ids.get(1).unwrap()); + + // Disabled — must always panic, regardless of caller or dispute state. + client.batch_arbitrate_swaps(&swap_ids, &arbitrator, &true); + } } diff --git a/contracts/atomic_swap/src/errors.rs b/contracts/atomic_swap/src/errors.rs index 51901ef..ac3835a 100644 --- a/contracts/atomic_swap/src/errors.rs +++ b/contracts/atomic_swap/src/errors.rs @@ -54,4 +54,15 @@ pub enum ContractError { // Partial quantity swap errors InvalidQuantity = 39, InvalidReferralFeeBps = 61, + // #781: Arbitrator committee / time-locked ruling / dispute bond errors + NotACommitteeSigner = 54, + DuplicateSigner = 55, + InsufficientSignatures = 56, + CommitteeSizeTooSmall = 57, + EvidenceRequired = 58, + RulingAlreadyPending = 59, + NoPendingRuling = 60, + TimelockNotElapsed = 62, + RulingFinalized = 63, + BatchArbitrationDisabled = 64, } diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index a91249e..e550403 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -86,6 +86,20 @@ pub enum ContractError { BatchTooLarge = 51, BatchSizeMismatch = 52, ConditionNotMet = 53, + /// #781: Arbitrator committee / time-locked ruling / dispute bond errors + NotACommitteeSigner = 54, + DuplicateSigner = 55, + InsufficientSignatures = 56, + CommitteeSizeTooSmall = 57, + EvidenceRequired = 58, + RulingAlreadyPending = 59, + NoPendingRuling = 60, + TimelockNotElapsed = 62, + RulingFinalized = 63, + /// #781: batch_arbitrate_swaps disabled — never validated the caller + /// against any stored arbitrator, bypassing the committee/evidence/ + /// timelock/bond system entirely. See #781 PR notes. + BatchArbitrationDisabled = 64, } // ── TTL ─────────────────────────────────────────────────────────────────────── @@ -97,6 +111,21 @@ pub const LEDGER_BUMP: u32 = 6_307_200; /// Maximum number of items allowed in a single batch operation. pub const MAX_BATCH_SIZE: u32 = 50; +/// #781: Minimum non-refundable dispute bond, in the swap token's smallest +/// unit — 1 XLM in stroops, per docs/threat-model.md's stated minimum. The +/// contract is token-agnostic (no function anywhere does per-token decimal +/// conversion), so this is a flat literal like every other constant here. +pub const MIN_DISPUTE_BOND: i128 = 10_000_000; + +/// #781: Default delay between a committee ruling being entered and it +/// becoming executable, per docs/threat-model.md's 48-hour requirement. +pub const DEFAULT_ARBITRATION_RULING_DELAY_SECONDS: u64 = 48 * 3600; + +/// #781: Minimum committee size and threshold for `set_arbitrator`, per +/// docs/threat-model.md's "minimum 2-of-3 threshold" requirement. +pub const MIN_COMMITTEE_SIGNERS: u32 = 3; +pub const MIN_COMMITTEE_THRESHOLD: u32 = 2; + // ── Storage Keys ────────────────────────────────────────────────────────────── #[contracttype] @@ -176,9 +205,19 @@ pub enum DataKey { /// #470: Price oracle configuration (oracle contract address + enabled flag). OracleConfig, /// #314: Maps swap_id → arbitrator Address for dispute resolution. + /// Legacy single-arbitrator key. No longer written by `set_arbitrator` + /// (#781 replaced it with `ArbitratorCommittee`); left in place only + /// because `arbitrate_swap` still reads it. See #781 PR notes. SwapArbitrator(u64), /// #313: Maps swap_id → Vec> of dispute evidence hashes. DisputeEvidence(u64), + /// #781: Maps swap_id → ArbitratorCommittee (M-of-N signers + threshold). + ArbitratorCommittee(u64), + /// #781: Maps swap_id → PendingRuling entered by the committee, awaiting + /// the time-lock delay before `execute_ruling` can move funds. + PendingRuling(u64), + /// #781: Maps swap_id → DisputeBonds deposited by buyer/seller. + DisputeBond(u64), } // ── Types ───────────────────────────────────────────────────────────────────── @@ -203,6 +242,12 @@ pub struct ProtocolConfig { /// How long (seconds) after arbitration is requested before auto-refund is allowed. /// Default: 14 days = 1_209_600 seconds. pub arbitration_timeout_seconds: u64, + /// #781: How long (seconds) a committee ruling must wait before + /// `execute_ruling` can move funds. Default: 48 hours = 172_800 seconds. + /// Note: `store_protocol_config` is a pre-existing no-op stub, so this + /// field cannot actually be reconfigured at runtime today — see its + /// definition below. + pub arbitration_ruling_delay_secs: u64, } #[contracttype] @@ -996,6 +1041,11 @@ impl AtomicSwap { ); } + // #781: this admin-direct path bypasses the committee/evidence/ + // timelock/bond system — refund any bond in flight rather than + // orphaning it, since no committee ruling occurred here. + Self::release_dispute_bonds_uncontested(&env, swap_id, &swap); + env.events().publish( (soroban_sdk::symbol_short!("disp_res"),), DisputeResolvedEvent { swap_id, refunded }, @@ -1062,6 +1112,11 @@ impl AtomicSwap { swap.status = SwapStatus::Cancelled; swap::save_swap(&env, swap_id, &swap); + // #781: this admin path bypasses the committee/evidence/timelock/bond + // system — refund any bond in flight rather than orphaning it, since + // no committee ruling occurred here. + Self::release_dispute_bonds_uncontested(&env, swap_id, &swap); + // Release the IP lock env.storage() .persistent() @@ -1225,10 +1280,17 @@ impl AtomicSwap { ); } - // ── #314: Third-Party Arbitration ───────────────────────────────────────── + // ── #314/#781: Third-Party Arbitration (M-of-N committee, time-locked) ───── - /// 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) { + /// #781: Admin designates an M-of-N committee to rule on a disputed swap. + /// Can only be set once. Minimum 2-of-3, per docs/threat-model.md. + pub fn set_arbitrator( + env: Env, + swap_id: u64, + admin: Address, + signers: Vec
, + threshold: u32, + ) { admin.require_auth(); require_admin(&env, &admin); @@ -1243,54 +1305,225 @@ impl AtomicSwap { if env .storage() .persistent() - .has(&DataKey::SwapArbitrator(swap_id)) + .has(&DataKey::ArbitratorCommittee(swap_id)) { env.panic_with_error(Error::from_contract_error( ContractError::ArbitratorAlreadySet as u32, )); } + if signers.len() < MIN_COMMITTEE_SIGNERS + || threshold < MIN_COMMITTEE_THRESHOLD + || threshold > signers.len() + { + env.panic_with_error(Error::from_contract_error( + ContractError::CommitteeSizeTooSmall as u32, + )); + } + + for i in 0..signers.len() { + let s = signers.get(i).unwrap(); + for j in 0..i { + if signers.get(j).unwrap() == s { + env.panic_with_error(Error::from_contract_error( + ContractError::DuplicateSigner as u32, + )); + } + } + } + + let committee = ArbitratorCommittee { + signers: signers.clone(), + threshold, + }; env.storage() .persistent() - .set(&DataKey::SwapArbitrator(swap_id), &arbitrator); + .set(&DataKey::ArbitratorCommittee(swap_id), &committee); env.storage().persistent().extend_ttl( - &DataKey::SwapArbitrator(swap_id), + &DataKey::ArbitratorCommittee(swap_id), LEDGER_BUMP, LEDGER_BUMP, ); - swap.arbitrator = Some(arbitrator.clone()); + // Informational only — not read for any auth/enforcement decision. + swap.arbitrator = signers.get(0); swap::save_swap(&env, swap_id, &swap); env.events().publish( - (soroban_sdk::symbol_short!("arb_set"),), - ArbitratorSetEvent { + (soroban_sdk::symbol_short!("arb_comm"),), + ArbitratorCommitteeSetEvent { swap_id, - arbitrator, + signers, + threshold, }, ); } - /// 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 + /// #781: Validates `signers` against the swap's `ArbitratorCommittee` — + /// each address must `require_auth()`, be a distinct committee member, + /// and together meet the committee's threshold. + fn require_committee_threshold( + env: &Env, + swap_id: u64, + signers: &Vec
, + ) -> ArbitratorCommittee { + let committee: ArbitratorCommittee = env .storage() .persistent() - .get(&DataKey::SwapArbitrator(swap_id)) + .get(&DataKey::ArbitratorCommittee(swap_id)) .unwrap_or_else(|| { env.panic_with_error(Error::from_contract_error( ContractError::NoArbitratorSet as u32, )) }); - if arbitrator != stored_arbitrator { + for i in 0..signers.len() { + let signer = signers.get(i).unwrap(); + signer.require_auth(); + + if !committee.signers.contains(signer.clone()) { + env.panic_with_error(Error::from_contract_error( + ContractError::NotACommitteeSigner as u32, + )); + } + + for j in 0..i { + if signers.get(j).unwrap() == signer { + env.panic_with_error(Error::from_contract_error( + ContractError::DuplicateSigner as u32, + )); + } + } + } + + if signers.len() < committee.threshold { env.panic_with_error(Error::from_contract_error( - ContractError::NotArbitrator as u32, + ContractError::InsufficientSignatures as u32, + )); + } + + committee + } + + /// #781: Committee ruling entry — NOT fund movement. Requires `threshold` + /// of the committee's `signers` to jointly authorize this call (each must + /// `require_auth()`), requires evidence to have already been submitted + /// (read directly from storage, not signer-supplied), and stores the + /// ruling as pending for `execute_ruling` to carry out once the time-lock + /// delay elapses. refund=true favors the buyer; false favors the seller. + pub fn arbitrate_dispute(env: Env, swap_id: u64, signers: Vec
, refund: bool) { + let swap = require_swap_exists(&env, swap_id); + require_swap_status( + &env, + &swap, + SwapStatus::Disputed, + ContractError::NotDisputed, + ); + + if env + .storage() + .persistent() + .has(&DataKey::PendingRuling(swap_id)) + { + env.panic_with_error(Error::from_contract_error( + ContractError::RulingAlreadyPending as u32, + )); + } + + Self::require_committee_threshold(&env, swap_id, &signers); + + let evidence: Vec> = env + .storage() + .persistent() + .get(&DataKey::DisputeEvidence(swap_id)) + .unwrap_or(Vec::new(&env)); + if evidence.is_empty() { + env.panic_with_error(Error::from_contract_error( + ContractError::EvidenceRequired as u32, + )); + } + + let ruled_at = env.ledger().timestamp(); + let pending = PendingRuling { + refund, + ruled_at, + evidence_hashes: evidence.clone(), + ruled_by: signers.clone(), + }; + env.storage() + .persistent() + .set(&DataKey::PendingRuling(swap_id), &pending); + env.storage().persistent().extend_ttl( + &DataKey::PendingRuling(swap_id), + LEDGER_BUMP, + LEDGER_BUMP, + ); + + env.events().publish( + (soroban_sdk::symbol_short!("rule_ent"),), + RulingEnteredEvent { + swap_id, + refund, + ruled_at, + evidence_hashes: evidence, + ruled_by: signers, + }, + ); + } + + /// #781: Cancels a pending ruling before the time-lock delay elapses, + /// requiring the same committee threshold as ruling entry. Once + /// cancelled, `arbitrate_dispute` may be called again to enter a fresh + /// ruling. + pub fn cancel_pending_ruling(env: Env, swap_id: u64, signers: Vec
) { + Self::require_committee_threshold(&env, swap_id, &signers); + + let pending: PendingRuling = env + .storage() + .persistent() + .get(&DataKey::PendingRuling(swap_id)) + .unwrap_or_else(|| { + env.panic_with_error(Error::from_contract_error( + ContractError::NoPendingRuling as u32, + )) + }); + + let config = Self::protocol_config(&env); + let elapsed = env.ledger().timestamp().saturating_sub(pending.ruled_at); + if elapsed >= config.arbitration_ruling_delay_secs { + env.panic_with_error(Error::from_contract_error( + ContractError::RulingFinalized as u32, )); } + env.storage() + .persistent() + .remove(&DataKey::PendingRuling(swap_id)); + + env.events().publish( + (soroban_sdk::symbol_short!("rule_can"),), + RulingCancelledEvent { + swap_id, + cancelled_by: signers, + }, + ); + } + + /// #781: Executes a committee ruling after the time-lock delay has + /// elapsed. Permissionless — anyone may trigger it once the delay has + /// passed, matching `auto_refund_timeout`'s convention. Moves funds and + /// settles dispute bonds (see `settle_dispute_bonds`). + pub fn execute_ruling(env: Env, swap_id: u64) { + let pending: PendingRuling = env + .storage() + .persistent() + .get(&DataKey::PendingRuling(swap_id)) + .unwrap_or_else(|| { + env.panic_with_error(Error::from_contract_error( + ContractError::NoPendingRuling as u32, + )) + }); + let mut swap = require_swap_exists(&env, swap_id); require_swap_status( &env, @@ -1299,13 +1532,21 @@ impl AtomicSwap { ContractError::NotDisputed, ); + let config = Self::protocol_config(&env); + let elapsed = env.ledger().timestamp().saturating_sub(pending.ruled_at); + if elapsed < config.arbitration_ruling_delay_secs { + env.panic_with_error(Error::from_contract_error( + ContractError::TimelockNotElapsed as u32, + )); + } + + let refund = pending.refund; 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 { @@ -1327,27 +1568,186 @@ impl AtomicSwap { swap.status = SwapStatus::Completed; } + Self::settle_dispute_bonds(&env, swap_id, &swap, refund, &token_client); + swap::save_swap(&env, swap_id, &swap); env.storage() .persistent() .remove(&DataKey::ActiveSwap(swap.ip_id)); env.storage() .persistent() - .remove(&DataKey::SwapArbitrator(swap_id)); + .remove(&DataKey::PendingRuling(swap_id)); + env.storage() + .persistent() + .remove(&DataKey::ArbitratorCommittee(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, - }, + (soroban_sdk::symbol_short!("rule_exc"),), + RulingExecutedEvent { swap_id, refund }, ); } - /// Buyer or seller submits dispute evidence (a hash of off-chain evidence). + /// #781: Refunds the winning party's dispute bond and forfeits the + /// losing party's bond. Forfeited bonds go to the current `DataKey::Admin` + /// address rather than `protocol_config().treasury` — `protocol_config()` + /// (below) unconditionally returns a hardcoded placeholder address + /// regardless of what's configured (a pre-existing storage bug this PR + /// does not fix); routing real forfeited value through it would be a new, + /// self-inflicted loss path, so real value is deliberately kept on the + /// one admin-storage path that actually round-trips correctly today. + fn settle_dispute_bonds( + env: &Env, + swap_id: u64, + swap: &SwapRecord, + refund: bool, + token_client: &token::Client, + ) { + let bonds: DisputeBonds = env + .storage() + .persistent() + .get(&DataKey::DisputeBond(swap_id)) + .unwrap_or(DisputeBonds { + buyer_bond: 0, + seller_bond: 0, + }); + + if bonds.buyer_bond == 0 && bonds.seller_bond == 0 { + return; + } + + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| { + env.panic_with_error(Error::from_contract_error( + ContractError::NotInitialized as u32, + )) + }); + + if bonds.buyer_bond > 0 { + if refund { + token_client.transfer( + &env.current_contract_address(), + &swap.buyer, + &bonds.buyer_bond, + ); + env.events().publish( + (soroban_sdk::symbol_short!("bond_rfd"),), + DisputeBondRefundedEvent { + swap_id, + submitter: swap.buyer.clone(), + bond_amount: bonds.buyer_bond, + }, + ); + } else { + token_client.transfer(&env.current_contract_address(), &admin, &bonds.buyer_bond); + env.events().publish( + (soroban_sdk::symbol_short!("bond_fft"),), + DisputeBondForfeitedEvent { + swap_id, + submitter: swap.buyer.clone(), + bond_amount: bonds.buyer_bond, + }, + ); + } + } + + if bonds.seller_bond > 0 { + if !refund { + token_client.transfer( + &env.current_contract_address(), + &swap.seller, + &bonds.seller_bond, + ); + env.events().publish( + (soroban_sdk::symbol_short!("bond_rfd"),), + DisputeBondRefundedEvent { + swap_id, + submitter: swap.seller.clone(), + bond_amount: bonds.seller_bond, + }, + ); + } else { + token_client.transfer(&env.current_contract_address(), &admin, &bonds.seller_bond); + env.events().publish( + (soroban_sdk::symbol_short!("bond_fft"),), + DisputeBondForfeitedEvent { + swap_id, + submitter: swap.seller.clone(), + bond_amount: bonds.seller_bond, + }, + ); + } + } + + env.storage() + .persistent() + .remove(&DataKey::DisputeBond(swap_id)); + } + + /// #781: Called by dispute-resolution paths other than the committee + /// ruling flow (`resolve_dispute`, `auto_refund_timeout`, + /// `admin_rollback_swap`) when they move a swap out of `Disputed`. + /// Refunds any deposited dispute bonds in full (no forfeiture — no + /// committee ruling occurred) and clears any in-flight committee/pending- + /// ruling state, so neither is orphaned in storage. + fn release_dispute_bonds_uncontested(env: &Env, swap_id: u64, swap: &SwapRecord) { + if let Some(bonds) = env + .storage() + .persistent() + .get::<_, DisputeBonds>(&DataKey::DisputeBond(swap_id)) + { + let token_client = token::Client::new(env, &swap.token); + if bonds.buyer_bond > 0 { + token_client.transfer( + &env.current_contract_address(), + &swap.buyer, + &bonds.buyer_bond, + ); + env.events().publish( + (soroban_sdk::symbol_short!("bond_rfd"),), + DisputeBondRefundedEvent { + swap_id, + submitter: swap.buyer.clone(), + bond_amount: bonds.buyer_bond, + }, + ); + } + if bonds.seller_bond > 0 { + token_client.transfer( + &env.current_contract_address(), + &swap.seller, + &bonds.seller_bond, + ); + env.events().publish( + (soroban_sdk::symbol_short!("bond_rfd"),), + DisputeBondRefundedEvent { + swap_id, + submitter: swap.seller.clone(), + bond_amount: bonds.seller_bond, + }, + ); + } + env.storage() + .persistent() + .remove(&DataKey::DisputeBond(swap_id)); + } + + env.storage() + .persistent() + .remove(&DataKey::PendingRuling(swap_id)); + env.storage() + .persistent() + .remove(&DataKey::ArbitratorCommittee(swap_id)); + } + + /// Buyer or seller submits dispute evidence (a hash of off-chain + /// evidence). #781: charges a non-refundable dispute bond (the greater of + /// `MIN_DISPUTE_BOND` or 10% of the swap price) on a submitter's first + /// submission; later submissions by the same address don't re-charge. pub fn submit_dispute_evidence( env: Env, swap_id: u64, @@ -1375,6 +1775,8 @@ impl AtomicSwap { .persistent() .extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP); + Self::charge_dispute_bond(&env, swap_id, &swap, &submitter); + env.events().publish( (soroban_sdk::symbol_short!("evid_sub"),), DisputeEvidenceSubmittedEvent { @@ -1385,6 +1787,60 @@ impl AtomicSwap { ); } + fn charge_dispute_bond(env: &Env, swap_id: u64, swap: &SwapRecord, submitter: &Address) { + let bond_key = DataKey::DisputeBond(swap_id); + let mut bonds: DisputeBonds = env + .storage() + .persistent() + .get(&bond_key) + .unwrap_or(DisputeBonds { + buyer_bond: 0, + seller_bond: 0, + }); + + let is_buyer = *submitter == swap.buyer; + let already_charged = if is_buyer { + bonds.buyer_bond > 0 + } else { + bonds.seller_bond > 0 + }; + if already_charged { + return; + } + + let pct_bond = swap.price * 1000 / 10000; + let bond_amount = if pct_bond > MIN_DISPUTE_BOND { + pct_bond + } else { + MIN_DISPUTE_BOND + }; + + token::Client::new(env, &swap.token).transfer( + submitter, + &env.current_contract_address(), + &bond_amount, + ); + + if is_buyer { + bonds.buyer_bond = bond_amount; + } else { + bonds.seller_bond = bond_amount; + } + env.storage().persistent().set(&bond_key, &bonds); + env.storage() + .persistent() + .extend_ttl(&bond_key, LEDGER_BUMP, LEDGER_BUMP); + + env.events().publish( + (soroban_sdk::symbol_short!("bond_dep"),), + DisputeBondDepositedEvent { + swap_id, + submitter: submitter.clone(), + bond_amount, + }, + ); + } + /// Returns all dispute evidence hashes submitted for a swap. pub fn get_dispute_evidence(env: Env, swap_id: u64) -> Vec> { env.storage() @@ -1393,8 +1849,6 @@ impl AtomicSwap { .unwrap_or(Vec::new(&env)) } - // submit_dispute_evidence removed - DisputeEvidence DataKey variant not defined - // get_dispute_evidence removed - DisputeEvidence DataKey variant not defined // accept_swap_with_quantity removed - price_tiers field not in SwapRecord /// Buyer accepts a partial quantity of a bulk swap at a proportional price. @@ -1747,6 +2201,7 @@ impl AtomicSwap { dispute_timeout_secs, referral_fee_bps, arbitration_timeout_seconds: 1_209_600, + arbitration_ruling_delay_secs: DEFAULT_ARBITRATION_RULING_DELAY_SECONDS, }, ); } @@ -1769,6 +2224,7 @@ impl AtomicSwap { dispute_timeout_secs: 604800, referral_fee_bps: 100, arbitration_timeout_seconds: 1_209_600, // 14 days + arbitration_ruling_delay_secs: DEFAULT_ARBITRATION_RULING_DELAY_SECONDS, } } @@ -3042,6 +3498,11 @@ impl AtomicSwap { .persistent() .remove(&DataKey::ArbitrationTimestamp(swap_id)); + // #781: this timeout path bypasses the committee/evidence/timelock/ + // bond system — refund any bond in flight rather than orphaning it, + // since no committee ruling occurred here. + Self::release_dispute_bonds_uncontested(&env, swap_id, &swap); + // Refund buyer token::Client::new(&env, &swap.token).transfer( &env.current_contract_address(), @@ -3791,99 +4252,24 @@ impl AtomicSwap { swap_ids } - /// Arbitrate multiple disputed swaps in one call. Arbitrator-only. - /// `refund` applies uniformly to all swaps in the batch. - pub fn batch_arbitrate_swaps(env: Env, swap_ids: Vec, arbitrator: Address, refund: bool) { - arbitrator.require_auth(); - - for swap_id in swap_ids.iter() { - 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); - - if swap.collateral_amount > 0 { - if let Some(collateral) = env - .storage() - .persistent() - .get::<_, i128>(&DataKey::SwapCollateral(swap_id)) - { - token_client.transfer( - &env.current_contract_address(), - &swap.buyer, - &collateral, - ); - env.storage() - .persistent() - .remove(&DataKey::SwapCollateral(swap_id)); - } - } - - swap.status = SwapStatus::Cancelled; - } else { - let config = Self::protocol_config(&env); - let fee_amount = if config.protocol_fee_bps > 0 && swap.price > 0 { - (swap.price * config.protocol_fee_bps as i128) / 10000 - } else { - 0 - }; - let seller_amount = swap.price - fee_amount; - token_client.transfer( - &env.current_contract_address(), - &swap.seller, - &seller_amount, - ); - if fee_amount > 0 { - token_client.transfer( - &env.current_contract_address(), - &config.treasury, - &fee_amount, - ); - } - - if swap.collateral_amount > 0 { - if let Some(collateral) = env - .storage() - .persistent() - .get::<_, i128>(&DataKey::SwapCollateral(swap_id)) - { - token_client.transfer( - &env.current_contract_address(), - &swap.seller, - &collateral, - ); - env.storage() - .persistent() - .remove(&DataKey::SwapCollateral(swap_id)); - } - } - - swap.status = SwapStatus::Completed; - } - - swap::save_swap(&env, swap_id, &swap); - env.storage() - .persistent() - .remove(&DataKey::ActiveSwap(swap.ip_id)); - Self::append_history(&env, swap_id, swap.status.clone()); - - env.events().publish( - (soroban_sdk::symbol_short!("arb_dec"),), - ArbitratedEvent { - swap_id, - arbitrator: arbitrator.clone(), - refunded: refund, - }, - ); - } + /// #781: Disabled. This function never validated `arbitrator` against + /// any stored arbitrator/committee — any caller could pass any address + /// and drain any disputed swap through it, fully bypassing the + /// committee/evidence/timelock/bond system added in #781. Left in place + /// (rather than deleted) only to preserve its signature/selector; + /// unconditionally rejected pending a follow-up issue to migrate it onto + /// the `ArbitratorCommittee` model, at which point it can call + /// `arbitrate_dispute`/`execute_ruling` per swap instead of moving funds + /// directly. + pub fn batch_arbitrate_swaps( + env: Env, + _swap_ids: Vec, + _arbitrator: Address, + _refund: bool, + ) { + env.panic_with_error(Error::from_contract_error( + ContractError::BatchArbitrationDisabled as u32, + )); } // ── #358: Swap Timeout Escalation ───────────────────────────────────────── @@ -4849,14 +5235,20 @@ impl AtomicSwap { // #[cfg(test)] // mod escrow_tests; -// FIXME: pre-existing compile errors from merge conflict - re-enable after fix -// #[cfg(test)] -// mod arbitration_tests; +// #781: re-enabled — was blocked by 3 pre-existing, unrelated compile errors +// (a dropped `commit_ip` arg, and two obsolete `accept_swap_with_quantity` +// calls to a since-removed function; both fixed / removed in this PR). +#[cfg(test)] +mod arbitration_tests; // FIXME: pre-existing compile errors from merge conflict - re-enable after fix // include!("multi_signer_tests.rs"); // FIXME: pre-existing compile errors from merge conflict - re-enable after fix +// (also has pre-existing runtime failures in test_batch_reveal_keys_* unrelated +// to #781 — a protocol-fee transfer to protocol_config().treasury's hardcoded +// placeholder address fails for lack of a trustline; same root cause noted in +// docs/threat-model.md's #781 update, left unfixed here as out of scope). // #[cfg(test)] // mod batch_swap_features_tests; diff --git a/contracts/atomic_swap/src/types.rs b/contracts/atomic_swap/src/types.rs index 88c2777..66efabb 100644 --- a/contracts/atomic_swap/src/types.rs +++ b/contracts/atomic_swap/src/types.rs @@ -201,6 +201,97 @@ pub struct ArbitratedEvent { pub refunded: bool, } +// ── #781: M-of-N Arbitrator Committee, Time-Locked Ruling, Dispute Bond ──────── + +/// A designated committee of `signers`, `threshold` of whom must jointly +/// authorize a ruling. Replaces the single trusted `Address` previously stored +/// under `DataKey::SwapArbitrator`. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ArbitratorCommittee { + pub signers: Vec
, + pub threshold: u32, +} + +/// A ruling entered by the committee but not yet executed. `evidence_hashes` is +/// a snapshot of `DisputeEvidence` read from storage at ruling-entry time (not +/// supplied by the signers), so outside observers can verify the ruling +/// actually references submitted evidence. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct PendingRuling { + pub refund: bool, + pub ruled_at: u64, + pub evidence_hashes: Vec>, + pub ruled_by: Vec
, +} + +/// Non-refundable dispute bonds deposited by the buyer and/or seller on their +/// first evidence submission for a swap. Zero means that party has not +/// submitted evidence (or hasn't been charged yet). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DisputeBonds { + pub buyer_bond: i128, + pub seller_bond: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct ArbitratorCommitteeSetEvent { + pub swap_id: u64, + pub signers: Vec
, + pub threshold: u32, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RulingEnteredEvent { + pub swap_id: u64, + pub refund: bool, + pub ruled_at: u64, + pub evidence_hashes: Vec>, + pub ruled_by: Vec
, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RulingCancelledEvent { + pub swap_id: u64, + pub cancelled_by: Vec
, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct RulingExecutedEvent { + pub swap_id: u64, + pub refund: bool, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DisputeBondDepositedEvent { + pub swap_id: u64, + pub submitter: Address, + pub bond_amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DisputeBondForfeitedEvent { + pub swap_id: u64, + pub submitter: Address, + pub bond_amount: i128, +} + +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct DisputeBondRefundedEvent { + pub swap_id: u64, + pub submitter: Address, + pub bond_amount: i128, +} + // ── #360: Admin Rollback Event ─────────────────────────────────────────────── #[contracttype] diff --git a/docs/threat-model.md b/docs/threat-model.md index d802113..7e8d913 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -178,12 +178,46 @@ The dispute resolution mechanism allows a designated admin to adjudicate contest **Impact**: Fraudulent dispute outcomes; direct loss of buyer funds or seller IP. **Mitigations**: -- Admin role must be a **multi-sig account** (minimum 2-of-3 threshold; 3-of-5 recommended for high-value deployments) -- All admin rulings are recorded on-chain with the admin address and ledger timestamp — fully auditable -- A **48-hour time-lock** between ruling and fund release gives the losing party time to escalate off-chain -- Admin key rotation is supported via contract upgrade path; rotation procedure must be documented before mainnet - -**Status**: ⚠️ Partially mitigated — depends on operator deploying multi-sig correctly +- Per-swap dispute rulings now require an **M-of-N arbitrator committee** + (`set_arbitrator`, minimum 2-of-3 threshold, 3-of-5 or larger supported) — + `arbitrate_dispute` requires `threshold` of the committee's `signers` to + jointly `require_auth()` a ruling in one call; no single key can rule alone. +- A ruling is bound to submitted evidence: `arbitrate_dispute` rejects a + ruling if `DisputeEvidence` is empty, and the evidence hashes actually + considered (read from storage, not signer-supplied) are recorded in + `RulingEnteredEvent` for outside audit. +- A **48-hour time-lock** (`execute_ruling`, `ProtocolConfig.arbitration_ruling_delay_secs`) + separates ruling entry from fund release; `cancel_pending_ruling` lets the + same committee threshold void a ruling within that window. +- All transitions are individually auditable: `ArbitratorCommitteeSetEvent`, + `RulingEnteredEvent`, `RulingCancelledEvent`, `RulingExecutedEvent`. +- **Not covered by the above, still open**: the contract's own `Admin` role + (`DataKey::Admin`) remains a single `Address` — it alone appoints the + arbitrator committee and is unaffected by this mechanism. More directly, + `resolve_dispute` still lets that single admin resolve any disputed swap + directly, completely bypassing the committee/evidence/timelock/bond system + described above — a real, disclosed gap, recommended as a follow-up issue + (either remove `resolve_dispute`'s direct fund-movement path or route it + through the same committee/timelock). `admin_rollback_swap` and + `auto_refund_timeout` are narrower emergency/timeout escape hatches that + remain single-admin/permissionless by design, but now correctly refund + (never forfeit) any dispute bond in flight if they fire instead of a + committee ruling, so they no longer orphan bonded value. +- `batch_arbitrate_swaps` — previously did not validate its caller against + any stored arbitrator at all — is now disabled (always reverts) pending a + follow-up migration onto the committee model. `arbitrate_swap`, a + collateral-aware duplicate of the old single-key `arbitrate_dispute` that + never checked swap status, is now permanently unreachable since nothing + populates the legacy `SwapArbitrator` key anymore; this also closes a + pre-existing double-payout risk where it could still be called after + `resolve_dispute` had already paid out a swap. +- Admin key rotation is supported via contract upgrade path; rotation + procedure must still be documented before mainnet. + +**Status**: ⚠️ Partially mitigated — per-swap arbitration is now committee-gated, +evidence-bound, and time-locked, but the contract `Admin` role itself is still +single-key and `resolve_dispute` remains an admin-direct bypass of the new +safeguards. --- @@ -194,12 +228,23 @@ The dispute resolution mechanism allows a designated admin to adjudicate contest **Impact**: Counterparty funds locked; griefing / DoS against legitimate swap completion. **Mitigations**: -- Disputes require an **on-chain evidence hash** (`dispute_evidence` field) submitted at filing time — no evidence, no dispute -- A **non-refundable dispute bond** (minimum 1 XLM or 10% of swap price, whichever is greater) is forfeited if the dispute is ruled frivolous +- Disputes require an **on-chain evidence hash** (`submit_dispute_evidence`) — + no evidence, no dispute, and (see #13) no evidence means `arbitrate_dispute` + cannot enter a ruling at all. +- A **non-refundable dispute bond** (`MIN_DISPUTE_BOND`, the greater of 1 XLM + or 10% of swap price) is charged on a party's first evidence submission and + forfeited to the contract admin if the committee's ruling goes against that + party; refunded in full if the ruling goes their way, or if the dispute is + instead resolved via one of the non-committee paths in #13 (no ruling ⇒ no + forfeiture). - Disputes must be filed within `dispute_period` ledgers of the triggering event; late filings are rejected by the contract - Repeated frivolous filings from the same address are rate-limited by the admin -**Status**: ⚠️ Partially mitigated — bond amount and evidence format must be configured by operator +**Status**: ⚠️ Partially mitigated — evidence + bond are now enforced in code +with a fixed minimum; bond forfeiture destination is the contract admin +address rather than the protocol treasury (`protocol_config().treasury` +resolves to a hardcoded placeholder today — a pre-existing, separate storage +bug, not fixed by this change). --- From 63daf53ac7de9bcfc972d4d806c5d05c045d346c Mon Sep 17 00:00:00 2001 From: liamscroxx-svg Date: Tue, 25 Aug 2026 05:40:26 +0000 Subject: [PATCH 2/2] fix(atomic_swap): add missing #781 ContractError variants to lib.rs The M-of-N committee/timelock/bond error variants (NotACommitteeSigner, DuplicateSigner, InsufficientSignatures, CommitteeSizeTooSmall, EvidenceRequired, RulingAlreadyPending, NoPendingRuling, TimelockNotElapsed, RulingFinalized, BatchArbitrationDisabled) were only added to the errors.rs reference mirror, not to the authoritative #[contracterror] enum in lib.rs that the new arbitration code actually references, breaking the build. Also fixes a discriminant collision in errors.rs where the mirrored codes reused 54 (already OracleDeviationExceeded). --- contracts/atomic_swap/src/errors.rs | 22 ++++++++++++---------- contracts/atomic_swap/src/lib.rs | 11 +++++++++++ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/contracts/atomic_swap/src/errors.rs b/contracts/atomic_swap/src/errors.rs index ac3835a..c203c8c 100644 --- a/contracts/atomic_swap/src/errors.rs +++ b/contracts/atomic_swap/src/errors.rs @@ -54,15 +54,17 @@ pub enum ContractError { // Partial quantity swap errors InvalidQuantity = 39, InvalidReferralFeeBps = 61, + // #784: Oracle price attestation exceeds the configured max deviation + OracleDeviationExceeded = 54, // #781: Arbitrator committee / time-locked ruling / dispute bond errors - NotACommitteeSigner = 54, - DuplicateSigner = 55, - InsufficientSignatures = 56, - CommitteeSizeTooSmall = 57, - EvidenceRequired = 58, - RulingAlreadyPending = 59, - NoPendingRuling = 60, - TimelockNotElapsed = 62, - RulingFinalized = 63, - BatchArbitrationDisabled = 64, + NotACommitteeSigner = 55, + DuplicateSigner = 56, + InsufficientSignatures = 57, + CommitteeSizeTooSmall = 58, + EvidenceRequired = 59, + RulingAlreadyPending = 60, + NoPendingRuling = 62, + TimelockNotElapsed = 63, + RulingFinalized = 64, + BatchArbitrationDisabled = 65, } diff --git a/contracts/atomic_swap/src/lib.rs b/contracts/atomic_swap/src/lib.rs index ac1ff73..c7fdd5b 100644 --- a/contracts/atomic_swap/src/lib.rs +++ b/contracts/atomic_swap/src/lib.rs @@ -90,6 +90,17 @@ pub enum ContractError { /// #784: Oracle price attestation exceeds the configured max deviation /// from the last accepted price. OracleDeviationExceeded = 54, + /// #781: Arbitrator committee / time-locked ruling / dispute bond errors + NotACommitteeSigner = 55, + DuplicateSigner = 56, + InsufficientSignatures = 57, + CommitteeSizeTooSmall = 58, + EvidenceRequired = 59, + RulingAlreadyPending = 60, + NoPendingRuling = 62, + TimelockNotElapsed = 63, + RulingFinalized = 64, + BatchArbitrationDisabled = 65, } // ── TTL ───────────────────────────────────────────────────────────────────────