From cbcc2e04d8f56ad43f9dd0e54eda7fa4be7f571c Mon Sep 17 00:00:00 2001 From: Ebuka321 Date: Wed, 24 Jun 2026 12:03:29 -0700 Subject: [PATCH] Closes #188, #189, #193, #195: dispute arbitration, admin rotation, creator volume cap, KYC for recipients - #188: Add arbiter/disputed fields, raise_dispute/resolve_dispute functions - #189: Add propose_admin/accept_admin admin rotation - #193: Add creator volume cap with set/get functions + creation-time check - #195: Wire require_kyc through _create_invoice_inner, validate recipients at creation --- contracts/split/src/lib.rs | 259 ++++++++++++++++++++++++++++++++++- contracts/split/src/types.rs | 14 ++ 2 files changed, 269 insertions(+), 4 deletions(-) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 0970822..fe01448 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -255,6 +255,21 @@ fn payment_window_key(invoice_id: u64) -> (Symbol, u64) { const PAYMENT_WINDOW_CAP: u32 = 100; +/// Per-creator volume cap key (issue #193). +fn creator_volume_cap_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("cr_vcap"), creator.clone()) +} + +/// Per-creator total invoice amount used toward cap (issue #193). +fn creator_volume_used_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("cr_vused"), creator.clone()) +} + +/// Pending admin address for admin rotation (issue #189). +fn pending_admin_key() -> Symbol { + symbol_short!("pend_adm") +} + // --------------------------------------------------------------------------- // Invoice storage helpers // --------------------------------------------------------------------------- @@ -318,6 +333,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), @@ -519,6 +536,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) // ----------------------------------------------------------------------- @@ -721,6 +903,7 @@ impl SplitContract { options.max_payments_per_window, options.payment_window_secs, options.priorities, + options.require_kyc, ) } @@ -769,6 +952,7 @@ impl SplitContract { max_payments_per_window: Option, payment_window_secs: Option, priorities: Vec, + require_kyc: bool, ) -> u64 { assert!( recipients.len() == amounts.len(), @@ -911,6 +1095,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); @@ -998,7 +1219,9 @@ impl SplitContract { max_payments_per_window, payment_window_secs, cross_chain_ref, - require_kyc: false, + require_kyc, + arbiter: None, + disputed: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(env), @@ -1136,6 +1359,7 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc ); ids.push_back(id); } @@ -1208,6 +1432,7 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc ); if months > 1 { @@ -1360,6 +1585,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(), }; @@ -1440,6 +1667,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); @@ -1471,6 +1699,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"); @@ -1524,6 +1753,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" @@ -1797,6 +2027,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"); @@ -1887,6 +2118,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"); @@ -1961,6 +2193,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" @@ -2032,6 +2265,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), @@ -2062,6 +2296,7 @@ impl SplitContract { let mut invoice = load_invoice(&env, invoice_id); assert!(!invoice.frozen, "invoice is frozen"); + assert!(!invoice.disputed, "invoice is disputed"); assert!( invoice.status == InvoiceStatus::Pending, "invoice is not pending" @@ -2182,6 +2417,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; @@ -2242,6 +2478,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; @@ -2516,6 +2753,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" @@ -2639,6 +2877,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"); @@ -3104,6 +3343,7 @@ impl SplitContract { None, None, Vec::new(env), // priorities + false, // require_kyc ); env.storage() .persistent() @@ -3126,6 +3366,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(); @@ -3202,6 +3443,7 @@ impl SplitContract { invoice.status == InvoiceStatus::Pending, "invoice is not pending" ); + assert!(!invoice.disputed, "invoice is disputed"); assert!( env.ledger().timestamp() > invoice.deadline, "deadline has not passed" @@ -3382,6 +3624,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(); @@ -3521,6 +3764,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; @@ -3538,6 +3782,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" @@ -3637,6 +3882,7 @@ impl SplitContract { old_invoice.max_payments_per_window, old_invoice.payment_window_secs, old_invoice.priorities.clone(), + old_invoice.require_kyc, ); // Load the newly created invoice and copy over the payments. @@ -3688,6 +3934,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"); @@ -3738,6 +3985,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(); @@ -3854,6 +4102,7 @@ impl SplitContract { None, None, Vec::new(&env), // priorities + false, // require_kyc ) } @@ -3894,6 +4143,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" @@ -4278,9 +4528,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. @@ -4373,6 +4623,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 6abe558..4c6d131 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -226,6 +226,8 @@ pub struct InvoiceOptions { pub payment_window_secs: Option, /// Issue: per-recipient release priorities (parallel to recipients); empty = no ordering. pub priorities: Vec, + /// Issue #195: require KYC verification for all recipients at creation time. + pub require_kyc: bool, } /// Legacy invoice layout used by stored invoices created before the `version` @@ -338,6 +340,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, @@ -411,6 +417,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, @@ -492,6 +500,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, @@ -566,6 +576,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, @@ -644,6 +656,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),