diff --git a/contracts/invoice-escrow/src/lib.rs b/contracts/invoice-escrow/src/lib.rs index c654064..f96f7bf 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(()) @@ -876,6 +877,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 43e0c3c..0d1b7d8 100644 --- a/contracts/invoice-escrow/src/storage.rs +++ b/contracts/invoice-escrow/src/storage.rs @@ -321,4 +321,4 @@ pub fn set_escrow_id_by_index(env: &soroban_sdk::Env, index: u32, invoice_id: &S env.storage() .persistent() .set(&StorageKey::EscrowIdByIndex(index), invoice_id); -} \ No newline at end of file +} diff --git a/contracts/invoice-escrow/src/types.rs b/contracts/invoice-escrow/src/types.rs index 8cd3362..847a33b 100644 --- a/contracts/invoice-escrow/src/types.rs +++ b/contracts/invoice-escrow/src/types.rs @@ -76,6 +76,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. @@ -95,6 +97,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. 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 452d5d5..8bcde77 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,