Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions contracts/invoice-escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
Expand Down Expand Up @@ -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,
<Address as IntoVal<Env, soroban_sdk::Val>>::into_val(&data.token, &env),
<Option<Address> as IntoVal<Env, soroban_sdk::Val>>::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,
Expand Down
2 changes: 1 addition & 1 deletion contracts/invoice-escrow/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
14 changes: 14 additions & 0 deletions contracts/invoice-escrow/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions contracts/payment-distributor/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>, new_recipient: &Address) {
let topics = (Symbol::new(env, "fee_recipient_updated"),);
Expand Down
29 changes: 29 additions & 0 deletions contracts/payment-distributor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions contracts/payment-distributor/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ pub fn get_admin(env: &Env) -> Option<Address> {
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<Address> {
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()
Expand Down
73 changes: 72 additions & 1 deletion contracts/payment-distributor/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions contracts/payment-distributor/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down