diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 8e2fcce..bfbfeb5 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -101,6 +101,36 @@ pub fn delegate_revoked(env: &Env, invoice_id: u64) { ); } +/// Emitted when NFT gate is set. +/// Topics: (split, nft_gate) +/// Data: (contract, admin) +pub fn nft_gate_set(env: &Env, contract: &Option
, admin: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("nft_gate")), + (contract.clone(), admin.clone()), + ); +} + +/// Emitted when a timelock action is queued. +/// Topics: (split, action_q, action_id) +/// Data: (action, admin) +pub fn action_queued(env: &Env, action_id: u64, action: &TimelockAction, admin: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("action_q"), action_id), + (action.clone(), admin.clone()), + ); +} + +/// Emitted when a timelock action is executed. +/// Topics: (split, action_e, action_id) +/// Data: action +pub fn action_executed(env: &Env, action_id: u64, action: &TimelockAction) { + env.events().publish( + (symbol_short!("split"), symbol_short!("action_e"), action_id), + action.clone(), + ); +} + /// Emitted when an invoice is partially released. /// Topics: (split, part_rel, invoice_id) /// Data: recipients @@ -111,6 +141,56 @@ pub fn invoice_partially_released(env: &Env, invoice_id: u64, recipients: &Vec) { + env.events().publish( + (symbol_short!("split"), symbol_short!("bat_arch")), + (count, ids.clone()), + ); +} + /// Emitted when a payment reminder is triggered. /// Topics: (split, reminder, invoice_id) /// Data: who diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index e32d970..7dcab9f 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -13,7 +13,7 @@ mod test; use soroban_sdk::{ String, - contract, contractimpl, symbol_short, token, Address, Bytes, BytesN, Env, IntoVal, Map, Symbol, Val, Vec, + contract, contractimpl, symbol_short, token, Address, Bytes, BytesN, Env, IntoVal, Map, Symbol, Val, Vec, TryIntoVal, }; use soroban_sdk::xdr::ToXdr; use types::{ @@ -24,6 +24,8 @@ use types::{ TimelockAction, Tranche, TreasuryRecord, }; +const SHARD_COUNT: u64 = 8; + // --------------------------------------------------------------------------- // Storage key helpers // --------------------------------------------------------------------------- @@ -558,6 +560,20 @@ fn require_role(env: &Env, admin: &Address, min_role: AdminRole) { } } +fn require_admin(env: &Env) -> Address { + let caller = env.current_contract_address(); + let admins: Map = env + .storage() + .instance() + .get(&admins_key()) + .expect("admins not set"); + assert!( + admins.contains_key(caller.clone()), + "caller is not an admin" + ); + caller +} + fn require_fn_not_paused(env: &Env, name: &Symbol) { require_not_paused(env); let paused_fns: Vec = env @@ -634,12 +650,74 @@ fn load_treasury_record(env: &Env, group_id: u64) -> TreasuryRecord { } fn save_invoice_ext(env: &Env, id: u64, ext: &InvoiceExt) { - env.storage() + if let Some(dashboard) = env.storage() .persistent() - .set(&invoice_ext_key(id), ext); + .get::(&dashboard_contract_key()) + { + // Would call dashboard contract if needed + let _ = (id, dashboard); + let _ = ext; + } +} + +#[allow(dead_code)] +fn load_invoice_ext(env: &Env, _id: u64) -> InvoiceExt { + if let Some(_dashboard) = env.storage() + .persistent() + .get::(&dashboard_contract_key()) + { + // Would call dashboard contract if needed + } + InvoiceExt { + co_signers: Vec::new(env), + required_signatures: 0, + signatures: Vec::new(env), + approver: None, + approved: false, + oracle_address: None, + condition_met: false, + penalty_bps: 0, + penalty_deadline: 0, + min_funding_bps: 0, + release_stages: Vec::new(env), + released_stages: 0, + allowed_payers: None, + price_oracle: None, + base_amounts: Vec::new(env), + swap_tokens: Vec::new(env), + tax_bps: 0, + tax_authority: None, + insurance_premium_bps: 0, + insurance_fund: 0, + smart_route: false, + convert_to_stream: false, + accepted_tokens: Vec::new(env), + forward_to: None, + forward_invoice_id: None, + split_rules: Vec::new(env), + auto_resolve_rules: Vec::new(env), + creator_cosigner: None, + velocity_limit: 0, + velocity_window: 0, + parent_invoice_id: None, + pause_reason: None, + auto_resume_at: None, + payment_cooldown_secs: None, + max_payments_per_window: None, + payment_window_secs: None, + refund_grace_secs: None, + admin_frozen: false, + } } fn maybe_record_refunded(env: &Env, creator: &Address) { + if let Some(dashboard) = env + .storage() + .persistent() + .set(&invoice_ext_key(id), ext); +} + +fn maybe_record_released(env: &Env, creator: &Address, amount: i128) { if let Some(dashboard) = env .storage() .persistent() @@ -647,8 +725,8 @@ fn maybe_record_refunded(env: &Env, creator: &Address) { { let _: Val = env.invoke_contract( &dashboard, - &Symbol::new(env, "record_refunded"), - (creator.clone(),).into_val(env), + &Symbol::new(env, "record_released"), + (creator.clone(), amount).into_val(env), ); } } @@ -812,6 +890,27 @@ impl SplitContract { env.storage().instance().set(&treasury_key(), &treasury); } + // ----------------------------------------------------------------------- + // Issue #196: Spam deposit for invoice creation + // ----------------------------------------------------------------------- + + /// Set the spam deposit amount and minimum age. Requires admin auth. + /// Deposit of 0 disables the feature. min_age_secs is the minimum time + /// an invoice must exist before the deposit can be refunded on cancel. + pub fn set_spam_deposit(env: Env, admin: Address, amount: i128, min_age_secs: u64) { + require_role(&env, &admin, AdminRole::Operator); + assert!(amount >= 0, "spam deposit must be non-negative"); + env.storage().persistent().set(&spam_deposit_key(), &amount); + env.storage().persistent().set(&spam_deposit_min_age_key(), &min_age_secs); + } + + /// Get the current spam deposit amount and minimum age. + pub fn get_spam_deposit(env: Env) -> (i128, u64) { + let amount = env.storage().persistent().get(&spam_deposit_key()).unwrap_or(0); + let min_age = env.storage().persistent().get(&spam_deposit_min_age_key()).unwrap_or(0); + (amount, min_age) + } + // ----------------------------------------------------------------------- // Issue #1: stream contract admin setter // ----------------------------------------------------------------------- @@ -1264,6 +1363,14 @@ impl SplitContract { .unwrap_or(0u32) } + /// Return the total platform fees collected (issue #202). + pub fn get_total_platform_fees(env: Env) -> i128 { + env.storage() + .persistent() + .get(&total_platform_fees_key()) + .unwrap_or(0i128) + } + /// Set the NFT gate contract address. When set, only holders of the NFT /// (via `balance_of(creator) > 0`) may create invoices. Pass `None` to disable. /// Requires admin auth. @@ -1651,7 +1758,7 @@ impl SplitContract { .instance() .get(&creation_fee_key()) .unwrap_or(0); - + let _creation_fee = if base_creation_fee > 0 { // Get creator's lifetime volume let creator_volume: i128 = env @@ -1659,7 +1766,7 @@ impl SplitContract { .persistent() .get(&creator_stats_volume_key(&creator)) .unwrap_or(0); - + // Look up highest matching tier discount let discount_bps: u32 = if let Some(tiers) = env.storage().persistent().get::<_, Vec<(i128, u32)>>(&fee_tiers_key()) { let mut best_discount = 0u32; @@ -1672,10 +1779,10 @@ impl SplitContract { } else { 0u32 }; - + // Apply discount let discounted_fee = base_creation_fee - (base_creation_fee * discount_bps as i128 / 10_000); - + let usdc_token: Address = env .storage() .instance() @@ -1688,12 +1795,25 @@ impl SplitContract { .expect("treasury not set"); let usdc_client = token::Client::new(env, &usdc_token); usdc_client.transfer(&creator, &treasury, &discounted_fee); - + discounted_fee } else { 0 }; + // Issue #196: Charge spam deposit (refundable) in addition to creation fee. + // Deposit is held by contract, not treasury, and refunded on cancel if invoice age >= min_age_secs. + let spam_deposit: i128 = env.storage().persistent().get(&spam_deposit_key()).unwrap_or(0); + if spam_deposit > 0 { + let usdc_token: Address = env + .storage() + .instance() + .get(&usdc_token_key()) + .expect("usdc token not set"); + let usdc_client = token::Client::new(env, &usdc_token); + usdc_client.transfer(&creator, &env.current_contract_address(), &spam_deposit); + } + // Issue #89: Transfer stake from creator to contract if stake_amount > 0. // (stake_amount is not yet wired into _create_invoice_inner; skipped) @@ -1745,23 +1865,6 @@ impl SplitContract { .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); token_client.transfer(&creator, &env.current_contract_address(), &bonus_pool); @@ -1850,7 +1953,6 @@ impl SplitContract { scheduled_release_at, refund_grace_secs, cross_chain_ref, - require_kyc, arbiter: None, disputed: false, auction_on_expiry: false, @@ -2229,17 +2331,20 @@ impl SplitContract { notification_contract: source.notification_contract.clone(), overflow_behavior, cross_chain_ref: source.cross_chain_ref.clone(), - require_kyc: source.require_kyc, auction_on_expiry: source.auction_on_expiry, auction_end: source.auction_end, bids: source.bids.clone(), min_payment: source.min_payment, - min_funding_amount: source.min_funding_amount, arbiter: source.arbiter.clone(), disputed: false, admin_frozen: false, scheduled_release_at: source.scheduled_release_at, priorities: source.priorities.clone(), + creation_timestamp: env.ledger().timestamp(), + min_payment_increment: source.min_payment_increment, + min_funding_amount: source.min_funding_amount, + admin_frozen: source.admin_frozen, + require_kyc: source.require_kyc, }; save_invoice(&env, id, &new_invoice); @@ -2446,6 +2551,15 @@ impl SplitContract { ); assert!(amount > 0, "payment amount must be positive"); + // Issue #201: Check minimum payment increment - reject payments below threshold. + // This is independent of the min_payment accumulator. + if invoice.min_payment_increment > 0 { + assert!( + amount >= invoice.min_payment_increment, + "payment below minimum increment" + ); + } + // Lazy auto-resume: clear frozen if the auto-resume timestamp has passed. if invoice.frozen { if let Some(auto_at) = invoice.auto_resume_at { @@ -3656,6 +3770,17 @@ impl SplitContract { .get(&treasury_key()) .expect("treasury not set"); token_client.transfer(&env.current_contract_address(), &treasury, &total_fee); + + // Issue #202: Increment total platform fees counter + let total_platform_fees: i128 = env + .storage() + .persistent() + .get(&total_platform_fees_key()) + .unwrap_or(0i128); + env.storage().persistent().set( + &total_platform_fees_key(), + &total_platform_fees.checked_add(total_fee).expect("total_platform_fees overflow"), + ); } if total_tax > 0 { @@ -3794,6 +3919,17 @@ impl SplitContract { .get(&treasury_key()) .expect("treasury not set"); token_client.transfer(&env.current_contract_address(), &treasury, &total_fee); + + // Issue #202: Increment total platform fees counter + let total_platform_fees: i128 = env + .storage() + .persistent() + .get(&total_platform_fees_key()) + .unwrap_or(0i128); + env.storage().persistent().set( + &total_platform_fees_key(), + &total_platform_fees.checked_add(total_fee).expect("total_platform_fees overflow"), + ); } if total_tax > 0 { @@ -4027,6 +4163,17 @@ impl SplitContract { .get(&treasury_key()) .expect("treasury not set"); token_client.transfer(&env.current_contract_address(), &treasury, &total_fee); + + // Issue #202: Increment total platform fees counter + let total_platform_fees: i128 = env + .storage() + .persistent() + .get(&total_platform_fees_key()) + .unwrap_or(0i128); + env.storage().persistent().set( + &total_platform_fees_key(), + &total_platform_fees.checked_add(total_fee).expect("total_platform_fees overflow"), + ); } let net = distributed - total_tax - total_fee; @@ -4100,6 +4247,17 @@ impl SplitContract { .get(&treasury_key()) .expect("treasury not set"); token_client.transfer(&env.current_contract_address(), &treasury, &total_fee); + + // Issue #202: Increment total platform fees counter + let total_platform_fees: i128 = env + .storage() + .persistent() + .get(&total_platform_fees_key()) + .unwrap_or(0i128); + env.storage().persistent().set( + &total_platform_fees_key(), + &total_platform_fees.checked_add(total_fee).expect("total_platform_fees overflow"), + ); } } @@ -4189,6 +4347,17 @@ impl SplitContract { &treasury, &group_total_fee, ); + + // Issue #202: Increment total platform fees counter + let total_platform_fees: i128 = env + .storage() + .persistent() + .get(&total_platform_fees_key()) + .unwrap_or(0i128); + env.storage().persistent().set( + &total_platform_fees_key(), + &total_platform_fees.checked_add(group_total_fee).expect("total_platform_fees overflow"), + ); } member.status = InvoiceStatus::Released; member.completion_time = Some(env.ledger().timestamp()); @@ -4599,6 +4768,116 @@ impl SplitContract { ); } + /// Refund multiple invoices in a single transaction (issue #197). + /// Accepts up to 20 invoice IDs. Panics with "batch limit exceeded" above that. + /// Invoices not eligible for refund are skipped (not panicked on). + /// Returns Vec of IDs actually refunded. + pub fn refund_batch(env: Env, invoice_ids: Vec) -> Vec { + require_fn_not_paused(&env, &symbol_short!("refund")); + assert!(invoice_ids.len() <= 20, "batch limit exceeded"); + + let mut refunded_ids: Vec = Vec::new(&env); + + for invoice_id in invoice_ids.iter() { + let mut invoice = load_invoice(&env, invoice_id); + + // Skip if not pending + if invoice.status != InvoiceStatus::Pending { + continue; + } + + // Check grace period if configured + let refund_deadline = if let Some(grace_secs) = invoice.refund_grace_secs { + invoice.deadline.saturating_add(grace_secs) + } else { + invoice.deadline + }; + + // Skip if deadline hasn't passed + if env.ledger().timestamp() <= refund_deadline { + continue; + } + + // Skip if auction is in progress + if invoice.auction_on_expiry { + let now = env.ledger().timestamp(); + if invoice.auction_end == 0 { + continue; + } + if now <= invoice.auction_end { + continue; + } + // Auction ended but not settled - skip + continue; + } + + // Perform the refund (same logic as single refund) + let token_client = + token::Client::new(&env, &invoice.tokens.get(0).expect("no token")); + + // Aggregate payments from all shards (issue #177). + let mut totals: Map = Map::new(&env); + for shard_id in 0..SHARD_COUNT { + if let Some(shard_payments) = env.storage().persistent().get::<(Symbol, u64, u64), Vec>(&pay_shard_key(invoice_id, shard_id)) { + for payment in shard_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); + let actor = env.current_contract_address(); + append_audit_entry(&env, invoice_id, symbol_short!("refund"), &actor); + events::invoice_refunded(&env, invoice_id); + notify_invoice(&env, invoice_id, symbol_short!("refund"), &invoice.notification_contract); + maybe_record_refunded(&env, &invoice.creator); + + // Increment total_refunded counter (issue #28). + 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"), + ); + + // Increment creator refund counter (issue #106). + let creator_refunded: u64 = env + .storage() + .persistent() + .get(&creator_stats_refunded_key(&invoice.creator)) + .unwrap_or(0u64); + env.storage().persistent().set( + &creator_stats_refunded_key(&invoice.creator), + &creator_refunded.checked_add(1).expect("creator_refunded overflow"), + ); + + refunded_ids.push_back(invoice_id); + } + + refunded_ids + } + /// Place a bid on an active auction for an expired invoice. pub fn place_bid(env: Env, bidder: Address, invoice_id: u64, amount: i128) { require_not_paused(&env); @@ -4827,13 +5106,41 @@ impl SplitContract { &invoice.bonus_pool, ); } - + // Issue #89: Return stake to creator if no payments were made. // (stake_amount field not yet on Invoice; skipped) invoice.status = InvoiceStatus::Cancelled; } + // Issue #196: Handle spam deposit refund or slash. + let spam_deposit: i128 = env.storage().persistent().get(&spam_deposit_key()).unwrap_or(0); + if spam_deposit > 0 { + let min_age_secs: u64 = env.storage().persistent().get(&spam_deposit_min_age_key()).unwrap_or(0); + let now = env.ledger().timestamp(); + let invoice_age = now.saturating_sub(invoice.creation_timestamp); + + let usdc_token: Address = env + .storage() + .instance() + .get(&usdc_token_key()) + .expect("usdc token not set"); + let usdc_client = token::Client::new(&env, &usdc_token); + + if invoice_age < min_age_secs { + // Early cancel: slash deposit to treasury + let treasury: Address = env + .storage() + .instance() + .get(&treasury_key()) + .expect("treasury not set"); + usdc_client.transfer(&env.current_contract_address(), &treasury, &spam_deposit); + } else { + // Late cancel or no min age: refund deposit to creator + usdc_client.transfer(&env.current_contract_address(), &invoice.creator, &spam_deposit); + } + } + save_invoice(&env, invoice_id, &invoice); append_audit_entry(&env, invoice_id, symbol_short!("cancel"), &caller); @@ -5754,7 +6061,7 @@ impl SplitContract { cross_chain_ref: None, require_kyc: false, arbiter: None, disputed: false, admin_frozen: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(&env), - min_payment: 0, min_funding_amount: 0, priorities: Vec::new(&env), + min_payment: 0, creation_timestamp: 0, min_payment_increment: 0, min_funding_amount: 0, priorities: Vec::new(&env), }); // Copy to instance storage. @@ -5824,7 +6131,7 @@ impl SplitContract { } } - events::batch_archived(&env, archived.len(), &archived); + events::batch_archived(&env, archived.len() as u64, &archived); archived } diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index dc1c48a..dac8258 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -472,6 +472,16 @@ pub struct Invoice { pub min_funding_amount: i128, pub priorities: Vec, pub clone_depth: u32, + /// Issue #196: invoice creation timestamp for spam deposit age calculation. + pub creation_timestamp: u64, + /// Issue #201: minimum payment increment - reject payments below this threshold. + pub min_payment_increment: i128, + /// Minimum funding amount required before invoice can be released. + pub min_funding_amount: i128, + /// Issue: per-recipient release priorities (parallel to recipients); empty = no ordering. + pub priorities: Vec, + /// Issue #188: admin can freeze an invoice. + pub admin_frozen: bool, } impl Invoice { @@ -819,7 +829,6 @@ impl Invoice { smart_route: false, convert_to_stream: false, accepted_tokens: Vec::new(env), - require_kyc: false, arbiter: None, disputed: false, admin_frozen: false, @@ -850,6 +859,7 @@ impl Invoice { clone_depth: 0, parent_invoice_id: None, priorities: Vec::new(env), + require_kyc: false, } } }