From 8b00183398936f3b4239fc7caf7affe5d9e93e3a Mon Sep 17 00:00:00 2001 From: StellarDataLab Bot Date: Fri, 26 Jun 2026 11:56:48 +0100 Subject: [PATCH] feat: implement creator self-imposed spending limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements set_self_limit and request_raise_self_limit functions as per issue #241. This feature allows creators to voluntarily self-restrict their spending independent of admin-imposed caps. Key features: - set_self_limit(creator, limit): Creator can immediately lower or set limit (requires creator auth) - request_raise_self_limit(creator, new_limit): Request to raise limit via timelock - Limit is enforced daily and resets at UTC midnight (86400s boundaries) - Whichever is lower wins: admin cap or creator self limit - Immediate raise attempts are blocked with clear error message Implementation Details: - Added RaiseCreatorSelfLimit to TimelockAction enum - New storage keys for limit, daily usage, and last day marker - Execute_action updated to handle self-limit raises after timelock - Create_invoice enforces self-limit alongside admin caps - Daily usage counter resets automatically at day boundary Acceptance Criteria Met: ✓ set_self_limit requires creator auth, applies only to their address ✓ Lowering takes effect immediately ✓ Raising requires timelock + execute_action ✓ Invoice creation enforces self-limit (whichever is lower with admin cap) ✓ Comprehensive test coverage including: - Immediate lower works - Immediate raise blocked - Timelock raise succeeds - Enforcement in invoice creation - Daily reset behavior - Unlimited when set to 0 - Auth requirement - Interaction with admin caps --- contracts/split/src/lib.rs | 204 +++++++++++++++++++++++++++++++++++ contracts/split/src/test.rs | 203 ++++++++++++++++++++++++++++++++++ contracts/split/src/types.rs | 2 + 3 files changed, 409 insertions(+) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index f02aa99..db76ede 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -357,6 +357,27 @@ fn creator_volume_used_key(creator: &Address) -> (Symbol, Address) { (symbol_short!("cr_v_use"), creator.clone()) } +/// Issue #241: Per-creator self-imposed daily spending limit. +fn creator_self_limit_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("cr_slf_lim"), creator.clone()) +} + +/// Issue #241: Per-creator self-imposed daily spending used (for the current day). +fn creator_self_used_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("cr_slf_use"), creator.clone()) +} + +/// Issue #241: Per-creator last day timestamp when self-limit was checked/reset. +fn creator_self_limit_day_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("cr_slf_day"), creator.clone()) +} + +/// Issue #241: Per-creator pending raise request for self-limit. +/// Stores the new limit amount that's waiting to be executed after timelock. +fn creator_self_limit_raise_key(creator: &Address) -> (Symbol, Address) { + (symbol_short!("cr_slf_rse"), creator.clone()) +} + fn fee_tiers_key() -> Symbol { symbol_short!("fee_trs") } @@ -880,6 +901,133 @@ impl SplitContract { .unwrap_or(0) } + // ----------------------------------------------------------------------- + // Issue #241: Creator self-imposed spending limit + // ----------------------------------------------------------------------- + + /// Set or lower a self-imposed daily spending limit for the caller (creator). + /// Requires the creator's own auth. Can only lower the limit immediately. + /// To raise the limit, use request_raise_self_limit() + timelock. + /// + /// A limit of 0 means no self-imposed limit (unrestricted). + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `creator` - The creator address (must authenticate) + /// * `new_limit` - The new daily spending limit (must be >= 0) + pub fn set_self_limit(env: Env, creator: Address, new_limit: i128) { + creator.require_auth(); + assert!(new_limit >= 0, "self limit must be non-negative"); + + let current_limit: i128 = env + .storage() + .persistent() + .get(&creator_self_limit_key(&creator)) + .unwrap_or(0); + + // Allow immediate lowering or setting from 0 + if current_limit > 0 { + assert!( + new_limit <= current_limit, + "cannot raise limit directly; use request_raise_self_limit()" + ); + } + + env.storage() + .persistent() + .set(&creator_self_limit_key(&creator), &new_limit); + + // Reset the daily usage counter when changing the limit + env.storage() + .persistent() + .set(&creator_self_used_key(&creator), &0i128); + + append_audit_entry(&env, 0, symbol_short!("slf_lim"), &creator); + } + + /// Request to raise the creator's self-imposed daily spending limit. + /// This queues a timelocked action. Once the timelock expires, + /// execute_action() must be called to apply the new limit. + /// + /// Requires the creator's own auth. + /// + /// # Arguments + /// * `env` - The Soroban environment + /// * `creator` - The creator address (must authenticate) + /// * `new_limit` - The desired new limit (must be > current limit) + /// + /// # Returns + /// The action_id of the queued raise request. + pub fn request_raise_self_limit(env: Env, creator: Address, new_limit: i128) -> u64 { + creator.require_auth(); + assert!(new_limit >= 0, "new limit must be non-negative"); + + let current_limit: i128 = env + .storage() + .persistent() + .get(&creator_self_limit_key(&creator)) + .unwrap_or(0); + + assert!( + new_limit > current_limit, + "new limit must be higher than current limit" + ); + + // Queue the timelock action using the existing timelock mechanism + let action = TimelockAction::RaiseCreatorSelfLimit(creator.clone(), new_limit); + + // Get and increment the action counter + let mut counter: u64 = env + .storage() + .persistent() + .get(&timelock_action_counter_key()) + .unwrap_or(0u64); + counter = counter.checked_add(1).expect("action counter overflow"); + + let now = env.ledger().timestamp(); + let queued = QueuedAction { + action, + queued_at: now, + executed: false, + }; + + env.storage().persistent().set(&timelock_action_key(counter), &queued); + env.storage().persistent().set(&timelock_action_counter_key(), &counter); + + append_audit_entry(&env, 0, symbol_short!("req_rse"), &creator); + + counter + } + + /// Get the current self-imposed daily spending limit for a creator (0 = no limit). + pub fn get_self_limit(env: Env, creator: Address) -> i128 { + env.storage() + .persistent() + .get(&creator_self_limit_key(&creator)) + .unwrap_or(0) + } + + /// Get the amount of the daily spending limit used so far for a creator. + pub fn get_self_limit_used(env: Env, creator: Address) -> i128 { + let today_start = (env.ledger().timestamp() / 86_400) * 86_400; + + let last_day: u64 = env + .storage() + .persistent() + .get(&creator_self_limit_day_key(&creator)) + .unwrap_or(0); + + // If we're in a new day, reset the counter + if last_day != today_start { + return 0; + } + + env.storage() + .persistent() + .get(&creator_self_used_key(&creator)) + .unwrap_or(0) + } + // ----------------------------------------------------------------------- // Issue #188: Dispute arbitration // ----------------------------------------------------------------------- @@ -1348,6 +1496,17 @@ impl SplitContract { assert!(*new_fee <= 10_000, "platform_fee_bps must be ≤ 10000"); env.storage().instance().set(&platform_fee_bps_key(), new_fee); } + TimelockAction::RaiseCreatorSelfLimit(creator, new_limit) => { + // Issue #241: Apply the raise to the self-limit + env.storage() + .persistent() + .set(&creator_self_limit_key(creator), new_limit); + + // Reset daily usage when limit is raised + env.storage() + .persistent() + .set(&creator_self_used_key(creator), &0i128); + } } queued.executed = true; @@ -1745,6 +1904,51 @@ impl SplitContract { .set(&creator_volume_used_key(&creator), &(used + total)); } + // Issue #241: check creator self-imposed spending limit. + let self_limit: i128 = env + .storage() + .persistent() + .get(&creator_self_limit_key(&creator)) + .unwrap_or(0); + if self_limit > 0 { + let today_start = (env.ledger().timestamp() / 86_400) * 86_400; + let last_day: u64 = env + .storage() + .persistent() + .get(&creator_self_limit_day_key(&creator)) + .unwrap_or(0); + + // Reset daily counter if we're in a new day + let mut daily_used: i128 = if last_day == today_start { + env.storage() + .persistent() + .get(&creator_self_used_key(&creator)) + .unwrap_or(0) + } else { + 0 + }; + + // The effective limit is the minimum of admin cap and self limit + let effective_limit = if volume_cap > 0 && volume_cap < self_limit { + volume_cap + } else { + self_limit + }; + + assert!( + daily_used.checked_add(total).expect("self limit overflow") <= effective_limit, + "creator self-imposed spending limit exceeded for today" + ); + + // Update the daily usage and current day marker + env.storage() + .persistent() + .set(&creator_self_used_key(&creator), &(daily_used + total)); + env.storage() + .persistent() + .set(&creator_self_limit_day_key(&creator), &today_start); + } + // Issue #195: if require_kyc, verify all recipients have KYC. if require_kyc { let kyc_contract: Address = env diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs index 0523b0c..24172b2 100644 --- a/contracts/split/src/test.rs +++ b/contracts/split/src/test.rs @@ -5282,3 +5282,206 @@ fn test_get_invoice_storage_footprint_checks_all_invoice_keys() { assert!(footprint <= 500, "TTL should not exceed initial min_persistent_entry_ttl"); }); } + + +// --------------------------------------------------------------------------- +// Creator Self-Imposed Spending Limit Tests (Issue #241) +// --------------------------------------------------------------------------- + +#[test] +fn test_creator_self_limit_immediate_lower() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + // Creator sets self limit to 500 + c.set_self_limit(&creator, &500_i128); + assert_eq!(c.get_self_limit(&creator), 500); + + // Creator immediately lowers it to 200 + c.set_self_limit(&creator, &200_i128); + assert_eq!(c.get_self_limit(&creator), 200); +} + +#[test] +#[should_panic(expected = "cannot raise limit directly")] +fn test_creator_self_limit_immediate_raise_blocked() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + + env.ledger().set_timestamp(1_000); + + // Creator sets self limit to 200 + c.set_self_limit(&creator, &200_i128); + assert_eq!(c.get_self_limit(&creator), 200); + + // Creator attempts to immediately raise it (should panic) + c.set_self_limit(&creator, &500_i128); +} + +#[test] +fn test_creator_self_limit_raise_via_timelock() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let creator = Address::generate(&env); + + // Initialize with a timelock delay of 7 days + let timelock_secs = 7 * 24 * 60 * 60u64; // 7 days in seconds + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + c.set_timelock_secs(&admin, timelock_secs); + + env.ledger().set_timestamp(1_000); + + // Creator sets self limit to 200 + c.set_self_limit(&creator, &200_i128); + assert_eq!(c.get_self_limit(&creator), 200); + + // Creator requests to raise limit to 500 + let action_id = c.request_raise_self_limit(&creator, &500_i128); + + // Attempt to use the raised limit immediately (should still be 200) + assert_eq!(c.get_self_limit(&creator), 200); + + // Advance time past timelock + env.ledger().set_timestamp(1_000 + timelock_secs + 1); + + // Execute the action + c.execute_action(&action_id); + + // Now the limit should be raised to 500 + assert_eq!(c.get_self_limit(&creator), 500); +} + +#[test] +fn test_creator_self_limit_enforced_in_create_invoice() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let payer = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&payer, &1_000_i128); + env.ledger().set_timestamp(1_000); + + // Creator sets self limit to 300 + c.set_self_limit(&creator, &300_i128); + + // Create invoice for 200 - should succeed + let id1 = make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999); + assert_eq!(c.get_invoice(&id1).status, InvoiceStatus::Pending); + + // Try to create another invoice for 200 - should fail (total would be 400 > 300) + let err_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999) + })); + assert!(err_result.is_err(), "Expected panic when self limit exceeded"); +} + +#[test] +fn test_creator_self_limit_daily_reset() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&creator, &1_000_i128); + env.ledger().set_timestamp(1_000); + + // Creator sets self limit to 500 + c.set_self_limit(&creator, &500_i128); + + // Create invoice for 300 + let _id1 = make_invoice(&env, &c, &creator, &recipient, 300, &token_id, 9_999); + assert_eq!(c.get_self_limit_used(&creator), 300); + + // Try to create another invoice for 300 on the same day (would exceed 500) + let err_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + make_invoice(&env, &c, &creator, &recipient, 300, &token_id, 9_999) + })); + assert!(err_result.is_err(), "Expected panic when exceeding daily self limit"); + + // Advance to next day (add 86400 seconds) + env.ledger().set_timestamp(1_000 + 86_400 + 1); + + // Now creating invoice for 300 should succeed (daily reset) + let _id2 = make_invoice(&env, &c, &creator, &recipient, 300, &token_id, 9_999); + assert_eq!(c.get_self_limit_used(&creator), 300); // Reset for new day +} + +#[test] +fn test_creator_self_limit_zero_means_unlimited() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + StellarAssetClient::new(&env, &token_id).mint(&creator, &10_000_i128); + env.ledger().set_timestamp(1_000); + + // Creator sets self limit to 0 (no limit) + c.set_self_limit(&creator, &0_i128); + + // Create multiple large invoices - should all succeed + let _id1 = make_invoice(&env, &c, &creator, &recipient, 5_000, &token_id, 9_999); + let _id2 = make_invoice(&env, &c, &creator, &recipient, 3_000, &token_id, 9_999); + let _id3 = make_invoice(&env, &c, &creator, &recipient, 1_000, &token_id, 9_999); + + // No panic should occur +} + +#[test] +#[should_panic(expected = "only creator can")] +fn test_creator_self_limit_requires_creator_auth() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + + let creator = Address::generate(&env); + let other_addr = Address::generate(&env); + + // Try to set limit for creator with different address's auth (should panic) + let _old_auth = env.mock_all_auths_allow_address(other_addr.clone()); + c.set_self_limit(&creator, &500_i128); +} + +#[test] +fn test_creator_self_limit_with_admin_cap() { + let (env, contract_id, token_id) = setup(); + let c = client(&env, &contract_id); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + + let creator = Address::generate(&env); + let recipient = Address::generate(&env); + + c.initialize(&admin, &0_i128, &treasury, &token_id, &0_u32, &None, &0_u32, &0_u32, &0_u64); + + StellarAssetClient::new(&env, &token_id).mint(&creator, &1_000_i128); + env.ledger().set_timestamp(1_000); + + // Set admin cap to 600 + c.set_creator_volume_cap(&admin, &creator, &600_i128); + + // Set creator self limit to 400 + c.set_self_limit(&creator, &400_i128); + + // Create invoice for 300 - should succeed + let _id1 = make_invoice(&env, &c, &creator, &recipient, 300, &token_id, 9_999); + + // Try to create invoice for 200 - should fail + // (would exceed self limit of 400, even though admin cap is 600) + let err_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + make_invoice(&env, &c, &creator, &recipient, 200, &token_id, 9_999) + })); + assert!(err_result.is_err(), "Expected panic when exceeding self limit"); +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index dc1c48a..f531fab 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -378,6 +378,8 @@ pub struct PenaltyTier { pub enum TimelockAction { SetTreasury(Address), SetPlatformFee(u32), + /// Issue #241: Creator self-imposed limit raise request. + RaiseCreatorSelfLimit(Address, i128), } /// A queued timelock action with metadata.