From 2c1c932f46a45fc93c58f95116494e0925adc649 Mon Sep 17 00:00:00 2001 From: Stephan-Thomas Date: Wed, 26 Aug 2026 21:12:22 +0100 Subject: [PATCH 1/2] feat(payment-distributor): implement two-step admin role rotation --- contracts/payment-distributor/src/events.rs | 5 ++ contracts/payment-distributor/src/lib.rs | 29 ++++++++ contracts/payment-distributor/src/storage.rs | 12 ++++ contracts/payment-distributor/src/test.rs | 73 +++++++++++++++++++- contracts/payment-distributor/src/types.rs | 1 + 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/contracts/payment-distributor/src/events.rs b/contracts/payment-distributor/src/events.rs index 0ffe2c5..e63debd 100644 --- a/contracts/payment-distributor/src/events.rs +++ b/contracts/payment-distributor/src/events.rs @@ -9,6 +9,11 @@ pub fn initialized(env: &Env, admin: &Address) { env.events().publish(topics, admin.clone()); } +pub fn admin_transferred(env: &Env, previous_admin: &Address, new_admin: &Address) { + let topics = (Symbol::new(env, "admin_transferred"),); + env.events().publish(topics, (previous_admin.clone(), new_admin.clone())); +} + /// Issue #122: Fee recipient updated event pub fn fee_recipient_updated(env: &Env, old_recipient: Option
, new_recipient: &Address) { let topics = (Symbol::new(env, "fee_recipient_updated"),); diff --git a/contracts/payment-distributor/src/lib.rs b/contracts/payment-distributor/src/lib.rs index 53982de..a8c5935 100644 --- a/contracts/payment-distributor/src/lib.rs +++ b/contracts/payment-distributor/src/lib.rs @@ -627,6 +627,35 @@ impl PaymentDistributor { storage::get_admin(&env).ok_or(Error::NotInit) } + /// Issue #380: Transfer admin ownership (Step 1). + /// Proposes a new admin. The new admin must call `accept_admin` to finalize. + pub fn transfer_admin(env: Env, current_admin: Address, new_admin: Address) -> Result<(), Error> { + let stored_admin = storage::get_admin(&env).ok_or(Error::NotInit)?; + if current_admin != stored_admin { + return Err(Error::Unauthorized); + } + current_admin.require_auth(); + storage::set_pending_admin(&env, &new_admin); + Ok(()) + } + + /// Issue #380: Accept admin ownership (Step 2). + /// Finalizes the transfer of admin ownership. + pub fn accept_admin(env: Env, new_admin: Address) -> Result<(), Error> { + let pending = storage::get_pending_admin(&env).ok_or(Error::Unauthorized)?; + if new_admin != pending { + return Err(Error::Unauthorized); + } + new_admin.require_auth(); + + let previous_admin = storage::get_admin(&env).ok_or(Error::NotInit)?; + storage::set_admin(&env, &new_admin); + storage::clear_pending_admin(&env); + + events::admin_transferred(&env, &previous_admin, &new_admin); + Ok(()) + } + /// Issue #122: Set the fee recipient address for platform fees. /// Only the admin can update the fee recipient. /// Emits a fee_recipient_updated event for audit trails. diff --git a/contracts/payment-distributor/src/storage.rs b/contracts/payment-distributor/src/storage.rs index a3875a0..8e9088b 100644 --- a/contracts/payment-distributor/src/storage.rs +++ b/contracts/payment-distributor/src/storage.rs @@ -28,6 +28,18 @@ pub fn get_admin(env: &Env) -> Option
{ env.storage().instance().get(&StorageKey::Admin) } +pub fn set_pending_admin(env: &Env, admin: &Address) { + env.storage().instance().set(&StorageKey::PendingAdmin, admin); +} + +pub fn get_pending_admin(env: &Env) -> Option
{ + env.storage().instance().get(&StorageKey::PendingAdmin) +} + +pub fn clear_pending_admin(env: &Env) { + env.storage().instance().remove(&StorageKey::PendingAdmin); +} + pub fn set_fee_recipient(env: &Env, fee_recipient: &Address) { env.storage() .instance() diff --git a/contracts/payment-distributor/src/test.rs b/contracts/payment-distributor/src/test.rs index 711a570..ce70d73 100644 --- a/contracts/payment-distributor/src/test.rs +++ b/contracts/payment-distributor/src/test.rs @@ -4923,8 +4923,79 @@ fn acceptance_criteria_no_unexpected_dust_after_completed_distribution() { let distributor = ctx.payment_token.balance(&ctx.distributor_id); let escrow = ctx.payment_token.balance(&ctx.escrow_id); - // No dust in distributor +// No dust in distributor assert_eq!(distributor, 0); // All escrowed funds distributed or reserved assert_eq!(escrow, 0); } + +// ────────────────────────────────────────────────────────────────────────────── +// ADMIN TRANSFER TWO-STEP PROCESS TESTS +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_successful_two_step_admin_transfer() { + let env = Env::default(); + env.mock_all_auths(); + + let ctx = setup(&env, 0, false); + let new_admin = Address::generate(&env); + + // Step 1: transfer_admin + ctx.distributor.transfer_admin(&ctx.admin, &new_admin); + + // Step 2: accept_admin + ctx.distributor.accept_admin(&new_admin); + + assert_eq!(ctx.distributor.get_admin(), new_admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_unauthorized_caller_on_transfer_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let ctx = setup(&env, 0, false); + let unauthorized = Address::generate(&env); + let new_admin = Address::generate(&env); + + // Fails with Unauthorized + ctx.distributor.transfer_admin(&unauthorized, &new_admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] +fn test_unauthorized_caller_on_accept_admin() { + let env = Env::default(); + env.mock_all_auths(); + + let ctx = setup(&env, 0, false); + let new_admin = Address::generate(&env); + let unauthorized = Address::generate(&env); + + ctx.distributor.transfer_admin(&ctx.admin, &new_admin); + + // Fails with Unauthorized (wrong caller) + ctx.distributor.accept_admin(&unauthorized); +} + +#[test] +fn test_chained_transfers_and_event_log_emission() { + let env = Env::default(); + env.mock_all_auths(); + + let ctx = setup(&env, 0, false); + let admin_1 = Address::generate(&env); + let admin_2 = Address::generate(&env); + + // Transfer 1 + ctx.distributor.transfer_admin(&ctx.admin, &admin_1); + ctx.distributor.accept_admin(&admin_1); + assert_eq!(ctx.distributor.get_admin(), admin_1); + + // Transfer 2 + ctx.distributor.transfer_admin(&admin_1, &admin_2); + ctx.distributor.accept_admin(&admin_2); + assert_eq!(ctx.distributor.get_admin(), admin_2); +} diff --git a/contracts/payment-distributor/src/types.rs b/contracts/payment-distributor/src/types.rs index efa4e16..ff43a3f 100644 --- a/contracts/payment-distributor/src/types.rs +++ b/contracts/payment-distributor/src/types.rs @@ -6,6 +6,7 @@ use crate::errors::Error; #[derive(Clone, Debug, Eq, PartialEq)] pub enum StorageKey { Admin, + PendingAdmin, Distribution(soroban_sdk::Address, soroban_sdk::Symbol), /// Ordered platform fee tiers. FeeTiers, From a7549ec88a313b92a22fc80feff3a53b0afd0331 Mon Sep 17 00:00:00 2001 From: Stephan-Thomas Date: Wed, 26 Aug 2026 21:46:28 +0100 Subject: [PATCH 2/2] feat(invoice-escrow): implement complete dispute resolution lifecycle with timeout and default fallback --- contracts/invoice-escrow/src/errors.rs | 6 + contracts/invoice-escrow/src/events.rs | 16 ++ contracts/invoice-escrow/src/lib.rs | 135 ++++++++++++++++ contracts/invoice-escrow/src/storage.rs | 14 ++ contracts/invoice-escrow/src/test.rs | 200 ++++++++++++++++++++++++ contracts/invoice-escrow/src/types.rs | 16 ++ 6 files changed, 387 insertions(+) diff --git a/contracts/invoice-escrow/src/errors.rs b/contracts/invoice-escrow/src/errors.rs index 8bea222..9199d3b 100644 --- a/contracts/invoice-escrow/src/errors.rs +++ b/contracts/invoice-escrow/src/errors.rs @@ -90,4 +90,10 @@ pub enum Error { InvalidLimit = 37, /// Pagination limit exceeds maximum allowed page size. LimitExceeded = 38, + /// Escrow is not currently disputed. + NotDisputed = 39, + /// Dispute is already resolved. + AlreadyResolved = 40, + /// Dispute resolution period has timed out. + DisputeTimedOut = 41, } diff --git a/contracts/invoice-escrow/src/events.rs b/contracts/invoice-escrow/src/events.rs index 4bd1ac8..5d4cded 100644 --- a/contracts/invoice-escrow/src/events.rs +++ b/contracts/invoice-escrow/src/events.rs @@ -195,3 +195,19 @@ pub fn funding_finalised(env: &Env, invoice_id: BytesN<32>, total_raised: i128, (total_raised, seller.clone()), ); } + +/// Publish dispute raised event. +pub fn dispute_raised(env: &Env, inv_id: Symbol, raiser: &Address, reason: &soroban_sdk::Bytes) { + env.events().publish( + (Symbol::new(env, "DisputeRaised"),), + (inv_id, raiser.clone(), reason.clone()), + ); +} + +/// Publish dispute resolved event. +pub fn dispute_resolved(env: &Env, inv_id: Symbol, admin: &Address, favour: Symbol) { + env.events().publish( + (Symbol::new(env, "DisputeResolved"),), + (inv_id, admin.clone(), favour), + ); +} diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index 8929b30..06cd0f4 100644 --- a/contracts/invoice-escrow/src/lib.rs +++ b/contracts/invoice-escrow/src/lib.rs @@ -75,6 +75,7 @@ impl InvoiceEscrow { paused: false, whitelist_enabled: false, min_investment: 0, + dispute_timeout_secs: 604_800, }; storage::set_config(&env, &config); Ok(()) @@ -781,6 +782,140 @@ impl InvoiceEscrow { Ok(config.paused) } + /// Raise a dispute on a Funded escrow. + pub fn raise_dispute( + env: Env, + caller: Address, + invoice_id: Symbol, + reason: soroban_sdk::Bytes, + ) -> Result<(), Error> { + caller.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + ensure_not_paused(&config)?; + + let mut data = storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.status != EscrowStatus::Funded { + return Err(Error::EscrowNotFunded); + } + + let dispute = types::DisputeData { + raiser: caller.clone(), + reason: reason.clone(), + raised_at: env.ledger().timestamp(), + resolved: false, + }; + storage::set_dispute(&env, &invoice_id, &dispute); + + data.status = EscrowStatus::Disputed; + storage::set_escrow(&env, invoice_id.clone(), &data); + + events::dispute_raised(&env, invoice_id.clone(), &caller, &reason); + events::escrow_status_changed(&env, invoice_id, EscrowStatus::Disputed, env.ledger().timestamp()); + Ok(()) + } + + /// Resolve a dispute by admin or via timeout. + pub fn resolve_dispute( + env: Env, + admin: Address, + invoice_id: Symbol, + favour: Symbol, + ) -> Result<(), Error> { + admin.require_auth(); + let config = storage::get_config(&env).ok_or(Error::NotInit)?; + if config.admin != admin { + return Err(Error::Unauthorized); + } + + let mut data = storage::get_escrow(&env, invoice_id.clone()).ok_or(Error::EscrowNotFound)?; + if data.status != EscrowStatus::Disputed { + return Err(Error::NotDisputed); + } + + let mut dispute = storage::get_dispute(&env, &invoice_id).ok_or(Error::NotDisputed)?; + if dispute.resolved { + return Err(Error::AlreadyResolved); + } + + let current_ts = env.ledger().timestamp(); + let timeout = dispute.raised_at.saturating_add(config.dispute_timeout_secs); + + let actual_favour = if current_ts > timeout { + Symbol::new(&env, "buyer") + } else { + favour.clone() + }; + + if actual_favour != Symbol::new(&env, "buyer") && actual_favour != Symbol::new(&env, "seller") { + return Err(Error::Unauthorized); + } + + dispute.resolved = true; + storage::set_dispute(&env, &invoice_id, &dispute); + + let token = token::Client::new(&env, &data.token); + let contract = env.current_contract_address(); + + if actual_favour == Symbol::new(&env, "buyer") { + let amount_to_refund = data.funded_amt; + let funder_opt = data.funder.clone(); + + if let Some(distributor) = config.payment_distributor.as_ref() { + token.transfer(&contract, distributor, &amount_to_refund); + env.invoke_contract::<()>( + distributor, + &Symbol::new(&env, DISTRIBUTE_REFUND_FN), + soroban_sdk::vec![ + &env, + contract.to_val(), + invoice_id.clone().into_val(&env), + soroban_sdk::vec![ + &env, +
>::into_val(&data.token, &env), + as IntoVal>::into_val(&funder_opt, &env) + ].into_val(&env), + soroban_sdk::vec![&env, amount_to_refund].into_val(&env), + (EscrowStatus::Refunded as u32).into_val(&env) + ], + ); + } else { + if let Some(funder) = &funder_opt { + if data.funded_amt > 0 { + let funder_amt = storage::get_funder_amount(&env, invoice_id.clone(), funder); + let pro_rata_refund = amount_to_refund + .checked_mul(funder_amt) + .unwrap_or(0) + .checked_div(data.funded_amt) + .unwrap_or(0); + if pro_rata_refund > 0 { + token.transfer(&contract, funder, &pro_rata_refund); + } + } + } + } + data.status = EscrowStatus::Refunded; + events::escrow_refunded(&env, invoice_id.clone(), amount_to_refund); + } else { + if data.funded_amt > 0 { + token.transfer(&contract, &data.seller, &data.funded_amt); + } + data.status = EscrowStatus::Settled; + events::payment_settled(&env, invoice_id.clone(), data.funded_amt, 0, 0); + } + + storage::set_escrow(&env, invoice_id.clone(), &data); + + env.invoke_contract::<()>( + &data.inv_token, + &Symbol::new(&env, "set_transfer_locked"), + soroban_sdk::vec![&env, contract.to_val(), false.into_val(&env)], + ); + + events::dispute_resolved(&env, invoice_id.clone(), &admin, actual_favour); + events::escrow_status_changed(&env, invoice_id, data.status, current_ts); + Ok(()) + } + /// Admin-only: configure the emergency multi-sig admin set and threshold. pub fn set_emergency_config( env: Env, diff --git a/contracts/invoice-escrow/src/storage.rs b/contracts/invoice-escrow/src/storage.rs index e384d9a..1110d36 100644 --- a/contracts/invoice-escrow/src/storage.rs +++ b/contracts/invoice-escrow/src/storage.rs @@ -247,3 +247,17 @@ pub fn set_escrow_id_by_index(env: &soroban_sdk::Env, index: u32, invoice_id: &S .persistent() .set(&StorageKey::EscrowIdByIndex(index), invoice_id); } + +/// Load the dispute data for an escrow. +pub fn get_dispute(env: &soroban_sdk::Env, inv_id: &Symbol) -> Option { + env.storage() + .persistent() + .get(&StorageKey::Dispute(inv_id.clone())) +} + +/// Save dispute data for an escrow. +pub fn set_dispute(env: &soroban_sdk::Env, inv_id: &Symbol, data: &crate::types::DisputeData) { + env.storage() + .persistent() + .set(&StorageKey::Dispute(inv_id.clone()), data); +} diff --git a/contracts/invoice-escrow/src/test.rs b/contracts/invoice-escrow/src/test.rs index 62e9fc1..fd6cdb7 100644 --- a/contracts/invoice-escrow/src/test.rs +++ b/contracts/invoice-escrow/src/test.rs @@ -8617,3 +8617,203 @@ fn test_fund_escrow_signed_future_timestamp_succeeds() { let result = c.fund_escrow_signed(&Symbol::new(&env, "inv1"), &buyer, &500, &1, &(now + 3600)); assert!(result.is_ok()); } + +// ────────────────────────────────────────────────────────────────────────────── +// DISPUTE RESOLUTION TESTS +// ────────────────────────────────────────────────────────────────────────────── + +#[test] +fn test_dispute_raise_by_buyer() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + let pt = create_token(&env, &admin); + let inv_token = create_invoice_token(&env, &admin); + + let contract_id = env.register_contract(None, InvoiceEscrow); + let c = InvoiceEscrowClient::new(&env, &contract_id); + c.initialize(&admin, &300); + + pt.asset.mint(&buyer, &1000); + let now = env.ledger().timestamp(); + let invoice_id = Symbol::new(&env, "inv_disp"); + + c.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &900, + &(now + 7200), + &pt.address, + &inv_token, + &test_commitment(&env, "disp1"), + &None, + ); + + c.fund_escrow(&invoice_id, &buyer, &900); + + assert_eq!(c.get_escrow_status(&invoice_id), EscrowStatus::Funded); + + let reason = soroban_sdk::Bytes::from_slice(&env, b"Quality issue"); + c.raise_dispute(&buyer, &invoice_id, &reason); + + assert_eq!(c.get_escrow_status(&invoice_id), EscrowStatus::Disputed); +} + +#[test] +fn test_dispute_resolution_in_favor_of_seller() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + let pt = create_token(&env, &admin); + let inv_token = create_invoice_token(&env, &admin); + + let contract_id = env.register_contract(None, InvoiceEscrow); + let c = InvoiceEscrowClient::new(&env, &contract_id); + c.initialize(&admin, &300); + + pt.asset.mint(&buyer, &1000); + let now = env.ledger().timestamp(); + let invoice_id = Symbol::new(&env, "inv_disp_s"); + + c.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &900, + &(now + 7200), + &pt.address, + &inv_token, + &test_commitment(&env, "disp2"), + &None, + ); + + c.fund_escrow(&invoice_id, &buyer, &900); + let reason = soroban_sdk::Bytes::from_slice(&env, b"Quality issue"); + c.raise_dispute(&buyer, &invoice_id, &reason); + + // Resolve in favor of seller + c.resolve_dispute(&admin, &invoice_id, &Symbol::new(&env, "seller")); + + assert_eq!(c.get_escrow_status(&invoice_id), EscrowStatus::Settled); + // Seller should receive the funded amount (900) + assert_eq!(pt.asset.balance(&seller), 900); +} + +#[test] +fn test_dispute_resolution_in_favor_of_buyer() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + let pt = create_token(&env, &admin); + let inv_token = create_invoice_token(&env, &admin); + + let contract_id = env.register_contract(None, InvoiceEscrow); + let c = InvoiceEscrowClient::new(&env, &contract_id); + c.initialize(&admin, &300); + + pt.asset.mint(&buyer, &1000); + let now = env.ledger().timestamp(); + let invoice_id = Symbol::new(&env, "inv_disp_b"); + + c.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &900, + &(now + 7200), + &pt.address, + &inv_token, + &test_commitment(&env, "disp3"), + &None, + ); + + c.fund_escrow(&invoice_id, &buyer, &900); + + let buyer_balance_after_fund = pt.asset.balance(&buyer); + assert_eq!(buyer_balance_after_fund, 100); + + let reason = soroban_sdk::Bytes::from_slice(&env, b"Quality issue"); + c.raise_dispute(&buyer, &invoice_id, &reason); + + // Resolve in favor of buyer + c.resolve_dispute(&admin, &invoice_id, &Symbol::new(&env, "buyer")); + + assert_eq!(c.get_escrow_status(&invoice_id), EscrowStatus::Refunded); + // Buyer should receive the refund (900), bringing balance back to 1000 + assert_eq!(pt.asset.balance(&buyer), 1000); +} + +#[test] +fn test_dispute_timeout_triggering_default_refund() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + let pt = create_token(&env, &admin); + let inv_token = create_invoice_token(&env, &admin); + + let contract_id = env.register_contract(None, InvoiceEscrow); + let c = InvoiceEscrowClient::new(&env, &contract_id); + c.initialize(&admin, &300); + + pt.asset.mint(&buyer, &1000); + let now = env.ledger().timestamp(); + let invoice_id = Symbol::new(&env, "inv_disp_to"); + + c.create_escrow( + &invoice_id, + &seller, + &seller, + &1000, + &900, + &(now + 7200), + &pt.address, + &inv_token, + &test_commitment(&env, "disp4"), + &None, + ); + + c.fund_escrow(&invoice_id, &buyer, &900); + + let reason = soroban_sdk::Bytes::from_slice(&env, b"Quality issue"); + c.raise_dispute(&buyer, &invoice_id, &reason); + + // Fast-forward past the default 7-day timeout (604800 seconds) + env.ledger().set(soroban_sdk::testutils::LedgerInfo { + timestamp: now + 604801, + protocol_version: 20, + sequence_number: 1000, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 10, + }); + + // Resolve, pass any favor. It should fallback to buyer because of timeout. + c.resolve_dispute(&admin, &invoice_id, &Symbol::new(&env, "seller")); + + assert_eq!(c.get_escrow_status(&invoice_id), EscrowStatus::Refunded); + // Buyer should get the refund + assert_eq!(pt.asset.balance(&buyer), 1000); +} + diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index c7145d4..6510f91 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -29,6 +29,8 @@ pub enum StorageKey { EscrowCount, /// Persistent: invoice_id indexed by sequential creation order. EscrowIdByIndex(u32), + /// Persistent: dispute data for an escrow by invoice_id. + Dispute(soroban_sdk::Symbol), } /// Global contract configuration. @@ -51,6 +53,8 @@ pub struct Config { /// `0` disables the floor (only `amount > 0` is required). Completing the /// remaining capacity below this floor is always allowed. pub min_investment: i128, + /// Dispute timeout in seconds before default fallback triggers (default: 604800s / 7 days). + pub dispute_timeout_secs: u64, } /// Lifecycle status of an escrow. @@ -70,6 +74,18 @@ pub enum EscrowStatus { /// Cancelled by seller while still in Created state and never funded /// (locked out once any investor contribution has been received). Cancelled = 4, + /// Dispute raised by buyer or seller, awaiting admin resolution. + Disputed = 5, +} + +/// Metadata for a raised dispute. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct DisputeData { + pub raiser: soroban_sdk::Address, + pub reason: soroban_sdk::Bytes, + pub raised_at: u64, + pub resolved: bool, } /// Per-invoice escrow data stored in persistent storage.