Skip to content
Merged
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
159 changes: 159 additions & 0 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,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")
}
Expand Down Expand Up @@ -992,6 +1013,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
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -1468,6 +1616,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;
Expand Down
203 changes: 203 additions & 0 deletions contracts/split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5098,3 +5098,206 @@ fn test_migrate_escrow_zero_balance() {
let tk = token_client(&env, &token_id);
assert_eq!(tk.balance(&new_contract), 0);
}


// ---------------------------------------------------------------------------
// 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");
}
2 changes: 2 additions & 0 deletions contracts/split/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading