diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 54e4142..d1df8f2 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -357,6 +357,8 @@ fn load_invoice(env: &Env, id: u64) -> Invoice { overflow_behavior: OverflowBehavior::Reject, cross_chain_ref: None, require_kyc: false, + arbiter: None, + disputed: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(env), @@ -702,6 +704,171 @@ impl SplitContract { env.storage().persistent().set(&soroban_sdk::symbol_short!("dex_ctr"), &contract); } + // ----------------------------------------------------------------------- + // Issue #189: Admin rotation + // ----------------------------------------------------------------------- + + /// Propose a new admin. Requires current admin auth. + pub fn propose_admin(env: Env, admin: Address, new_admin: Address) { + require_admin(&env); + let _ = admin; + env.storage().instance().set(&pending_admin_key(), &new_admin); + } + + /// Accept the admin role. Requires the proposed admin to authenticate. + pub fn accept_admin(env: Env) { + let pending: Address = env + .storage() + .instance() + .get(&pending_admin_key()) + .expect("no pending admin"); + pending.require_auth(); + env.storage().instance().set(&admin_key(), &pending); + env.storage().instance().remove(&pending_admin_key()); + } + + // ----------------------------------------------------------------------- + // Issue #193: Creator volume cap + // ----------------------------------------------------------------------- + + /// Set a volume cap for a specific creator. Requires admin auth. + /// A cap of 0 means no limit. + pub fn set_creator_volume_cap(env: Env, admin: Address, creator: Address, cap: i128) { + require_admin(&env); + let _ = admin; + assert!(cap >= 0, "cap must be non-negative"); + env.storage().persistent().set(&creator_volume_cap_key(&creator), &cap); + } + + /// Return the volume cap for a creator (0 = no limit). + pub fn get_creator_volume_cap(env: Env, creator: Address) -> i128 { + env.storage() + .persistent() + .get(&creator_volume_cap_key(&creator)) + .unwrap_or(0) + } + + /// Return the volume used toward the cap for a creator. + pub fn get_creator_volume_used(env: Env, creator: Address) -> i128 { + env.storage() + .persistent() + .get(&creator_volume_used_key(&creator)) + .unwrap_or(0) + } + + // ----------------------------------------------------------------------- + // Issue #188: Dispute arbitration + // ----------------------------------------------------------------------- + + /// Set an arbiter address for an invoice. Requires admin auth. + /// Only the arbiter may raise and resolve disputes on this invoice. + pub fn set_arbiter(env: Env, admin: Address, invoice_id: u64, arbiter: Address) { + require_admin(&env); + let _ = admin; + let mut invoice = load_invoice(&env, invoice_id); + invoice.arbiter = Some(arbiter.clone()); + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("set_arb"), &arbiter); + } + + /// Raise a dispute on an invoice. Only the configured arbiter may call this. + /// When disputed, all actions (pay, release, refund, cancel) are blocked. + pub fn raise_dispute(env: Env, invoice_id: u64, arbiter: Address) { + require_not_paused(&env); + arbiter.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + assert!( + invoice.arbiter.as_ref() == Some(&arbiter), + "not the designated arbiter" + ); + assert!(!invoice.disputed, "invoice is already disputed"); + assert!( + invoice.status == InvoiceStatus::Pending, + "invoice is not pending" + ); + + invoice.disputed = true; + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("dispute"), &arbiter); + } + + /// Resolve a dispute — release or refund the invoice. + /// Only the designated arbiter may call this. + pub fn resolve_dispute(env: Env, invoice_id: u64, arbiter: Address, resolution: ResolveAction) { + require_not_paused(&env); + arbiter.require_auth(); + + let mut invoice = load_invoice(&env, invoice_id); + assert!( + invoice.arbiter.as_ref() == Some(&arbiter), + "not the designated arbiter" + ); + assert!(invoice.disputed, "invoice is not disputed"); + + match resolution { + ResolveAction::Release => { + let caller = env.current_contract_address(); + Self::_release(&env, invoice_id, &mut invoice, &caller); + } + ResolveAction::Refund => { + // If the invoice has no payments, mark as cancelled. + if invoice.funded == 0 { + invoice.status = InvoiceStatus::Cancelled; + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("resolve"), &arbiter); + return; + } + + let token_client = token::Client::new( + &env, + &invoice.tokens.get(0).expect("no token"), + ); + let mut totals: Map = Map::new(&env); + for payment in invoice.payments.iter() { + let prev = totals.get(payment.payer.clone()).unwrap_or(0); + totals.set(payment.payer.clone(), prev + payment.amount); + } + let mut total_refunded_amount: i128 = 0; + for (payer, amount) in totals.iter() { + token_client.transfer( + &env.current_contract_address(), + &payer, + &amount, + ); + total_refunded_amount += amount; + events::payer_refunded(&env, invoice_id, &payer, amount); + } + + if invoice.bonus_pool > 0 { + token_client.transfer( + &env.current_contract_address(), + &invoice.creator, + &invoice.bonus_pool, + ); + } + + invoice.status = InvoiceStatus::Refunded; + invoice.completion_time = Some(env.ledger().timestamp()); + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("resolve"), &arbiter); + events::invoice_refunded(&env, invoice_id); + + let total_refunded: i128 = env + .storage() + .persistent() + .get(&total_refunded_key()) + .unwrap_or(0i128); + env.storage().persistent().set( + &total_refunded_key(), + &total_refunded + .checked_add(total_refunded_amount) + .expect("total_refunded overflow"), + ); + } + } + } + // ----------------------------------------------------------------------- // Issue: receipt token factory (Issue 3) // ----------------------------------------------------------------------- @@ -1070,6 +1237,7 @@ impl SplitContract { options.payment_window_secs, options.refund_grace_secs, options.priorities, + options.require_kyc, ) } @@ -1119,6 +1287,7 @@ impl SplitContract { payment_window_secs: Option, refund_grace_secs: Option, priorities: Vec, + require_kyc: bool, ) -> u64 { assert!( recipients.len() == amounts.len(), @@ -1289,6 +1458,43 @@ impl SplitContract { assert!(approved, "governance approval required"); } + // Issue #193: check creator volume cap. + let volume_cap: i128 = env + .storage() + .persistent() + .get(&creator_volume_cap_key(&creator)) + .unwrap_or(0); + if volume_cap > 0 { + let used: i128 = env + .storage() + .persistent() + .get(&creator_volume_used_key(&creator)) + .unwrap_or(0); + assert!( + used.checked_add(total).expect("volume overflow") <= volume_cap, + "creator volume cap exceeded" + ); + env.storage() + .persistent() + .set(&creator_volume_used_key(&creator), &(used + total)); + } + + // Issue #195: if require_kyc, verify all recipients have KYC. + if require_kyc { + let kyc_contract: Address = env + .storage() + .persistent() + .get(&kyc_contract_key()) + .expect("kyc contract not set"); + for recipient in recipients.iter() { + let verified: bool = env.invoke_contract( + &kyc_contract, + &Symbol::new(env, "is_verified"), + (recipient.clone(),).into_val(env), + ); + assert!(verified, "kyc required for recipient"); + } + } if bonus_pool > 0 { let token_client = token::Client::new(env, &token); @@ -1377,7 +1583,9 @@ impl SplitContract { payment_window_secs, refund_grace_secs, cross_chain_ref, - require_kyc: false, + require_kyc, + arbiter: None, + disputed: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(env), @@ -1517,6 +1725,7 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc ); ids.push_back(id); } @@ -1590,6 +1799,7 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc ); if months > 1 { @@ -1743,6 +1953,8 @@ impl SplitContract { bids: source.bids.clone(), min_payment: source.min_payment, min_funding_amount: source.min_funding_amount, + arbiter: source.arbiter.clone(), + disputed: false, priorities: source.priorities.clone(), }; @@ -1823,6 +2035,7 @@ impl SplitContract { let invoice = load_invoice(&env, invoice_id); assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!(!invoice.disputed, "invoice is disputed"); let token_client = token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); token_client.transfer(&payer, &env.current_contract_address(), &deposit); @@ -1854,6 +2067,7 @@ impl SplitContract { let net_paid = deposited - balance; let mut invoice = load_invoice(&env, invoice_id); + assert!(!invoice.disputed, "invoice is disputed"); if net_paid > 0 { assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); @@ -1907,6 +2121,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); assert!( env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed" @@ -2181,6 +2396,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!(!invoice.disputed, "invoice is disputed"); assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); assert!(amount > 0, "payment amount must be positive"); @@ -2271,6 +2487,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!(!invoice.disputed, "invoice is disputed"); assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); assert!(source_amount > 0, "payment amount must be positive"); @@ -2345,6 +2562,7 @@ impl SplitContract { for p in payments.iter() { let inv = load_invoice(&env, p.invoice_id); assert!(inv.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!(!inv.disputed, "invoice is disputed"); assert!( env.ledger().timestamp() <= inv.deadline, "invoice deadline has passed" @@ -2416,6 +2634,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); assert!(!invoice.co_signers.is_empty(), "no co-signers required"); assert!( invoice.co_signers.iter().any(|c| c == signer), @@ -2567,6 +2786,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); assert!(!invoice.frozen, "invoice is already frozen"); invoice.frozen = true; @@ -2723,6 +2943,7 @@ impl SplitContract { pub fn confirm_condition(env: Env, invoice_id: u64) { require_not_paused(&env); let mut invoice = load_invoice(&env, invoice_id); + assert!(!invoice.disputed, "invoice is disputed"); let oracle = invoice.oracle_address.as_ref().expect("no oracle set for invoice"); oracle.require_auth(); invoice.condition_met = true; @@ -2998,6 +3219,7 @@ impl SplitContract { assert!(invoice.creator == creator, "only creator can call stage_release"); assert!(!invoice.frozen, "invoice is frozen"); + assert!(!invoice.disputed, "invoice is disputed"); assert!( invoice.status == InvoiceStatus::Pending, "invoice is not pending" @@ -3121,6 +3343,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!(invoice.creator == creator, "only creator can call partial_release"); assert!(!invoice.frozen, "invoice is frozen"); + assert!(!invoice.disputed, "invoice is disputed"); assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); assert!(amount > 0, "amount must be positive"); assert!(amount <= invoice.funded, "amount exceeds funded balance"); @@ -3588,6 +3811,7 @@ impl SplitContract { None, None, Vec::new(env), // priorities + false, // require_kyc ); env.storage() .persistent() @@ -3610,6 +3834,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); assert!(!invoice.auto_resolve_rules.is_empty(), "no auto-resolve rules defined"); let total: i128 = invoice.amounts.iter().sum(); @@ -3919,6 +4144,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); // If a creator cosigner is set, require both the creator and cosigner auths. if let Some(cos) = invoice.creator_cosigner.clone() { invoice.creator.require_auth(); @@ -4059,6 +4285,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); invoice.creator.require_auth(); invoice.creator = new_creator; @@ -4076,6 +4303,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); assert!( new_deadline > invoice.deadline, "new deadline must be after current deadline" @@ -4176,6 +4404,7 @@ impl SplitContract { old_invoice.payment_window_secs, old_invoice.refund_grace_secs, old_invoice.priorities.clone(), + old_invoice.require_kyc, ); // Load the newly created invoice and copy over the payments. @@ -4227,6 +4456,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); assert!(invoice.creator == caller, "only creator can add recipients"); assert!(invoice.funded == 0, "cannot add recipient after payment received"); assert!(amount > 0, "amount must be positive"); @@ -4277,6 +4507,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); // If a creator cosigner is set, require both creator and cosigner auths. if let Some(cos) = invoice.creator_cosigner.clone() { invoice.creator.require_auth(); @@ -4394,6 +4625,7 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc ) } @@ -4434,6 +4666,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!(invoice.allow_early_withdrawal, "early withdrawal not allowed"); + assert!(!invoice.disputed, "invoice is disputed"); assert!( invoice.status == InvoiceStatus::Pending, "invoice is not pending" @@ -4882,9 +5115,9 @@ impl SplitContract { .get(&invoice_ext2_key(invoice_id)) .unwrap_or_else(|| InvoiceExt2 { notification_contract: None, overflow_behavior: OverflowBehavior::Reject, - cross_chain_ref: None, require_kyc: false, auction_on_expiry: false, - auction_end: 0, bids: Vec::new(&env), min_payment: 0, min_funding_amount: 0, - priorities: Vec::new(&env), + cross_chain_ref: None, require_kyc: false, arbiter: None, disputed: false, + auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), + min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), }); // Copy to instance storage. @@ -5033,6 +5266,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!(invoice.status == InvoiceStatus::Pending, "invoice is not pending"); + assert!(!invoice.disputed, "invoice is disputed"); assert!(env.ledger().timestamp() <= invoice.deadline, "invoice deadline has passed"); assert!(amount > 0, "payment amount must be positive"); diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index f90647e..b02ba22 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -349,6 +349,10 @@ pub struct InvoiceExt2 { pub overflow_behavior: OverflowBehavior, pub cross_chain_ref: Option, pub require_kyc: bool, + /// Issue #188: arbiter address that can raise and resolve disputes. + pub arbiter: Option
, + /// Issue #188: whether this invoice is under active dispute. + pub disputed: bool, pub auction_on_expiry: bool, pub auction_end: u64, pub bids: Vec, @@ -441,6 +445,8 @@ pub struct Invoice { pub overflow_behavior: OverflowBehavior, pub cross_chain_ref: Option, pub require_kyc: bool, + pub arbiter: Option
, + pub disputed: bool, pub auction_on_expiry: bool, pub auction_end: u64, pub bids: Vec, @@ -523,6 +529,8 @@ impl Invoice { overflow_behavior: self.overflow_behavior, cross_chain_ref: self.cross_chain_ref, require_kyc: self.require_kyc, + arbiter: self.arbiter, + disputed: self.disputed, auction_on_expiry: self.auction_on_expiry, auction_end: self.auction_end, bids: self.bids, @@ -598,6 +606,8 @@ impl Invoice { overflow_behavior: ext2.overflow_behavior, cross_chain_ref: ext2.cross_chain_ref, require_kyc: ext2.require_kyc, + arbiter: ext2.arbiter, + disputed: ext2.disputed, auction_on_expiry: ext2.auction_on_expiry, auction_end: ext2.auction_end, bids: ext2.bids, @@ -763,6 +773,8 @@ impl Invoice { convert_to_stream: false, accepted_tokens: Vec::new(env), require_kyc: false, + arbiter: None, + disputed: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(env),