From 96b85d90be002d735c1c45c6f8e16f55393caf7e Mon Sep 17 00:00:00 2001 From: Godsmiracle001 Date: Sat, 30 May 2026 13:19:16 +0100 Subject: [PATCH] all done --- backend/.well-known/stellar.toml | 29 +- contracts/asset_path_payment/src/lib.rs | 35 +- contracts/asset_path_payment/src/test.rs | 189 +++- contracts/bulk_payment/README.md | 48 + contracts/bulk_payment/src/lib.rs | 827 +++++++++++++----- contracts/bulk_payment/src/test.rs | 218 ++++- contracts/cross_asset_payment/src/lib.rs | 45 +- contracts/cross_asset_payment/src/test.rs | 48 +- .../cross_asset_payment/src/test_escrow.rs | 30 +- contracts/milestone_escrow/src/lib.rs | 88 +- contracts/milestone_escrow/src/test.rs | 15 +- contracts/revenue_split/src/lib.rs | 21 +- contracts/revenue_split/src/test.rs | 393 ++++++--- contracts/smart_wallet/src/lib.rs | 4 +- contracts/smart_wallet/src/test.rs | 4 +- contracts/vesting_escrow/src/lib.rs | 85 +- contracts/vesting_escrow/src/test.rs | 328 ++++++- .../vesting_escrow/src/test_escrow_logic.rs | 390 +++++++-- 18 files changed, 2071 insertions(+), 726 deletions(-) create mode 100644 contracts/bulk_payment/README.md diff --git a/backend/.well-known/stellar.toml b/backend/.well-known/stellar.toml index d6b78b54..457384aa 100644 --- a/backend/.well-known/stellar.toml +++ b/backend/.well-known/stellar.toml @@ -1,26 +1,23 @@ -# SEP-0001 Stellar Metadata for PayD - +VERSION = "2.0.0" NETWORK_PASSPHRASE = "Test SDF Network ; September 2015" -HORIZON_URL = "https://horizon-testnet.stellar.org" + +ACCOUNTS = [] [DOCUMENTATION] ORG_NAME = "PayD" +ORG_DBA = "PayD" ORG_URL = "https://payd.example.com" -ORG_DESCRIPTION = "PayD is a Stellar-based cross-border payroll platform enabling organizations to pay employees using digital assets." -ORG_LOG_URL = "https://payd.example.com/logo.png" - -[CONTACT] -ORG_SUPPORT_EMAIL = "support@payd.example.com" +ORG_DESCRIPTION = "PayD is a decentralized payroll and payment platform built on Stellar and Soroban." +ORG_LOGO = "https://payd.example.com/logo.png" +ORG_OFFICIAL_EMAIL = "security@payd.example.com" [[CURRENCIES]] code = "ORGUSD" -issuer = "GD7TPWEDZCAD3TTMI7OGEDHHVH4QIJPKKXWKT3NIXOFMVA5QJ4BEBVLF" -display_decimal = 2 -name = "PayD Organizational Dollar" -desc = "A stablecoin pegged to USD for payroll disbursements within the PayD platform." -status = "live" -conditions = "This asset is used for payroll and requires authorization from the issuer." +issuer = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5" +display_decimals = 2 +name = "Org USD" +desc = "Organization-issued USD-denominated payroll asset for PayD deployments." +conditions = "Issued for payroll, escrow, and settlement flows in approved PayD deployments." is_asset_anchored = true -anchor_asset_type = "other" +anchor_asset_type = "fiat" anchor_asset = "USD" -funding_methods = "Direct issuance to distribution accounts." diff --git a/contracts/asset_path_payment/src/lib.rs b/contracts/asset_path_payment/src/lib.rs index 1623ce24..678ecb21 100644 --- a/contracts/asset_path_payment/src/lib.rs +++ b/contracts/asset_path_payment/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, contractevent, - Address, Env, String, Symbol, Vec, symbol_short, token + Address, Env, String, Symbol, Vec, contract, contracterror, contractevent, contractimpl, + contracttype, symbol_short, token, }; /// Errors for path payment operations @@ -123,7 +123,9 @@ impl AssetPathPaymentContract { panic!("Already initialized"); } env.storage().persistent().set(&DataKey::Admin, &admin); - env.storage().persistent().set(&DataKey::PaymentCount, &0u64); + env.storage() + .persistent() + .set(&DataKey::PaymentCount, &0u64); Self::bump_core_ttl(&env); } @@ -171,14 +173,20 @@ impl AssetPathPaymentContract { // Transfer source tokens to contract (escrow) let token_client = token::Client::new(&env, &source_asset); let contract_addr = env.current_contract_address(); - + token_client.transfer(&from, &contract_addr, &source_amount); // Increment payment counter Self::bump_core_ttl(&env); - let mut count: u64 = env.storage().persistent().get(&DataKey::PaymentCount).unwrap_or(0); + let mut count: u64 = env + .storage() + .persistent() + .get(&DataKey::PaymentCount) + .unwrap_or(0); count += 1; - env.storage().persistent().set(&DataKey::PaymentCount, &count); + env.storage() + .persistent() + .set(&DataKey::PaymentCount, &count); env.storage().persistent().extend_ttl( &DataKey::PaymentCount, PERSISTENT_TTL_THRESHOLD, @@ -238,7 +246,8 @@ impl AssetPathPaymentContract { Self::require_admin(&env); let key = DataKey::Payment(payment_id); - let mut record: PathPaymentRecord = env.storage() + let mut record: PathPaymentRecord = env + .storage() .temporary() .get(&key) .ok_or(PathPaymentError::PaymentNotFound)?; @@ -253,7 +262,7 @@ impl AssetPathPaymentContract { record.error_message = Some(String::from_str(&env, "Destination amount below minimum")); record.partial_failure = true; env.storage().temporary().set(&key, &record); - + PathPaymentFailed { payment_id, error_code: PathPaymentError::SlippageExceeded as u32, @@ -298,7 +307,8 @@ impl AssetPathPaymentContract { Self::require_admin(&env); let key = DataKey::Payment(payment_id); - let mut record: PathPaymentRecord = env.storage() + let mut record: PathPaymentRecord = env + .storage() .temporary() .get(&key) .ok_or(PathPaymentError::PaymentNotFound)?; @@ -328,7 +338,7 @@ impl AssetPathPaymentContract { pub fn get_payment(env: Env, payment_id: u64) -> Option { let key = DataKey::Payment(payment_id); let record: Option = env.storage().temporary().get(&key); - + if record.is_some() { env.storage().temporary().extend_ttl( &key, @@ -343,7 +353,7 @@ impl AssetPathPaymentContract { pub fn get_payment_count(env: Env) -> u64 { let key = DataKey::PaymentCount; let count = env.storage().persistent().get(&key).unwrap_or(0); - + if env.storage().persistent().has(&key) { env.storage().persistent().extend_ttl( &key, @@ -375,7 +385,8 @@ impl AssetPathPaymentContract { /// Require admin authorization fn require_admin(env: &Env) { - let admin: Address = env.storage() + let admin: Address = env + .storage() .persistent() .get(&DataKey::Admin) .expect("Admin not set; contract may not be initialized"); diff --git a/contracts/asset_path_payment/src/test.rs b/contracts/asset_path_payment/src/test.rs index 37038bf7..3d6fdbe9 100644 --- a/contracts/asset_path_payment/src/test.rs +++ b/contracts/asset_path_payment/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::testutils::Address as _; -use soroban_sdk::{token, Env, String}; +use soroban_sdk::{Env, String, token}; fn create_token_contract(env: &Env, admin: &Address) -> Address { env.register_stellar_asset_contract_v2(admin.clone()) @@ -177,7 +177,16 @@ fn test_initiate_path_payment_escrows_tokens() { let tc = token::Client::new(&env, &source); - client.initiate_path_payment(&from, &to, &source, &dest, &300, &270, &300, &Vec::new(&env)); + client.initiate_path_payment( + &from, + &to, + &source, + &dest, + &300, + &270, + &300, + &Vec::new(&env), + ); assert_eq!(tc.balance(&contract_id), 300); assert_eq!(tc.balance(&from), 4700); @@ -250,7 +259,14 @@ fn test_fail_path_payment_success() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &180, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &180, + &200, + &Vec::new(&env), ); let msg = String::from_str(&env, "No liquidity on path"); @@ -292,7 +308,14 @@ fn test_complete_path_payment_rejects_non_pending() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &180, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &180, + &200, + &Vec::new(&env), ); // Complete it once @@ -321,7 +344,14 @@ fn test_complete_path_payment_slippage_rejection() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &190, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &190, + &200, + &Vec::new(&env), ); // actual_dest_amount < dest_min_amount should fail with slippage @@ -347,7 +377,14 @@ fn test_fail_path_payment_with_partial_failure_flag() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &180, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &180, + &200, + &Vec::new(&env), ); let msg = String::from_str(&env, "Partial execution"); @@ -367,12 +404,8 @@ fn test_fail_path_payment_rejects_unknown_payment() { let client = AssetPathPaymentContractClient::new(&env, &contract_id); let msg = String::from_str(&env, "Not found"); - let result = client.try_fail_path_payment( - &999, - &(PathPaymentError::PathNotFound as u32), - &msg, - &false, - ); + let result = + client.try_fail_path_payment(&999, &(PathPaymentError::PathNotFound as u32), &msg, &false); assert_eq!(result, Err(Ok(PathPaymentError::PaymentNotFound))); } @@ -394,7 +427,14 @@ fn test_fail_path_payment_rejects_non_pending() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &180, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &180, + &200, + &Vec::new(&env), ); // Fail it @@ -450,7 +490,14 @@ fn test_withdraw_success() { // Send tokens to contract via path payment client.initiate_path_payment( - &from, &to, &source, &dest, &300, &270, &300, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &300, + &270, + &300, + &Vec::new(&env), ); let tc = token::Client::new(&env, &source); @@ -484,9 +531,8 @@ fn test_path_payment_initiated_event() { let client = AssetPathPaymentContractClient::new(&env, &contract_id); client.init(&admin); - let id = client.initiate_path_payment( - &from, &to, &source, &dest, &100, &90, &100, &Vec::new(&env), - ); + let id = + client.initiate_path_payment(&from, &to, &source, &dest, &100, &90, &100, &Vec::new(&env)); assert_eq!(id, 1); } @@ -508,7 +554,14 @@ fn test_path_payment_completed_event() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &180, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &180, + &200, + &Vec::new(&env), ); client.complete_path_payment(&id, &195, &190); @@ -535,7 +588,14 @@ fn test_path_payment_failed_event() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &180, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &180, + &200, + &Vec::new(&env), ); let msg = String::from_str(&env, "Liquidity vanished"); @@ -564,7 +624,14 @@ fn test_complete_slippage_emits_failed_event() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &190, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &190, + &200, + &Vec::new(&env), ); let result = client.try_complete_path_payment(&id, &195, &100); @@ -594,9 +661,7 @@ fn test_full_lifecycle_complete() { // Initiate let path = Vec::from_array(&env, [dest.clone()]); - let id = client.initiate_path_payment( - &from, &to, &source, &dest, &500, &480, &500, &path, - ); + let id = client.initiate_path_payment(&from, &to, &source, &dest, &500, &480, &500, &path); assert_eq!(client.get_payment_count(), 1); let record = client.get_payment(&id).unwrap(); @@ -628,17 +693,19 @@ fn test_full_lifecycle_fail() { client.init(&admin); let id = client.initiate_path_payment( - &from, &to, &source, &dest, &300, &280, &300, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &300, + &280, + &300, + &Vec::new(&env), ); assert_eq!(client.get_payment_count(), 1); let msg = String::from_str(&env, "Path execution failed"); - client.fail_path_payment( - &id, - &(PathPaymentError::PathNotFound as u32), - &msg, - &false, - ); + client.fail_path_payment(&id, &(PathPaymentError::PathNotFound as u32), &msg, &false); let record = client.get_payment(&id).unwrap(); assert_eq!(record.status, symbol_short!("failed")); @@ -667,16 +734,29 @@ fn test_multiple_payments_independent_lifecycle() { client.init(&admin); // Payment 1: Complete - let id1 = client.initiate_path_payment( - &from, &to, &source, &dest, &100, &90, &100, &Vec::new(&env), - ); + let id1 = + client.initiate_path_payment(&from, &to, &source, &dest, &100, &90, &100, &Vec::new(&env)); // Payment 2: Fail let id2 = client.initiate_path_payment( - &from, &to, &source, &dest, &200, &180, &200, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &200, + &180, + &200, + &Vec::new(&env), ); // Payment 3: Complete let id3 = client.initiate_path_payment( - &from, &to, &source, &dest, &300, &270, &300, &Vec::new(&env), + &from, + &to, + &source, + &dest, + &300, + &270, + &300, + &Vec::new(&env), ); client.complete_path_payment(&id1, &95, &92); @@ -684,9 +764,18 @@ fn test_multiple_payments_independent_lifecycle() { client.fail_path_payment(&id2, &1, &msg, &false); client.complete_path_payment(&id3, &295, &285); - assert_eq!(client.get_payment(&id1).unwrap().status, symbol_short!("completed")); - assert_eq!(client.get_payment(&id2).unwrap().status, symbol_short!("failed")); - assert_eq!(client.get_payment(&id3).unwrap().status, symbol_short!("completed")); + assert_eq!( + client.get_payment(&id1).unwrap().status, + symbol_short!("completed") + ); + assert_eq!( + client.get_payment(&id2).unwrap().status, + symbol_short!("failed") + ); + assert_eq!( + client.get_payment(&id3).unwrap().status, + symbol_short!("completed") + ); } #[test] @@ -706,9 +795,8 @@ fn test_empty_path_initiation() { let client = AssetPathPaymentContractClient::new(&env, &contract_id); client.init(&admin); - let id = client.initiate_path_payment( - &from, &to, &source, &dest, &100, &90, &100, &Vec::new(&env), - ); + let id = + client.initiate_path_payment(&from, &to, &source, &dest, &100, &90, &100, &Vec::new(&env)); let record = client.get_payment(&id).unwrap(); assert_eq!(record.path.len(), 0); @@ -735,9 +823,7 @@ fn test_populated_path_initiation() { client.init(&admin); let path = Vec::from_array(&env, [hop1.clone(), hop2.clone()]); - let id = client.initiate_path_payment( - &from, &to, &source, &dest, &100, &90, &100, &path, - ); + let id = client.initiate_path_payment(&from, &to, &source, &dest, &100, &90, &100, &path); let record = client.get_payment(&id).unwrap(); assert_eq!(record.path.len(), 2); @@ -749,7 +835,16 @@ fn test_sep0034_metadata() { let contract_id = env.register(AssetPathPaymentContract, ()); let client = AssetPathPaymentContractClient::new(&env, &contract_id); - assert_eq!(client.name(), String::from_str(&env, env!("CARGO_PKG_NAME"))); - assert_eq!(client.version(), String::from_str(&env, env!("CARGO_PKG_VERSION"))); - assert_eq!(client.author(), String::from_str(&env, env!("CARGO_PKG_AUTHORS"))); + assert_eq!( + client.name(), + String::from_str(&env, env!("CARGO_PKG_NAME")) + ); + assert_eq!( + client.version(), + String::from_str(&env, env!("CARGO_PKG_VERSION")) + ); + assert_eq!( + client.author(), + String::from_str(&env, env!("CARGO_PKG_AUTHORS")) + ); } diff --git a/contracts/bulk_payment/README.md b/contracts/bulk_payment/README.md new file mode 100644 index 00000000..cae9580b --- /dev/null +++ b/contracts/bulk_payment/README.md @@ -0,0 +1,48 @@ +# Bulk Payment Contract + +Soroban contract for payroll-sized batch payments with per-account spending limits, scheduling, partial failure recovery, and maintenance controls. + +## Maintenance And Stability + +- `set_throttle_config(max_batch_size, min_ledger_gap)` lets the admin reduce the maximum batch size below the protocol ceiling of 100 and require a minimum ledger gap between submissions by the same sender. +- `get_throttle_config()` returns the active throttle settings. +- `estimate_batch_fee(payment_count, base_fee_stroops, fee_bump_required)` returns a deterministic fee budget for off-chain callers using Horizon or transaction simulation fee data. The contract does not fetch Horizon directly. +- Same-ledger replay protection remains active for all batch execution paths. + +## Public Functions + +| Function | Description | +|---|---| +| `initialize(admin)` | Initializes admin, sequence counters, and default throttle settings. | +| `set_admin(new_admin)` | Transfers admin authority. | +| `bump_ttl()` | Extends TTL for critical persistent keys. | +| `set_paused(paused)` | Pauses or resumes batch execution. | +| `is_paused()` | Returns the pause state. | +| `set_default_limits(daily, weekly, monthly)` | Sets default spending limits. | +| `set_account_limits(account, daily, weekly, monthly)` | Sets account-specific limits. | +| `remove_account_limits(account)` | Removes an account limit override. | +| `get_account_limits(account)` | Reads effective account limits. | +| `get_account_usage(account)` | Reads current rolling usage counters. | +| `set_throttle_config(max_batch_size, min_ledger_gap)` | Updates global batch throttling. | +| `get_throttle_config()` | Reads global batch throttling. | +| `estimate_batch_fee(payment_count, base_fee_stroops, fee_bump_required)` | Estimates batch fee and budget in stroops. | +| `execute_batch(sender, token, payments, expected_sequence)` | Executes all-or-nothing batch payments. | +| `execute_batch_partial(sender, token, payments, expected_sequence)` | Executes legacy best-effort batch payments. | +| `execute_batch_v2(sender, token, payments, expected_sequence, all_or_nothing)` | Executes tracked batch payments with per-payment status. | +| `refund_failed_payment(batch_id, payment_index)` | Refunds a failed v2 payment entry. | +| `schedule_batch(sender, token, payments, execute_after_ledger)` | Escrows and schedules a future batch. | +| `execute_scheduled_batch(scheduled_id)` | Executes a ready scheduled batch. | +| `cancel_scheduled_batch(sender, scheduled_id)` | Cancels a pending scheduled batch and refunds escrow. | +| `get_scheduled_batch(scheduled_id)` | Reads scheduled batch state. | +| `get_payment_entry(batch_id, payment_index)` | Reads a per-payment status entry. | +| `get_sequence()` | Reads replay sequence counter. | +| `get_batch(batch_id)` | Reads batch summary. | +| `get_batch_count()` | Reads total batch count. | +| `get_last_batch_ledger(sender)` | Reads the sender's last batch ledger. | + +## Testing + +```bash +cargo test -p bulk_payment +cargo clippy -p bulk_payment --all-targets --all-features -- -D warnings +``` diff --git a/contracts/bulk_payment/src/lib.rs b/contracts/bulk_payment/src/lib.rs index 7e32cb5c..c2f08e22 100644 --- a/contracts/bulk_payment/src/lib.rs +++ b/contracts/bulk_payment/src/lib.rs @@ -1,8 +1,9 @@ #![no_std] +#![allow(deprecated)] use soroban_sdk::{ - contract, contractimpl, contracttype, contracterror, contractevent, - Address, Env, String, Vec, token, symbol_short, Symbol, + Address, Env, String, Symbol, Vec, contract, contracterror, contractevent, contractimpl, + contracttype, symbol_short, token, }; // ── Errors ──────────────────────────────────────────────────────────────────── @@ -11,37 +12,43 @@ use soroban_sdk::{ #[derive(Copy, Clone, Debug, PartialEq)] #[repr(u32)] pub enum ContractError { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - EmptyBatch = 4, - BatchTooLarge = 5, - InvalidAmount = 6, - AmountOverflow = 7, - SequenceMismatch = 8, - BatchNotFound = 9, - DailyLimitExceeded = 10, - WeeklyLimitExceeded = 11, + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + EmptyBatch = 4, + BatchTooLarge = 5, + InvalidAmount = 6, + AmountOverflow = 7, + SequenceMismatch = 8, + BatchNotFound = 9, + DailyLimitExceeded = 10, + WeeklyLimitExceeded = 11, MonthlyLimitExceeded = 12, - InvalidLimitConfig = 13, + InvalidLimitConfig = 13, /// Payment is not in a Failed state, so no refund is available. - RefundNotAvailable = 14, + RefundNotAvailable = 14, /// Payment has already been refunded; cannot refund twice. - AlreadyRefunded = 15, + AlreadyRefunded = 15, /// No PaymentEntry found for the given (batch_id, payment_index). - PaymentNotFound = 16, + PaymentNotFound = 16, /// Contract is paused — all payment operations are suspended. - ContractPaused = 17, + ContractPaused = 17, /// Sender already executed a batch in this ledger sequence. - LedgerReplayDetected = 18, + LedgerReplayDetected = 18, /// Scheduled batch does not exist or has expired. - ScheduledBatchNotFound = 19, + ScheduledBatchNotFound = 19, /// Scheduled batch cannot be executed yet — target ledger not reached. - ScheduledBatchNotReady = 20, + ScheduledBatchNotReady = 20, /// Scheduled batch has already been executed or cancelled. - ScheduledBatchConsumed = 21, + ScheduledBatchConsumed = 21, /// Only the original sender may cancel a scheduled batch. ScheduledBatchUnauthorized = 22, + /// Throttle configuration is outside supported contract bounds. + InvalidThrottleConfig = 23, + /// Sender submitted another batch before the configured throttle gap elapsed. + ThrottleLimitExceeded = 24, + /// Fee estimation inputs are invalid. + InvalidFeeConfig = 25, } // ── Events ──────────────────────────────────────────────────────────────────── @@ -92,24 +99,24 @@ pub struct LimitsUpdatedEvent { /// Emitted when a failed payment's held funds are returned to the batch sender. #[contractevent] pub struct RefundIssuedEvent { - pub batch_id: u64, + pub batch_id: u64, pub payment_index: u32, - pub sender: Address, - pub amount: i128, + pub sender: Address, + pub amount: i128, } /// Emitted when the contract is paused or unpaused (circuit breaker). #[contractevent] pub struct ContractStatusChangedEvent { - pub paused: bool, - pub admin: Address, + pub paused: bool, + pub admin: Address, } /// Emitted when a batch is scheduled for future execution. #[contractevent] pub struct BatchScheduledEvent { - pub scheduled_id: u64, - pub sender: Address, + pub scheduled_id: u64, + pub sender: Address, pub execute_after_ledger: u32, } @@ -117,30 +124,30 @@ pub struct BatchScheduledEvent { #[contractevent] pub struct ScheduledBatchExecutedEvent { pub scheduled_id: u64, - pub batch_id: u64, - pub total_sent: i128, + pub batch_id: u64, + pub total_sent: i128, } /// Emitted when a scheduled batch is cancelled by its sender. #[contractevent] pub struct ScheduledBatchCancelledEvent { pub scheduled_id: u64, - pub sender: Address, + pub sender: Address, } /// Emitted when an all-or-nothing batch completes successfully. #[contractevent] pub struct BatchExecutedEvent { - pub batch_id: u64, + pub batch_id: u64, pub total_sent: i128, } /// Emitted when a partial batch completes (some payments may have been skipped). #[contractevent] pub struct BatchPartialEvent { - pub batch_id: u64, + pub batch_id: u64, pub success_count: u32, - pub fail_count: u32, + pub fail_count: u32, } /// Emitted for real-time analytics indexing on contract initialization. @@ -204,13 +211,33 @@ pub struct AccountUsage { pub monthly_reset_ledger: u32, } +/// Network throughput controls applied to every batch submission path. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ThrottleConfig { + pub max_batch_size: u32, + pub min_ledger_gap: u32, +} + +/// Deterministic fee estimate for a payroll batch. +#[contracttype] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FeeEstimate { + pub payment_count: u32, + pub operation_count: u32, + pub base_fee_stroops: i128, + pub recommended_fee_stroops: i128, + pub budget_fee_stroops: i128, + pub fee_bump_required: bool, +} + /// Tier identifier used in events. #[contracttype] #[derive(Copy, Clone, Debug, PartialEq)] #[repr(u32)] pub enum LimitTier { - Daily = 0, - Weekly = 1, + Daily = 0, + Weekly = 1, Monthly = 2, } @@ -219,17 +246,17 @@ pub enum LimitTier { /// ### State Machine /// 1. **Pending**: Initial state before any execution (internal to input). /// 2. **Sent**: Successfully executed payment where funds moved from sender to recipient. -/// 3. **Failed**: Payment skipped due to invalid amount or insufficient funds. +/// 3. **Failed**: Payment skipped due to invalid amount or insufficient funds. /// The proportional funds are held in the contract account. -/// 4. **Refunded**: A previously `Failed` payment whose funds have been returned +/// 4. **Refunded**: A previously `Failed` payment whose funds have been returned /// to the original sender via `refund_failed_payment`. #[contracttype] #[derive(Copy, Clone, Debug, PartialEq)] #[repr(u32)] pub enum PaymentStatus { - Pending = 0, - Sent = 1, - Failed = 2, + Pending = 0, + Sent = 1, + Failed = 2, Refunded = 3, } @@ -239,9 +266,9 @@ pub enum PaymentStatus { #[derive(Clone, Debug, PartialEq)] pub struct PaymentEntry { pub recipient: Address, - pub amount: i128, - pub category: Symbol, - pub status: PaymentStatus, + pub amount: i128, + pub category: Symbol, + pub status: PaymentStatus, } /// Status of a scheduled batch. @@ -249,8 +276,8 @@ pub struct PaymentEntry { #[derive(Copy, Clone, Debug, PartialEq)] #[repr(u32)] pub enum ScheduledBatchStatus { - Pending = 0, - Executed = 1, + Pending = 0, + Executed = 1, Cancelled = 2, } @@ -258,11 +285,11 @@ pub enum ScheduledBatchStatus { #[contracttype] #[derive(Clone, Debug)] pub struct ScheduledBatch { - pub sender: Address, - pub token: Address, - pub payments: Vec, + pub sender: Address, + pub token: Address, + pub payments: Vec, pub execute_after_ledger: u32, - pub status: ScheduledBatchStatus, + pub status: ScheduledBatchStatus, } #[contracttype] @@ -288,9 +315,14 @@ pub enum DataKey { ScheduledBatch(u64), /// Counter for scheduled batches ScheduledBatchCount, + /// Configurable network throttling limits + ThrottleConfig, } const MAX_BATCH_SIZE: u32 = 100; +const MAX_THROTTLE_LEDGER_GAP: u32 = LEDGERS_PER_DAY; +const FEE_BUMP_MULTIPLIER: i128 = 2; +const BATCH_OPERATION_OVERHEAD: u32 = 1; const PERSISTENT_TTL_THRESHOLD: u32 = 20_000; const PERSISTENT_TTL_EXTEND_TO: u32 = 120_000; const TEMPORARY_TTL_THRESHOLD: u32 = 2_000; @@ -301,8 +333,8 @@ const TEMPORARY_TTL_EXTEND_TO: u32 = 20_000; // Daily ≈ 86_400 / 5 = 17_280 // Weekly ≈ 7 × 17_280 = 120_960 // Monthly ≈ 30 × 17_280 = 518_400 -const LEDGERS_PER_DAY: u32 = 17_280; -const LEDGERS_PER_WEEK: u32 = 120_960; +const LEDGERS_PER_DAY: u32 = 17_280; +const LEDGERS_PER_WEEK: u32 = 120_960; const LEDGERS_PER_MONTH: u32 = 518_400; // ── Contract ────────────────────────────────────────────────────────────────── @@ -331,6 +363,7 @@ impl BulkPaymentContract { // ── Initialization ──────────────────────────────────────────────────── + /// Initializes the contract with an admin and default maintenance settings. pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> { if env.storage().persistent().has(&DataKey::Admin) { return Err(ContractError::AlreadyInitialized); @@ -338,10 +371,14 @@ impl BulkPaymentContract { env.storage().persistent().set(&DataKey::Admin, &admin); env.storage().persistent().set(&DataKey::BatchCount, &0u64); env.storage().persistent().set(&DataKey::Sequence, &0u64); + env.storage() + .instance() + .set(&DataKey::ThrottleConfig, &Self::default_throttle_config()); Self::bump_core_ttl(&env); Ok(()) } + /// Transfers the admin role to `new_admin`. pub fn set_admin(env: Env, new_admin: Address) -> Result<(), ContractError> { Self::require_admin(&env)?; env.storage().persistent().set(&DataKey::Admin, &new_admin); @@ -364,26 +401,30 @@ impl BulkPaymentContract { /// /// Only the current admin (multi-sig administrator) may call this. pub fn set_paused(env: Env, paused: bool) -> Result<(), ContractError> { - let admin: Address = env.storage().persistent().get(&DataKey::Admin) + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) .ok_or(ContractError::NotInitialized)?; env.storage().persistent().extend_ttl( - &DataKey::Admin, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &DataKey::Admin, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); admin.require_auth(); env.storage().instance().set(&DataKey::Paused, &paused); - env.events().publish( - (symbol_short!("paused"),), - (paused, admin.clone()), - ); + env.events() + .publish((symbol_short!("paused"),), (paused, admin.clone())); Ok(()) } /// Returns `true` if the contract is currently paused. pub fn is_paused(env: Env) -> bool { - env.storage().instance() + env.storage() + .instance() .get(&DataKey::Paused) .unwrap_or(false) } @@ -406,7 +447,9 @@ impl BulkPaymentContract { weekly_limit: weekly, monthly_limit: monthly, }; - env.storage().instance().set(&DataKey::DefaultLimits, &limits); + env.storage() + .instance() + .set(&DataKey::DefaultLimits, &limits); Ok(()) } @@ -427,7 +470,9 @@ impl BulkPaymentContract { weekly_limit: weekly, monthly_limit: monthly, }; - env.storage().persistent().set(&DataKey::AcctLimits(account.clone()), &limits); + env.storage() + .persistent() + .set(&DataKey::AcctLimits(account.clone()), &limits); env.events().publish( (symbol_short!("limits"), account.clone()), @@ -440,7 +485,9 @@ impl BulkPaymentContract { /// Remove per-account overrides so the account falls back to default limits. pub fn remove_account_limits(env: Env, account: Address) -> Result<(), ContractError> { Self::require_admin(&env)?; - env.storage().persistent().remove(&DataKey::AcctLimits(account)); + env.storage() + .persistent() + .remove(&DataKey::AcctLimits(account)); Ok(()) } @@ -454,6 +501,74 @@ impl BulkPaymentContract { Self::current_usage(&env, &account) } + /// Sets global on-chain throttling for batch submissions. + /// + /// `max_batch_size` caps the number of payments per batch and may not exceed + /// the protocol safety ceiling of 100. `min_ledger_gap` controls how many + /// ledgers must separate two batches from the same sender. + pub fn set_throttle_config( + env: Env, + max_batch_size: u32, + min_ledger_gap: u32, + ) -> Result<(), ContractError> { + Self::require_admin(&env)?; + Self::validate_throttle_config(max_batch_size, min_ledger_gap)?; + + let config = ThrottleConfig { + max_batch_size, + min_ledger_gap, + }; + env.storage() + .instance() + .set(&DataKey::ThrottleConfig, &config); + Ok(()) + } + + /// Returns the active throttling configuration. + pub fn get_throttle_config(env: Env) -> ThrottleConfig { + Self::throttle_config(&env) + } + + /// Estimates the fee budget for a batch using an externally supplied + /// current base fee from Horizon or transaction simulation. + /// + /// The contract cannot fetch Horizon data on-chain, so callers pass the + /// observed base fee in stroops. The estimate includes one batch overhead + /// operation plus one operation per payment. + pub fn estimate_batch_fee( + _env: Env, + payment_count: u32, + base_fee_stroops: i128, + fee_bump_required: bool, + ) -> Result { + Self::validate_fee_inputs(payment_count, base_fee_stroops)?; + + let operation_count = payment_count + .checked_add(BATCH_OPERATION_OVERHEAD) + .ok_or(ContractError::AmountOverflow)?; + let multiplier = if fee_bump_required { + FEE_BUMP_MULTIPLIER + } else { + 1 + }; + let recommended_fee_stroops = i128::from(operation_count) + .checked_mul(base_fee_stroops) + .and_then(|fee| fee.checked_mul(multiplier)) + .ok_or(ContractError::AmountOverflow)?; + let budget_fee_stroops = recommended_fee_stroops + .checked_mul(2) + .ok_or(ContractError::AmountOverflow)?; + + Ok(FeeEstimate { + payment_count, + operation_count, + base_fee_stroops, + recommended_fee_stroops, + budget_fee_stroops, + fee_bump_required, + }) + } + // ── Batch execution ─────────────────────────────────────────────────── /// Gas-optimized all-or-nothing batch payment. @@ -471,16 +586,19 @@ impl BulkPaymentContract { Self::check_and_advance_sequence(&env, expected_sequence)?; let len = payments.len(); - if len == 0 { return Err(ContractError::EmptyBatch); } - if len > MAX_BATCH_SIZE { return Err(ContractError::BatchTooLarge); } + Self::validate_batch_len(&env, len)?; let mut total: i128 = 0; let mut success_count: u32 = 0; // Use a single loop to calculate total and validate (O(n)) for op in payments.iter() { - if op.amount <= 0 { return Err(ContractError::InvalidAmount); } - total = total.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; + if op.amount <= 0 { + return Err(ContractError::InvalidAmount); + } + total = total + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; success_count += 1; } @@ -488,7 +606,7 @@ impl BulkPaymentContract { let token_client = token::Client::new(&env, &token); let current_contract = env.current_contract_address(); - + // Single transfer of total amount to escrow token_client.transfer(&sender, ¤t_contract, &total); @@ -504,14 +622,15 @@ impl BulkPaymentContract { recipient: op.recipient.clone(), amount: op.amount, category: op.category, - }.publish(&env); + } + .publish(&env); payment_index += 1; } Self::record_usage(&env, &sender, total); let record = BatchRecord { - sender, - token, + sender: sender.clone(), + token: token.clone(), total_sent: total, success_count, fail_count: 0, @@ -521,12 +640,18 @@ impl BulkPaymentContract { // Use Persistent storage for historical records to keep Instance storage small let key = DataKey::Batch(batch_id); env.storage().persistent().set(&key, &record); - + // Extend TTL to ensure record is available for off-chain querying (1 year minimum suggested) // 500,000 ledgers is ~30 days, we could extend more if needed. - env.storage().persistent().extend_ttl(&key, 100_000, 500_000); + env.storage() + .persistent() + .extend_ttl(&key, 100_000, 500_000); - BatchExecutedEvent { batch_id, total_sent: total }.publish(&env); + BatchExecutedEvent { + batch_id, + total_sent: total, + } + .publish(&env); // Emit analytics event for real-time indexing BatchAnalyticsEvent { @@ -536,7 +661,8 @@ impl BulkPaymentContract { total_sent: total, payment_count: success_count, timestamp: env.ledger().timestamp(), - }.publish(&env); + } + .publish(&env); Ok(batch_id) } @@ -556,8 +682,7 @@ impl BulkPaymentContract { Self::check_and_advance_sequence(&env, expected_sequence)?; let len = payments.len(); - if len == 0 { return Err(ContractError::EmptyBatch); } - if len > MAX_BATCH_SIZE { return Err(ContractError::BatchTooLarge); } + Self::validate_batch_len(&env, len)?; let mut total: i128 = 0; let mut success_count: u32 = 0; @@ -569,7 +694,9 @@ impl BulkPaymentContract { // Invalid amount — skip it and mark fail continue; } - total = total.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; + total = total + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; success_count += 1; } @@ -596,7 +723,8 @@ impl BulkPaymentContract { recipient: op.recipient.clone(), amount: op.amount, category: op.category, - }.publish(&env); + } + .publish(&env); payment_index += 1; continue; } @@ -610,7 +738,8 @@ impl BulkPaymentContract { recipient: op.recipient.clone(), amount: op.amount, category: op.category, - }.publish(&env); + } + .publish(&env); payment_index += 1; } @@ -620,9 +749,13 @@ impl BulkPaymentContract { Self::record_usage(&env, &sender, total_sent); - let status = if fail_count == 0 { symbol_short!("completed") } - else if actual_success == 0 { symbol_short!("rollbck") } - else { symbol_short!("partial") }; + let status = if fail_count == 0 { + symbol_short!("completed") + } else if actual_success == 0 { + symbol_short!("rollbck") + } else { + symbol_short!("partial") + }; let record = BatchRecord { sender, @@ -632,13 +765,20 @@ impl BulkPaymentContract { fail_count, status, }; - + let key = DataKey::Batch(batch_id); env.storage().persistent().set(&key, &record); - - env.storage().persistent().extend_ttl(&key, 100_000, 500_000); - BatchPartialEvent { batch_id, success_count, fail_count }.publish(&env); + env.storage() + .persistent() + .extend_ttl(&key, 100_000, 500_000); + + BatchPartialEvent { + batch_id, + success_count, + fail_count, + } + .publish(&env); Ok(batch_id) } @@ -646,27 +786,27 @@ impl BulkPaymentContract { /// Unified batch entry point with a runtime `all_or_nothing` flag. /// - /// This function serves as the primary entry point for batch payments, supporting + /// This function serves as the primary entry point for batch payments, supporting /// two distinct modes of execution to balance strict atomicity with resilience. /// /// ### `all_or_nothing = true` (Strict Mode) /// - **Atomicity**: The entire batch succeeds or the entire call reverts. - /// - **Validation**: Every payment amount is validated (must be > 0) before + /// - **Validation**: Every payment amount is validated (must be > 0) before /// any funds move. /// - **Transfer**: Funds move directly from `sender` to each `recipient`. /// - **Auditability**: On success, each payment is recorded as `Sent`. /// /// ### `all_or_nothing = false` (Resilient/Partial Mode) /// - **Best-effort**: Valid payments execute immediately; invalid ones are skipped. - /// - **Escrow Mechanism**: Funds for the entire batch (sum of positive amounts) + /// - **Escrow Mechanism**: Funds for the entire batch (sum of positive amounts) /// are first pulled into the contract. - /// - **State Tracking**: + /// - **State Tracking**: /// - Successful transfers are marked `Sent`. /// - Failed transfers (e.g. invalid amount) are marked `Failed`. - /// - **Manual Recovery**: Funds associated with `Failed` entries remain in the + /// - **Manual Recovery**: Funds associated with `Failed` entries remain in the /// contract and must be retrieved using `refund_failed_payment`. /// - /// In both modes, every individual payment generates a `PaymentEntry` for + /// In both modes, every individual payment generates a `PaymentEntry` for /// granular status querying via `get_payment_entry`. pub fn execute_batch_v2( env: Env, @@ -683,8 +823,7 @@ impl BulkPaymentContract { Self::check_and_advance_sequence(&env, expected_sequence)?; let len = payments.len(); - if len == 0 { return Err(ContractError::EmptyBatch); } - if len > MAX_BATCH_SIZE { return Err(ContractError::BatchTooLarge); } + Self::validate_batch_len(&env, len)?; if all_or_nothing { Self::execute_strict(&env, sender, token, payments, len) @@ -696,13 +835,13 @@ impl BulkPaymentContract { /// Refund a single `Failed` payment from an `execute_batch_v2` partial /// batch back to the original batch sender. /// - /// This function implements a secure recovery path for funds that were - /// earmarked for a payment that failed validation. + /// This function implements a secure recovery path for funds that were + /// earmarked for a payment that failed validation. /// /// ### Security Model /// - **Fixed Destination**: Funds are *always* returned to `BatchRecord.sender`. - /// - **No Authorization Required**: Since the destination is fixed to the - /// original funder, anyone can trigger the refund (e.g. a maintenance bot) + /// - **No Authorization Required**: Since the destination is fixed to the + /// original funder, anyone can trigger the refund (e.g. a maintenance bot) /// without risking fund diversion. /// /// ### State Transition @@ -722,19 +861,25 @@ impl BulkPaymentContract { ) -> Result<(), ContractError> { // Resolve sender and token from the batch record. let batch_key = DataKey::Batch(batch_id); - let batch: BatchRecord = env.storage().persistent().get(&batch_key) + let batch: BatchRecord = env + .storage() + .persistent() + .get(&batch_key) .ok_or(ContractError::BatchNotFound)?; // Load the individual payment entry. let entry_key = DataKey::PaymentEntry(batch_id, payment_index); - let mut entry: PaymentEntry = env.storage().temporary().get(&entry_key) + let mut entry: PaymentEntry = env + .storage() + .temporary() + .get(&entry_key) .ok_or(ContractError::PaymentNotFound)?; // Guard: status must be Failed — Refunded and Sent/Pending are errors. match entry.status { - PaymentStatus::Failed => {} // proceed + PaymentStatus::Failed => {} // proceed PaymentStatus::Refunded => return Err(ContractError::AlreadyRefunded), - _ => return Err(ContractError::RefundNotAvailable), + _ => return Err(ContractError::RefundNotAvailable), } // Return the held funds to the original sender. @@ -749,7 +894,9 @@ impl BulkPaymentContract { entry.status = PaymentStatus::Refunded; env.storage().temporary().set(&entry_key, &entry); env.storage().temporary().extend_ttl( - &entry_key, TEMPORARY_TTL_THRESHOLD, TEMPORARY_TTL_EXTEND_TO, + &entry_key, + TEMPORARY_TTL_THRESHOLD, + TEMPORARY_TTL_EXTEND_TO, ); env.events().publish( @@ -782,13 +929,16 @@ impl BulkPaymentContract { Self::bump_core_ttl(&env); let len = payments.len(); - if len == 0 { return Err(ContractError::EmptyBatch); } - if len > MAX_BATCH_SIZE { return Err(ContractError::BatchTooLarge); } + Self::validate_batch_len(&env, len)?; let mut total: i128 = 0; for op in payments.iter() { - if op.amount <= 0 { return Err(ContractError::InvalidAmount); } - total = total.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; + if op.amount <= 0 { + return Err(ContractError::InvalidAmount); + } + total = total + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; } Self::check_limits(&env, &sender, total)?; @@ -797,12 +947,19 @@ impl BulkPaymentContract { let token_client = token::Client::new(&env, &token); token_client.transfer(&sender, &env.current_contract_address(), &total); - let count: u64 = env.storage().persistent() + let count: u64 = env + .storage() + .persistent() .get(&DataKey::ScheduledBatchCount) - .unwrap_or(0) + 1; - env.storage().persistent().set(&DataKey::ScheduledBatchCount, &count); + .unwrap_or(0) + + 1; + env.storage() + .persistent() + .set(&DataKey::ScheduledBatchCount, &count); env.storage().persistent().extend_ttl( - &DataKey::ScheduledBatchCount, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &DataKey::ScheduledBatchCount, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); let scheduled = ScheduledBatch { @@ -815,9 +972,18 @@ impl BulkPaymentContract { let key = DataKey::ScheduledBatch(count); env.storage().persistent().set(&key, &scheduled); - env.storage().persistent().extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); - BatchScheduledEvent { scheduled_id: count, sender, execute_after_ledger }.publish(&env); + BatchScheduledEvent { + scheduled_id: count, + sender, + execute_after_ledger, + } + .publish(&env); Ok(count) } @@ -825,15 +991,14 @@ impl BulkPaymentContract { /// reached. Funds were already pulled at schedule time and are distributed /// from the contract's balance. Open to any caller once the ledger condition /// is satisfied. - pub fn execute_scheduled_batch( - env: Env, - scheduled_id: u64, - ) -> Result { + pub fn execute_scheduled_batch(env: Env, scheduled_id: u64) -> Result { Self::require_not_paused(&env)?; Self::bump_core_ttl(&env); let key = DataKey::ScheduledBatch(scheduled_id); - let mut scheduled: ScheduledBatch = env.storage().persistent() + let mut scheduled: ScheduledBatch = env + .storage() + .persistent() .get(&key) .ok_or(ContractError::ScheduledBatchNotFound)?; @@ -848,7 +1013,9 @@ impl BulkPaymentContract { let mut total: i128 = 0; for op in scheduled.payments.iter() { - total = total.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; + total = total + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; } let token_client = token::Client::new(&env, &scheduled.token); @@ -863,24 +1030,38 @@ impl BulkPaymentContract { let batch_id = Self::next_batch_id(&env); let success_count = scheduled.payments.len(); - env.storage().persistent().set(&DataKey::Batch(batch_id), &BatchRecord { - sender: scheduled.sender.clone(), - token: scheduled.token.clone(), - total_sent: total, - success_count, - fail_count: 0, - status: symbol_short!("completed"), - }); + env.storage().persistent().set( + &DataKey::Batch(batch_id), + &BatchRecord { + sender: scheduled.sender.clone(), + token: scheduled.token.clone(), + total_sent: total, + success_count, + fail_count: 0, + status: symbol_short!("completed"), + }, + ); env.storage().persistent().extend_ttl( - &DataKey::Batch(batch_id), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &DataKey::Batch(batch_id), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); // Mark scheduled batch as executed scheduled.status = ScheduledBatchStatus::Executed; env.storage().persistent().set(&key, &scheduled); - env.storage().persistent().extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); - ScheduledBatchExecutedEvent { scheduled_id, batch_id, total_sent: total }.publish(&env); + ScheduledBatchExecutedEvent { + scheduled_id, + batch_id, + total_sent: total, + } + .publish(&env); Ok(batch_id) } @@ -894,7 +1075,9 @@ impl BulkPaymentContract { sender.require_auth(); let key = DataKey::ScheduledBatch(scheduled_id); - let mut scheduled: ScheduledBatch = env.storage().persistent() + let mut scheduled: ScheduledBatch = env + .storage() + .persistent() .get(&key) .ok_or(ContractError::ScheduledBatchNotFound)?; @@ -908,16 +1091,26 @@ impl BulkPaymentContract { // Return held funds to sender let mut total: i128 = 0; for op in scheduled.payments.iter() { - total = total.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; + total = total + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; } let token_client = token::Client::new(&env, &scheduled.token); token_client.transfer(&env.current_contract_address(), &sender, &total); scheduled.status = ScheduledBatchStatus::Cancelled; env.storage().persistent().set(&key, &scheduled); - env.storage().persistent().extend_ttl(&key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); + env.storage().persistent().extend_ttl( + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); - ScheduledBatchCancelledEvent { scheduled_id, sender }.publish(&env); + ScheduledBatchCancelledEvent { + scheduled_id, + sender, + } + .publish(&env); Ok(()) } @@ -926,7 +1119,8 @@ impl BulkPaymentContract { env: Env, scheduled_id: u64, ) -> Result { - env.storage().persistent() + env.storage() + .persistent() .get(&DataKey::ScheduledBatch(scheduled_id)) .ok_or(ContractError::ScheduledBatchNotFound) } @@ -938,7 +1132,10 @@ impl BulkPaymentContract { payment_index: u32, ) -> Result { let key = DataKey::PaymentEntry(batch_id, payment_index); - let entry: PaymentEntry = env.storage().temporary().get(&key) + let entry: PaymentEntry = env + .storage() + .temporary() + .get(&key) .ok_or(ContractError::PaymentNotFound)?; // Reading state should not modify TTL; extend only on write Ok(entry) @@ -951,16 +1148,19 @@ impl BulkPaymentContract { if let Some(value) = env.storage().persistent().get(&key) { // Reading state should not modify TTL; extend only on write value - } else { 0 } + } else { + 0 + } } pub fn get_batch(env: Env, batch_id: u64) -> Result { let key = DataKey::Batch(batch_id); - let record = env.storage() + let record = env + .storage() .persistent() .get(&key) .ok_or(ContractError::BatchNotFound)?; - + // Reading state should not modify TTL; extend only on write Ok(record) } @@ -970,12 +1170,15 @@ impl BulkPaymentContract { if let Some(value) = env.storage().persistent().get(&key) { // Reading state should not modify TTL; extend only on write value - } else { 0 } + } else { + 0 + } } /// Returns the ledger sequence of the last batch executed by a given sender. pub fn get_last_batch_ledger(env: Env, sender: Address) -> u32 { - env.storage().persistent() + env.storage() + .persistent() .get(&DataKey::LastBatchLedger(sender)) .unwrap_or(0) } @@ -995,8 +1198,12 @@ impl BulkPaymentContract { ) -> Result { let mut total: i128 = 0; for op in payments.iter() { - if op.amount <= 0 { return Err(ContractError::InvalidAmount); } - total = total.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; + if op.amount <= 0 { + return Err(ContractError::InvalidAmount); + } + total = total + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; } Self::check_limits(env, &sender, total)?; @@ -1009,34 +1216,49 @@ impl BulkPaymentContract { Self::record_usage(env, &sender, total); let batch_id = Self::next_batch_id(env); - env.storage().persistent().set(&DataKey::Batch(batch_id), &BatchRecord { - sender: sender.clone(), - token: token.clone(), - total_sent: total, - success_count: len, - fail_count: 0, - status: symbol_short!("completed"), - }); + env.storage().persistent().set( + &DataKey::Batch(batch_id), + &BatchRecord { + sender: sender.clone(), + token: token.clone(), + total_sent: total, + success_count: len, + fail_count: 0, + status: symbol_short!("completed"), + }, + ); env.storage().persistent().extend_ttl( - &DataKey::Batch(batch_id), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &DataKey::Batch(batch_id), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); for (index, op) in payments.iter().enumerate() { Self::write_payment_entry(env, batch_id, index as u32, &op, PaymentStatus::Sent); if op.category == symbol_short!("bonus") { - let mut tb: i128 = env.storage().instance() - .get(&DataKey::TotalBonusesPaid).unwrap_or(0); - tb = tb.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; - env.storage().instance().set(&DataKey::TotalBonusesPaid, &tb); + let mut tb: i128 = env + .storage() + .instance() + .get(&DataKey::TotalBonusesPaid) + .unwrap_or(0); + tb = tb + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; + env.storage() + .instance() + .set(&DataKey::TotalBonusesPaid, &tb); env.events().publish( - (symbol_short!("bonus"), op.category.clone(), op.recipient.clone()), + ( + symbol_short!("bonus"), + op.category.clone(), + op.recipient.clone(), + ), op.amount, ); } else { - env.events().publish( - (symbol_short!("payment"), op.recipient.clone()), op.amount, - ); + env.events() + .publish((symbol_short!("payment"), op.recipient.clone()), op.amount); } } @@ -1046,12 +1268,12 @@ impl BulkPaymentContract { /// Partial-success path used by `execute_batch_v2(all_or_nothing = false)`. /// /// ### Logic Flow - /// 1. **Escrow Initialization**: Calculates the sum of all positive amounts and + /// 1. **Escrow Initialization**: Calculates the sum of all positive amounts and /// transfers that total from `sender` to the contract address. /// 2. **Execution Loop**: Iterates through payments: /// - If `amount > 0`: Transfers from contract to `recipient`, marks as `Sent`. /// - If `amount <= 0`: Marks as `Failed`. Proportional funds remain in contract. - /// 3. **Dust/Residual Handling**: Any remaining funds (due to calculation + /// 3. **Dust/Residual Handling**: Any remaining funds (due to calculation /// discrepancies or explicit skips) are held for manual refund. fn execute_partial_with_refund( env: &Env, @@ -1063,7 +1285,9 @@ impl BulkPaymentContract { let mut total: i128 = 0; for op in payments.iter() { if op.amount > 0 { - total = total.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; + total = total + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; } } @@ -1073,10 +1297,10 @@ impl BulkPaymentContract { let contract_addr = env.current_contract_address(); token_client.transfer(&sender, &contract_addr, &total); - let mut remaining = total; - let mut success_count = 0u32; - let mut fail_count = 0u32; - let mut total_sent = 0i128; + let mut remaining = total; + let mut success_count = 0u32; + let mut fail_count = 0u32; + let mut total_sent = 0i128; // Funds earmarked for deferred refund — kept in contract, not returned // immediately. Under normal accounting this is 0 because invalid // amounts were excluded from `total`; the defensive branch below guards @@ -1096,9 +1320,8 @@ impl BulkPaymentContract { // held funds. fail_count += 1; Self::write_payment_entry(env, batch_id, idx, &op, PaymentStatus::Failed); - env.events().publish( - (symbol_short!("skipped"), op.recipient.clone()), op.amount, - ); + env.events() + .publish((symbol_short!("skipped"), op.recipient.clone()), op.amount); continue; } @@ -1111,30 +1334,39 @@ impl BulkPaymentContract { .checked_add(op.amount) .ok_or(ContractError::AmountOverflow)?; Self::write_payment_entry(env, batch_id, idx, &op, PaymentStatus::Failed); - env.events().publish( - (symbol_short!("skipped"), op.recipient.clone()), op.amount, - ); + env.events() + .publish((symbol_short!("skipped"), op.recipient.clone()), op.amount); continue; } // Valid — transfer contract → recipient. token_client.transfer(&contract_addr, &op.recipient, &op.amount); - remaining -= op.amount; + remaining -= op.amount; total_sent += op.amount; success_count += 1; Self::write_payment_entry(env, batch_id, idx, &op, PaymentStatus::Sent); - env.events().publish( - (symbol_short!("payment"), op.recipient.clone()), op.amount, - ); + env.events() + .publish((symbol_short!("payment"), op.recipient.clone()), op.amount); if op.category == symbol_short!("bonus") { - let mut tb: i128 = env.storage().instance() - .get(&DataKey::TotalBonusesPaid).unwrap_or(0); - tb = tb.checked_add(op.amount).ok_or(ContractError::AmountOverflow)?; - env.storage().instance().set(&DataKey::TotalBonusesPaid, &tb); + let mut tb: i128 = env + .storage() + .instance() + .get(&DataKey::TotalBonusesPaid) + .unwrap_or(0); + tb = tb + .checked_add(op.amount) + .ok_or(ContractError::AmountOverflow)?; + env.storage() + .instance() + .set(&DataKey::TotalBonusesPaid, &tb); env.events().publish( - (symbol_short!("bonus"), op.category.clone(), op.recipient.clone()), + ( + symbol_short!("bonus"), + op.category.clone(), + op.recipient.clone(), + ), op.amount, ); } @@ -1148,20 +1380,29 @@ impl BulkPaymentContract { Self::record_usage(env, &sender, total_sent); - let status = if fail_count == 0 { symbol_short!("completed") } - else if success_count == 0 { symbol_short!("rollbck") } - else { symbol_short!("partial") }; + let status = if fail_count == 0 { + symbol_short!("completed") + } else if success_count == 0 { + symbol_short!("rollbck") + } else { + symbol_short!("partial") + }; - env.storage().persistent().set(&DataKey::Batch(batch_id), &BatchRecord { - sender: sender.clone(), - token: token.clone(), - total_sent, - success_count, - fail_count, - status, - }); + env.storage().persistent().set( + &DataKey::Batch(batch_id), + &BatchRecord { + sender: sender.clone(), + token: token.clone(), + total_sent, + success_count, + fail_count, + status, + }, + ); env.storage().persistent().extend_ttl( - &DataKey::Batch(batch_id), PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &DataKey::Batch(batch_id), + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); env.events().publish( @@ -1182,22 +1423,32 @@ impl BulkPaymentContract { status: PaymentStatus, ) { let key = DataKey::PaymentEntry(batch_id, payment_index); - env.storage().temporary().set(&key, &PaymentEntry { - recipient: op.recipient.clone(), - amount: op.amount, - category: op.category.clone(), - status, - }); + env.storage().temporary().set( + &key, + &PaymentEntry { + recipient: op.recipient.clone(), + amount: op.amount, + category: op.category.clone(), + status, + }, + ); env.storage().temporary().extend_ttl( - &key, TEMPORARY_TTL_THRESHOLD, TEMPORARY_TTL_EXTEND_TO, + &key, + TEMPORARY_TTL_THRESHOLD, + TEMPORARY_TTL_EXTEND_TO, ); } fn require_admin(env: &Env) -> Result<(), ContractError> { - let admin: Address = env.storage().persistent().get(&DataKey::Admin) + let admin: Address = env + .storage() + .persistent() + .get(&DataKey::Admin) .ok_or(ContractError::NotInitialized)?; env.storage().persistent().extend_ttl( - &DataKey::Admin, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &DataKey::Admin, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); admin.require_auth(); Ok(()) @@ -1205,7 +1456,9 @@ impl BulkPaymentContract { /// Returns `ContractPaused` if the circuit breaker is engaged. fn require_not_paused(env: &Env) -> Result<(), ContractError> { - let paused: bool = env.storage().instance() + let paused: bool = env + .storage() + .instance() .get(&DataKey::Paused) .unwrap_or(false); if paused { @@ -1215,22 +1468,37 @@ impl BulkPaymentContract { } fn check_and_advance_sequence(env: &Env, expected: u64) -> Result<(), ContractError> { - let current: u64 = env.storage().persistent().get(&DataKey::Sequence) + let current: u64 = env + .storage() + .persistent() + .get(&DataKey::Sequence) .ok_or(ContractError::NotInitialized)?; - if current != expected { return Err(ContractError::SequenceMismatch); } - env.storage().persistent().set(&DataKey::Sequence, &(current + 1)); + if current != expected { + return Err(ContractError::SequenceMismatch); + } + env.storage() + .persistent() + .set(&DataKey::Sequence, &(current + 1)); env.storage().persistent().extend_ttl( - &DataKey::Sequence, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &DataKey::Sequence, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); Ok(()) } fn next_batch_id(env: &Env) -> u64 { - let count: u64 = env.storage().persistent() - .get(&DataKey::BatchCount).unwrap_or(0) + 1; + let count: u64 = env + .storage() + .persistent() + .get(&DataKey::BatchCount) + .unwrap_or(0) + + 1; env.storage().persistent().set(&DataKey::BatchCount, &count); env.storage().persistent().extend_ttl( - &DataKey::BatchCount, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &DataKey::BatchCount, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); count } @@ -1242,47 +1510,121 @@ impl BulkPaymentContract { Ok(()) } + fn default_throttle_config() -> ThrottleConfig { + ThrottleConfig { + max_batch_size: MAX_BATCH_SIZE, + min_ledger_gap: 0, + } + } + + fn throttle_config(env: &Env) -> ThrottleConfig { + env.storage() + .instance() + .get(&DataKey::ThrottleConfig) + .unwrap_or_else(Self::default_throttle_config) + } + + fn validate_throttle_config( + max_batch_size: u32, + min_ledger_gap: u32, + ) -> Result<(), ContractError> { + if max_batch_size == 0 + || max_batch_size > MAX_BATCH_SIZE + || min_ledger_gap > MAX_THROTTLE_LEDGER_GAP + { + return Err(ContractError::InvalidThrottleConfig); + } + Ok(()) + } + + fn validate_batch_len(env: &Env, len: u32) -> Result<(), ContractError> { + if len == 0 { + return Err(ContractError::EmptyBatch); + } + if len > Self::throttle_config(env).max_batch_size { + return Err(ContractError::BatchTooLarge); + } + Ok(()) + } + + fn validate_fee_inputs( + payment_count: u32, + base_fee_stroops: i128, + ) -> Result<(), ContractError> { + if payment_count == 0 || payment_count > MAX_BATCH_SIZE || base_fee_stroops <= 0 { + return Err(ContractError::InvalidFeeConfig); + } + Ok(()) + } + fn effective_limits(env: &Env, account: &Address) -> AccountLimits { - if let Some(limits) = env.storage().persistent() + if let Some(limits) = env + .storage() + .persistent() .get::(&DataKey::AcctLimits(account.clone())) { return limits; } - if let Some(limits) = env.storage().instance() + if let Some(limits) = env + .storage() + .instance() .get::(&DataKey::DefaultLimits) { return limits; } - AccountLimits { daily_limit: 0, weekly_limit: 0, monthly_limit: 0 } + AccountLimits { + daily_limit: 0, + weekly_limit: 0, + monthly_limit: 0, + } } fn current_usage(env: &Env, account: &Address) -> AccountUsage { let ledger = env.ledger().sequence(); - let mut usage: AccountUsage = env.storage().persistent() + let mut usage: AccountUsage = env + .storage() + .persistent() .get(&DataKey::AcctUsage(account.clone())) .unwrap_or(AccountUsage { - daily_spent: 0, daily_reset_ledger: ledger, - weekly_spent: 0, weekly_reset_ledger: ledger, - monthly_spent: 0, monthly_reset_ledger: ledger, + daily_spent: 0, + daily_reset_ledger: ledger, + weekly_spent: 0, + weekly_reset_ledger: ledger, + monthly_spent: 0, + monthly_reset_ledger: ledger, }); - if ledger >= usage.daily_reset_ledger + LEDGERS_PER_DAY { usage.daily_spent = 0; usage.daily_reset_ledger = ledger; } - if ledger >= usage.weekly_reset_ledger + LEDGERS_PER_WEEK { usage.weekly_spent = 0; usage.weekly_reset_ledger = ledger; } - if ledger >= usage.monthly_reset_ledger + LEDGERS_PER_MONTH { usage.monthly_spent = 0; usage.monthly_reset_ledger = ledger; } + if ledger >= usage.daily_reset_ledger + LEDGERS_PER_DAY { + usage.daily_spent = 0; + usage.daily_reset_ledger = ledger; + } + if ledger >= usage.weekly_reset_ledger + LEDGERS_PER_WEEK { + usage.weekly_spent = 0; + usage.weekly_reset_ledger = ledger; + } + if ledger >= usage.monthly_reset_ledger + LEDGERS_PER_MONTH { + usage.monthly_spent = 0; + usage.monthly_reset_ledger = ledger; + } usage } fn check_limits(env: &Env, account: &Address, amount: i128) -> Result<(), ContractError> { let limits = Self::effective_limits(env, account); - let usage = Self::current_usage(env, account); + let usage = Self::current_usage(env, account); if limits.daily_limit > 0 { let projected = usage.daily_spent + amount; if projected > limits.daily_limit { env.events().publish( (symbol_short!("blocked"), account.clone()), - (amount, LimitTier::Daily, usage.daily_spent, limits.daily_limit), + ( + amount, + LimitTier::Daily, + usage.daily_spent, + limits.daily_limit, + ), ); return Err(ContractError::DailyLimitExceeded); } @@ -1292,7 +1634,12 @@ impl BulkPaymentContract { if projected > limits.weekly_limit { env.events().publish( (symbol_short!("blocked"), account.clone()), - (amount, LimitTier::Weekly, usage.weekly_spent, limits.weekly_limit), + ( + amount, + LimitTier::Weekly, + usage.weekly_spent, + limits.weekly_limit, + ), ); return Err(ContractError::WeeklyLimitExceeded); } @@ -1302,7 +1649,12 @@ impl BulkPaymentContract { if projected > limits.monthly_limit { env.events().publish( (symbol_short!("blocked"), account.clone()), - (amount, LimitTier::Monthly, usage.monthly_spent, limits.monthly_limit), + ( + amount, + LimitTier::Monthly, + usage.monthly_spent, + limits.monthly_limit, + ), ); return Err(ContractError::MonthlyLimitExceeded); } @@ -1313,17 +1665,21 @@ impl BulkPaymentContract { fn record_usage(env: &Env, account: &Address, amount: i128) { let mut usage = Self::current_usage(env, account); - usage.daily_spent += amount; - usage.weekly_spent += amount; + usage.daily_spent += amount; + usage.weekly_spent += amount; usage.monthly_spent += amount; - env.storage().persistent().set(&DataKey::AcctUsage(account.clone()), &usage); + env.storage() + .persistent() + .set(&DataKey::AcctUsage(account.clone()), &usage); } fn bump_core_ttl(env: &Env) { for key in [DataKey::Admin, DataKey::BatchCount, DataKey::Sequence] { if env.storage().persistent().has(&key) { env.storage().persistent().extend_ttl( - &key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); } } @@ -1338,9 +1694,18 @@ impl BulkPaymentContract { if last_ledger == current_ledger && current_ledger != 0 { return Err(ContractError::LedgerReplayDetected); } + let min_gap = Self::throttle_config(env).min_ledger_gap; + if current_ledger != 0 && last_ledger != 0 && min_gap > 0 { + let earliest_allowed = last_ledger.saturating_add(min_gap); + if current_ledger < earliest_allowed { + return Err(ContractError::ThrottleLimitExceeded); + } + } env.storage().persistent().set(&key, ¤t_ledger); env.storage().persistent().extend_ttl( - &key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO, + &key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, ); Ok(()) } diff --git a/contracts/bulk_payment/src/test.rs b/contracts/bulk_payment/src/test.rs index 05a54b3a..d9b61368 100644 --- a/contracts/bulk_payment/src/test.rs +++ b/contracts/bulk_payment/src/test.rs @@ -2,11 +2,9 @@ use super::*; use soroban_sdk::{ testutils::Address as _, - testutils::AuthorizedFunction, - testutils::AuthorizedInvocation, testutils::Ledger, token::{Client as TokenClient, StellarAssetClient}, - Address, Env, IntoVal, Vec, + Address, Env, Vec, }; // ── Errors map ──────────────────────────────────────────────────────────────── @@ -32,7 +30,9 @@ fn setup() -> (Env, Address, Address, BulkPaymentContractClient<'static>) { env.mock_all_auths(); let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); let sender = Address::generate(&env); StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000_000); @@ -74,9 +74,21 @@ fn test_execute_batch_success() { let r3 = Address::generate(&env); let mut payments: Vec = Vec::new(&env); - payments.push_back(PaymentOp { recipient: r1.clone(), amount: 100, category: soroban_sdk::symbol_short!("payroll") }); - payments.push_back(PaymentOp { recipient: r2.clone(), amount: 200, category: soroban_sdk::symbol_short!("payroll") }); - payments.push_back(PaymentOp { recipient: r3.clone(), amount: 300, category: soroban_sdk::symbol_short!("payroll") }); + payments.push_back(PaymentOp { + recipient: r1.clone(), + amount: 100, + category: soroban_sdk::symbol_short!("payroll"), + }); + payments.push_back(PaymentOp { + recipient: r2.clone(), + amount: 200, + category: soroban_sdk::symbol_short!("payroll"), + }); + payments.push_back(PaymentOp { + recipient: r3.clone(), + amount: 300, + category: soroban_sdk::symbol_short!("payroll"), + }); let batch_id = client.execute_batch(&sender, &token, &payments, &client.get_sequence()); @@ -180,8 +192,7 @@ fn test_partial_batch_skips_insufficient_funds() { category: soroban_sdk::symbol_short!("payroll"), }); // invalid → skip - let batch_id = - client.execute_batch_partial(&sender, &token, &payments, &client.get_sequence()); + let batch_id = client.execute_batch_partial(&sender, &token, &payments, &client.get_sequence()); let record = client.get_batch(&batch_id); assert_eq!(record.success_count, 1); @@ -203,8 +214,7 @@ fn test_partial_batch_all_fail_status_is_rollbck() { category: soroban_sdk::symbol_short!("payroll"), }); - let batch_id = - client.execute_batch_partial(&sender, &token, &payments, &client.get_sequence()); + let batch_id = client.execute_batch_partial(&sender, &token, &payments, &client.get_sequence()); let record = client.get_batch(&batch_id); assert_eq!(record.success_count, 0); @@ -608,7 +618,9 @@ fn test_benchmark_50_payment_batch() { env.mock_all_auths(); let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); let sender = Address::generate(&env); // Mint enough for 50 payments of 1_000 each = 50_000 StellarAssetClient::new(&env, &token_id).mint(&sender, &100_000); @@ -661,7 +673,9 @@ fn test_benchmark_50_payment_partial_batch() { env.mock_all_auths(); let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); let sender = Address::generate(&env); StellarAssetClient::new(&env, &token_id).mint(&sender, &100_000); @@ -772,7 +786,9 @@ fn test_max_batch_100_payments() { env.mock_all_auths(); let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); let sender = Address::generate(&env); StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000_000); @@ -803,8 +819,6 @@ fn test_max_batch_100_payments() { assert_eq!(record.fail_count, 0); } - - // ══════════════════════════════════════════════════════════════════════════════ // ── GRACEFUL REVERT WITH REFUND TESTS (Issue #261) ──────────────────────────── // ══════════════════════════════════════════════════════════════════════════════ @@ -947,7 +961,7 @@ fn test_v2_partial_invalid_recorded_as_failed() { let (env, sender, token, client) = setup(); let r_good = Address::generate(&env); - let r_bad = Address::generate(&env); + let r_bad = Address::generate(&env); let mut payments: Vec = Vec::new(&env); payments.push_back(PaymentOp { @@ -1020,15 +1034,15 @@ fn test_v2_partial_all_fail_status_rollbck() { /// status transitions to Refunded. #[test] fn test_refund_failed_payment_success() { - let (env, sender, token, client) = setup(); - // Mint a controlled amount to make balance assertions exact. // Mint is already 1_000_000 from setup; use fresh env for precision. let env2 = Env::default(); env2.mock_all_auths(); let token_admin2 = Address::generate(&env2); - let token_id2 = env2.register_stellar_asset_contract_v2(token_admin2.clone()).address(); + let token_id2 = env2 + .register_stellar_asset_contract_v2(token_admin2.clone()) + .address(); let sender2 = Address::generate(&env2); StellarAssetClient::new(&env2, &token_id2).mint(&sender2, &1_000); @@ -1051,8 +1065,7 @@ fn test_refund_failed_payment_success() { category: soroban_sdk::symbol_short!("payroll"), }); - let batch_id = - client2.execute_batch_v2(&sender2, &token_id2, &payments, &0, &false); + let batch_id = client2.execute_batch_v2(&sender2, &token_id2, &payments, &0, &false); let tc2 = TokenClient::new(&env2, &token_id2); // After batch: sender has 400 (1_000 - 600), contract has 0. @@ -1105,7 +1118,9 @@ fn test_refund_positive_held_amount_returns_to_sender() { env.mock_all_auths(); let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); let sender = Address::generate(&env); StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000); @@ -1130,12 +1145,11 @@ fn test_refund_positive_held_amount_returns_to_sender() { category: soroban_sdk::symbol_short!("payroll"), }); - let batch_id = - client.execute_batch_v2(&sender, &token_id, &payments, &0, &false); + let batch_id = client.execute_batch_v2(&sender, &token_id, &payments, &0, &false); let tc = TokenClient::new(&env, &token_id); assert_eq!(tc.balance(&r_valid), 500); - assert_eq!(tc.balance(&sender), 500); // 1_000 - 500 + assert_eq!(tc.balance(&sender), 500); // 1_000 - 500 assert_eq!(tc.balance(&contract_id), 0); // 0 held (zero amount excluded) let e1 = client.get_payment_entry(&batch_id, &1); @@ -1167,8 +1181,7 @@ fn test_refund_already_refunded_panics() { category: soroban_sdk::symbol_short!("payroll"), }); - let batch_id = - client.execute_batch_v2(&sender, &token, &payments, &0, &false); + let batch_id = client.execute_batch_v2(&sender, &token, &payments, &0, &false); client.refund_failed_payment(&batch_id, &0); // first → ok client.refund_failed_payment(&batch_id, &0); // second → AlreadyRefunded @@ -1187,8 +1200,7 @@ fn test_refund_sent_payment_panics() { category: soroban_sdk::symbol_short!("payroll"), }); - let batch_id = - client.execute_batch_v2(&sender, &token, &payments, &0, &false); + let batch_id = client.execute_batch_v2(&sender, &token, &payments, &0, &false); // Index 0 was sent successfully — cannot refund. client.refund_failed_payment(&batch_id, &0); @@ -1216,8 +1228,7 @@ fn test_refund_payment_not_found_panics() { category: soroban_sdk::symbol_short!("payroll"), }); - let batch_id = - client.execute_batch_v2(&sender, &token, &payments, &0, &false); + let batch_id = client.execute_batch_v2(&sender, &token, &payments, &0, &false); // Index 99 was never written. client.refund_failed_payment(&batch_id, &99); @@ -1247,8 +1258,7 @@ fn test_v2_strict_entries_all_sent() { }); } - let batch_id = - client.execute_batch_v2(&sender, &token, &payments, &0, &true); + let batch_id = client.execute_batch_v2(&sender, &token, &payments, &0, &true); for i in 0..5u32 { let entry = client.get_payment_entry(&batch_id, &i); @@ -1264,7 +1274,7 @@ fn test_v2_increments_batch_count() { let (env, sender, token, client) = setup(); let payments = one_payment(&env); - client.execute_batch(&sender, &token, &payments, &0); // batch 1 + client.execute_batch(&sender, &token, &payments, &0); // batch 1 client.execute_batch_v2(&sender, &token, &payments, &1, &true); // batch 2 client.execute_batch_v2(&sender, &token, &payments, &2, &false); // batch 3 @@ -1411,7 +1421,7 @@ fn test_unpause_allows_batch_again() { // // These tests verify that every administrative entry point requires correct // authorization and that no unauthorized actor can modify contract state. -// +// // Soroban's `mock_all_auths()` test helper automatically satisfies all // `require_auth()` calls. We verify correctness by inspecting `env.auths()` // after each call, which returns the list of (Address, AuthorizedInvocation) @@ -1510,9 +1520,18 @@ fn test_read_only_functions_need_no_auth() { let name = client.name(); let version = client.version(); let author = client.author(); - assert_eq!(name, soroban_sdk::String::from_str(&env, env!("CARGO_PKG_NAME"))); - assert_eq!(version, soroban_sdk::String::from_str(&env, env!("CARGO_PKG_VERSION"))); - assert_eq!(author, soroban_sdk::String::from_str(&env, env!("CARGO_PKG_AUTHORS"))); + assert_eq!( + name, + soroban_sdk::String::from_str(&env, env!("CARGO_PKG_NAME")) + ); + assert_eq!( + version, + soroban_sdk::String::from_str(&env, env!("CARGO_PKG_VERSION")) + ); + assert_eq!( + author, + soroban_sdk::String::from_str(&env, env!("CARGO_PKG_AUTHORS")) + ); } /// Verify that `bump_ttl` requires admin auth. @@ -1583,13 +1602,17 @@ fn test_remove_account_limits_requires_admin_auth() { // ══════════════════════════════════════════════════════════════════════════════ /// Helper that initializes the contract with a non-zero ledger sequence. -fn setup_with_ledger(initial_ledger: u32) -> (Env, Address, Address, BulkPaymentContractClient<'static>) { +fn setup_with_ledger( + initial_ledger: u32, +) -> (Env, Address, Address, BulkPaymentContractClient<'static>) { let env = Env::default(); env.mock_all_auths(); env.ledger().set_sequence_number(initial_ledger); let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address(); + let token_id = env + .register_stellar_asset_contract_v2(token_admin.clone()) + .address(); let sender = Address::generate(&env); StellarAssetClient::new(&env, &token_id).mint(&sender, &1_000_000); @@ -1938,7 +1961,7 @@ fn test_get_scheduled_batch_not_found_panics() { #[test] #[should_panic(expected = "Error(Contract, #19)")] fn test_cancel_nonexistent_batch_panics() { - let (env, sender, _token, client) = setup(); + let (_env, sender, _token, client) = setup(); client.cancel_scheduled_batch(&sender, &999); } @@ -1958,3 +1981,114 @@ fn test_cancel_scheduled_batch_returns_held_funds() { // Funds returned on cancel assert_eq!(tc.balance(&sender), balance_before); } + +#[test] +fn test_default_throttle_config_is_protocol_limit() { + let (_env, _sender, _token, client) = setup(); + + let config = client.get_throttle_config(); + + assert_eq!(config.max_batch_size, 100); + assert_eq!(config.min_ledger_gap, 0); +} + +#[test] +fn test_set_throttle_config_updates_limits() { + let (_env, _sender, _token, client) = setup(); + + client.set_throttle_config(&25, &3); + let config = client.get_throttle_config(); + + assert_eq!(config.max_batch_size, 25); + assert_eq!(config.min_ledger_gap, 3); +} + +#[test] +fn test_configured_batch_size_blocks_large_batch() { + let (env, sender, token, client) = setup(); + client.set_throttle_config(&1, &0); + + let mut payments: Vec = Vec::new(&env); + payments.push_back(PaymentOp { + recipient: Address::generate(&env), + amount: 10, + category: soroban_sdk::symbol_short!("payroll"), + }); + payments.push_back(PaymentOp { + recipient: Address::generate(&env), + amount: 20, + category: soroban_sdk::symbol_short!("payroll"), + }); + + let result = client.try_execute_batch(&sender, &token, &payments, &0); + assert_eq!(result, Err(Ok(ContractError::BatchTooLarge))); +} + +#[test] +fn test_min_ledger_gap_throttles_sender() { + let (env, sender, token, client) = setup_with_ledger(100); + client.set_throttle_config(&100, &3); + let payments = one_payment(&env); + + client.execute_batch(&sender, &token, &payments, &0); + + env.ledger().set_sequence_number(102); + let result = client.try_execute_batch(&sender, &token, &payments, &1); + assert_eq!(result, Err(Ok(ContractError::ThrottleLimitExceeded))); +} + +#[test] +fn test_min_ledger_gap_allows_after_gap() { + let (env, sender, token, client) = setup_with_ledger(100); + client.set_throttle_config(&100, &3); + let payments = one_payment(&env); + + client.execute_batch(&sender, &token, &payments, &0); + + env.ledger().set_sequence_number(103); + let batch_id = client.execute_batch(&sender, &token, &payments, &1); + assert_eq!(batch_id, 2); +} + +#[test] +fn test_invalid_throttle_config_rejected() { + let (_env, _sender, _token, client) = setup(); + + let result = client.try_set_throttle_config(&0, &0); + + assert_eq!(result, Err(Ok(ContractError::InvalidThrottleConfig))); +} + +#[test] +fn test_estimate_batch_fee_without_fee_bump() { + let (_env, _sender, _token, client) = setup(); + + let estimate = client.estimate_batch_fee(&3, &100, &false); + + assert_eq!(estimate.payment_count, 3); + assert_eq!(estimate.operation_count, 4); + assert_eq!(estimate.recommended_fee_stroops, 400); + assert_eq!(estimate.budget_fee_stroops, 800); + assert!(!estimate.fee_bump_required); +} + +#[test] +fn test_estimate_batch_fee_with_fee_bump() { + let (_env, _sender, _token, client) = setup(); + + let estimate = client.estimate_batch_fee(&2, &100, &true); + + assert_eq!(estimate.operation_count, 3); + assert_eq!(estimate.recommended_fee_stroops, 600); + assert_eq!(estimate.budget_fee_stroops, 1200); + assert!(estimate.fee_bump_required); +} + +#[test] +fn test_estimate_batch_fee_rejects_invalid_inputs() { + let (_env, _sender, _token, client) = setup(); + + let result = client.try_estimate_batch_fee(&0, &100, &false); + + assert_eq!(result, Err(Ok(ContractError::InvalidFeeConfig))); +} diff --git a/contracts/cross_asset_payment/src/lib.rs b/contracts/cross_asset_payment/src/lib.rs index 9d78a0ce..976b2c66 100644 --- a/contracts/cross_asset_payment/src/lib.rs +++ b/contracts/cross_asset_payment/src/lib.rs @@ -1,8 +1,8 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractevent, contractimpl, contracttype, symbol_short, token, - Address, Env, String, Symbol, + Address, Env, String, Symbol, contract, contracterror, contractevent, contractimpl, + contracttype, symbol_short, token, }; /// Emitted when the current admin proposes a new admin (two-step transfer). @@ -184,9 +184,7 @@ impl CrossAssetPaymentContract { .get(&DataKey::Admin) .expect("Not initialized"); - env.storage() - .persistent() - .set(&DataKey::Admin, &new_admin); + env.storage().persistent().set(&DataKey::Admin, &new_admin); env.storage().persistent().remove(&DataKey::PendingAdmin); Self::bump_core_ttl(&env); @@ -336,7 +334,11 @@ impl CrossAssetPaymentContract { Self::require_pending_status(&record)?; let token_client = token::Client::new(&env, &record.asset); - token_client.transfer(&env.current_contract_address(), &record.from, &record.amount); + token_client.transfer( + &env.current_contract_address(), + &record.from, + &record.amount, + ); record.status = symbol_short!("failed"); Self::store_payment(&env, payment_id, &record); @@ -412,29 +414,22 @@ impl CrossAssetPaymentContract { count } - fn load_payment( - env: &Env, - payment_id: u64, - ) -> Result { + fn load_payment(env: &Env, payment_id: u64) -> Result { let key = DataKey::Payment(payment_id); let record: Option = env.storage().persistent().get(&key); let record = record.ok_or(CrossAssetPaymentError::PaymentNotFound)?; - env.storage().persistent().extend_ttl( - &key, - PAYMENT_TTL_THRESHOLD, - PAYMENT_TTL_EXTEND_TO, - ); + env.storage() + .persistent() + .extend_ttl(&key, PAYMENT_TTL_THRESHOLD, PAYMENT_TTL_EXTEND_TO); Ok(record) } fn store_payment(env: &Env, payment_id: u64, record: &PaymentRecord) { let key = DataKey::Payment(payment_id); env.storage().persistent().set(&key, record); - env.storage().persistent().extend_ttl( - &key, - PAYMENT_TTL_THRESHOLD, - PAYMENT_TTL_EXTEND_TO, - ); + env.storage() + .persistent() + .extend_ttl(&key, PAYMENT_TTL_THRESHOLD, PAYMENT_TTL_EXTEND_TO); } fn require_pending_status(record: &PaymentRecord) -> Result<(), CrossAssetPaymentError> { @@ -493,10 +488,7 @@ impl CrossAssetPaymentContract { admin.require_auth(); } - fn require_matching_admin( - env: &Env, - admin: &Address, - ) -> Result<(), CrossAssetPaymentError> { + fn require_matching_admin(env: &Env, admin: &Address) -> Result<(), CrossAssetPaymentError> { let stored_admin: Address = env .storage() .persistent() @@ -509,10 +501,7 @@ impl CrossAssetPaymentContract { Ok(()) } - fn require_unique_ledger( - env: &Env, - sender: &Address, - ) -> Result<(), CrossAssetPaymentError> { + fn require_unique_ledger(env: &Env, sender: &Address) -> Result<(), CrossAssetPaymentError> { let current_ledger = env.ledger().sequence(); let key = DataKey::LastPaymentLedger(sender.clone()); let last_ledger: u32 = env.storage().persistent().get(&key).unwrap_or(0); diff --git a/contracts/cross_asset_payment/src/test.rs b/contracts/cross_asset_payment/src/test.rs index 42999165..7e418a53 100644 --- a/contracts/cross_asset_payment/src/test.rs +++ b/contracts/cross_asset_payment/src/test.rs @@ -2,7 +2,7 @@ use super::*; use soroban_sdk::testutils::{Address as _, Ledger}; -use soroban_sdk::{token, Address, Env, String}; +use soroban_sdk::{Address, Env, String, token}; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -16,7 +16,12 @@ fn create_token(env: &Env, recipient: &Address, amount: i128) -> Address { token_address } -fn setup() -> (Env, Address, Address, CrossAssetPaymentContractClient<'static>) { +fn setup() -> ( + Env, + Address, + Address, + CrossAssetPaymentContractClient<'static>, +) { let env = Env::default(); env.mock_all_auths(); @@ -76,13 +81,28 @@ fn test_initiate_payment_counter_increments() { let anchor_id = String::from_str(&env, "anc1"); let id1 = client.initiate_payment( - &from, &100, &token_address, &receiver_id, &target_asset, &anchor_id, + &from, + &100, + &token_address, + &receiver_id, + &target_asset, + &anchor_id, ); let id2 = client.initiate_payment( - &from, &200, &token_address, &receiver_id, &target_asset, &anchor_id, + &from, + &200, + &token_address, + &receiver_id, + &target_asset, + &anchor_id, ); let id3 = client.initiate_payment( - &from, &300, &token_address, &receiver_id, &target_asset, &anchor_id, + &from, + &300, + &token_address, + &receiver_id, + &target_asset, + &anchor_id, ); assert_eq!(id1, 1); @@ -187,7 +207,10 @@ fn test_initiate_payment_replay_same_ledger() { &String::from_str(&env, "EUR"), &String::from_str(&env, "anc-2"), ); - assert_eq!(result, Err(Ok(CrossAssetPaymentError::LedgerReplayDetected))); + assert_eq!( + result, + Err(Ok(CrossAssetPaymentError::LedgerReplayDetected)) + ); } #[test] @@ -530,12 +553,17 @@ fn test_accept_admin_transfer_allows_new_admin_operations() { client.accept_admin_transfer(&new_admin); // New admin can perform admin-gated operations (update_status) - let receiver_id = String::from_str(&env, "worker-01"); + let receiver_id = String::from_str(&env, "worker-01"); let target_asset = String::from_str(&env, "USDC"); - let anchor_id = String::from_str(&env, "anchor-1"); + let anchor_id = String::from_str(&env, "anchor-1"); let payment_id = client.initiate_payment( - &from, &200, &token_address, &receiver_id, &target_asset, &anchor_id, + &from, + &200, + &token_address, + &receiver_id, + &target_asset, + &anchor_id, ); // update_status is admin-gated; new_admin must be able to call it client.update_status(&payment_id, &soroban_sdk::symbol_short!("settled")); @@ -583,7 +611,7 @@ fn test_cancel_admin_transfer_clears_pending() { fn test_propose_admin_transfer_replaces_previous_proposal() { let (env, _admin, _contract_id, client) = setup(); - let first_candidate = Address::generate(&env); + let first_candidate = Address::generate(&env); let second_candidate = Address::generate(&env); client.propose_admin_transfer(&first_candidate); diff --git a/contracts/cross_asset_payment/src/test_escrow.rs b/contracts/cross_asset_payment/src/test_escrow.rs index 4133bd0b..8e8c3ee1 100644 --- a/contracts/cross_asset_payment/src/test_escrow.rs +++ b/contracts/cross_asset_payment/src/test_escrow.rs @@ -10,11 +10,9 @@ use super::*; use soroban_sdk::{ + Address, Env, String as SorobanString, testutils::{Address as _, Ledger}, token, - Address, - Env, - String as SorobanString, }; // ══════════════════════════════════════════════════════════════════════════════ @@ -23,13 +21,13 @@ use soroban_sdk::{ fn setup_payment_escrow() -> ( Env, - Address, // admin - Address, // sender - Address, // token_contract - token::Client<'static>, // token_client - token::StellarAssetClient<'static>, // token_admin_client + Address, // admin + Address, // sender + Address, // token_contract + token::Client<'static>, // token_client + token::StellarAssetClient<'static>, // token_admin_client CrossAssetPaymentContractClient<'static>, // payment_client - Address, // contract_address + Address, // contract_address ) { let env = Env::default(); env.mock_all_auths(); @@ -87,7 +85,10 @@ fn test_payment_escrow_locks_funds() { &SorobanString::from_str(&env, "anchor-1"), ); - assert_eq!(token_client.balance(&sender), initial_balance - payment_amount); + assert_eq!( + token_client.balance(&sender), + initial_balance - payment_amount + ); assert_eq!(token_client.balance(&contract_address), payment_amount); assert_eq!(payment_id, 1); } @@ -174,8 +175,7 @@ fn test_complete_payment_releases_funds() { #[test] fn test_multiple_payments_released_independently() { - let (env, admin, sender, token_contract, token_client, _, client, _) = - setup_payment_escrow(); + let (env, admin, sender, token_contract, token_client, _, client, _) = setup_payment_escrow(); let recipient_1 = Address::generate(&env); let recipient_2 = Address::generate(&env); @@ -242,8 +242,7 @@ fn test_fail_payment_refunds_sender() { #[test] fn test_partial_refund_scenario() { - let (env, admin, sender, token_contract, token_client, _, client, _) = - setup_payment_escrow(); + let (env, admin, sender, token_contract, token_client, _, client, _) = setup_payment_escrow(); let recipient = Address::generate(&env); @@ -458,8 +457,7 @@ fn test_zero_balance_after_all_payments_processed() { } for (idx, payment_id) in (1..=10_u64).enumerate() { - env.ledger() - .set_sequence_number(100 + idx as u32); + env.ledger().set_sequence_number(100 + idx as u32); client.complete_payment(&admin, &payment_id, &recipient); } diff --git a/contracts/milestone_escrow/src/lib.rs b/contracts/milestone_escrow/src/lib.rs index 621b4998..16dc9cc7 100644 --- a/contracts/milestone_escrow/src/lib.rs +++ b/contracts/milestone_escrow/src/lib.rs @@ -11,22 +11,22 @@ const PERSISTENT_TTL_EXTEND_TO: u32 = 120_000; #[derive(Copy, Clone, Debug, PartialEq)] #[repr(u32)] pub enum ContractError { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, - InvalidAmount = 4, - EscrowNotFound = 5, - EscrowInactive = 6, - MilestoneNotFound = 7, - MilestoneAlreadyApproved = 8, - MilestoneNotApproved = 9, - InvalidMilestones = 10, - ContractPaused = 11, - LedgerReplayDetected = 12, - SameAdmin = 13, - NotVerifier = 14, - InsufficientFunds = 15, - InsufficientEscrowBalance = 16, + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, + InvalidAmount = 4, + EscrowNotFound = 5, + EscrowInactive = 6, + MilestoneNotFound = 7, + MilestoneAlreadyApproved = 8, + MilestoneNotApproved = 9, + InvalidMilestones = 10, + ContractPaused = 11, + LedgerReplayDetected = 12, + SameAdmin = 13, + NotVerifier = 14, + InsufficientFunds = 15, + InsufficientEscrowBalance = 16, } #[contracttype] @@ -156,9 +156,11 @@ impl MilestoneEscrowContract { .ok_or(ContractError::NotInitialized)?; admin.require_auth(); - env.storage() - .persistent() - .extend_ttl(&DataKey::Admin, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); + env.storage().persistent().extend_ttl( + &DataKey::Admin, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); if admin == new_admin { return Err(ContractError::SameAdmin); @@ -186,11 +188,7 @@ impl MilestoneEscrowContract { env.storage().instance().set(&DataKey::Paused, &paused); - ContractStatusChangedEvent { - paused, - admin, - } - .publish(&env); + ContractStatusChangedEvent { paused, admin }.publish(&env); Ok(()) } @@ -244,15 +242,19 @@ impl MilestoneEscrowContract { .persistent() .get(&DataKey::EscrowCount) .unwrap_or(0); - escrow_count = escrow_count.checked_add(1).ok_or(ContractError::InvalidAmount)?; + escrow_count = escrow_count + .checked_add(1) + .ok_or(ContractError::InvalidAmount)?; let escrow_id = escrow_count; e.storage() .persistent() .set(&DataKey::EscrowCount, &escrow_count); - e.storage() - .persistent() - .extend_ttl(&DataKey::EscrowCount, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); + e.storage().persistent().extend_ttl( + &DataKey::EscrowCount, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); let token_client = token::Client::new(&e, &token); token_client.transfer(&sender, e.current_contract_address(), &total_amount); @@ -269,7 +271,9 @@ impl MilestoneEscrowContract { created_at: e.ledger().timestamp(), }; - e.storage().persistent().set(&DataKey::Escrow(escrow_id), &record); + e.storage() + .persistent() + .set(&DataKey::Escrow(escrow_id), &record); Self::bump_escrow_ttl(&e, escrow_id); EscrowCreatedEvent { @@ -320,9 +324,7 @@ impl MilestoneEscrowContract { } milestone.status = MilestoneStatus::Approved; - record - .milestones - .set(milestone_index, milestone.clone()); + record.milestones.set(milestone_index, milestone.clone()); e.storage() .persistent() @@ -383,9 +385,7 @@ impl MilestoneEscrowContract { } milestone.status = MilestoneStatus::Released; - record - .milestones - .set(milestone_index, milestone.clone()); + record.milestones.set(milestone_index, milestone.clone()); record.released_amount = record .released_amount @@ -485,10 +485,7 @@ impl MilestoneEscrowContract { Ok(()) } - pub fn get_escrow( - e: Env, - escrow_id: u64, - ) -> Result { + pub fn get_escrow(e: Env, escrow_id: u64) -> Result { e.storage() .persistent() .get(&DataKey::Escrow(escrow_id)) @@ -502,10 +499,7 @@ impl MilestoneEscrowContract { .unwrap_or(0) } - pub fn get_releasable_amount( - e: Env, - escrow_id: u64, - ) -> Result { + pub fn get_releasable_amount(e: Env, escrow_id: u64) -> Result { let record: EscrowRecord = e .storage() .persistent() @@ -546,9 +540,11 @@ impl MilestoneEscrowContract { return Err(ContractError::LedgerReplayDetected); } e.storage().persistent().set(key, ¤t_ledger); - e.storage() - .persistent() - .extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); + e.storage().persistent().extend_ttl( + key, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); Ok(()) } diff --git a/contracts/milestone_escrow/src/test.rs b/contracts/milestone_escrow/src/test.rs index a019bf47..32dd6c20 100644 --- a/contracts/milestone_escrow/src/test.rs +++ b/contracts/milestone_escrow/src/test.rs @@ -1,9 +1,9 @@ #![cfg(test)] use super::*; use soroban_sdk::{ + Address, Env, String, Vec, testutils::{Address as _, Ledger}, token, - Address, Env, String, Vec, }; fn setup() -> ( @@ -610,16 +610,21 @@ fn test_approve_across_ledgers_succeeds() { client.approve_milestone(&escrow_id, &1); let record = client.get_escrow(&escrow_id); - assert!(matches!(record.milestones.get(0).unwrap().status, MilestoneStatus::Approved)); - assert!(matches!(record.milestones.get(1).unwrap().status, MilestoneStatus::Approved)); + assert!(matches!( + record.milestones.get(0).unwrap().status, + MilestoneStatus::Approved + )); + assert!(matches!( + record.milestones.get(1).unwrap().status, + MilestoneStatus::Approved + )); } #[test] fn test_single_milestone_escrow() { let (e, sender, beneficiary, verifier, token, token_client, _, client) = setup(); let milestones = make_milestones(&e, &[5000]); - let escrow_id = - client.create_escrow(&sender, &beneficiary, &verifier, &token, &milestones); + let escrow_id = client.create_escrow(&sender, &beneficiary, &verifier, &token, &milestones); let record = client.get_escrow(&escrow_id); assert_eq!(record.total_amount, 5000); diff --git a/contracts/revenue_split/src/lib.rs b/contracts/revenue_split/src/lib.rs index 5477c406..5689d9a2 100644 --- a/contracts/revenue_split/src/lib.rs +++ b/contracts/revenue_split/src/lib.rs @@ -1,7 +1,8 @@ #![no_std] use soroban_sdk::{ - contract, contractevent, contractimpl, contracttype, contracterror, token, Address, Env, String, Vec, + Address, Env, String, Vec, contract, contracterror, contractevent, contractimpl, contracttype, + token, }; #[cfg(test)] @@ -196,11 +197,7 @@ impl RevenueSplitContract { env.storage().instance().set(&DataKey::Paused, &paused); - PauseStateChangedEvent { - paused, - admin, - } - .publish(&env); + PauseStateChangedEvent { paused, admin }.publish(&env); } /// Returns `true` if the contract is currently paused. @@ -231,7 +228,12 @@ impl RevenueSplitContract { /// - `from` must authorize the transaction. /// - Contract must not be paused (circuit breaker). /// - Must be the only distribution in this ledger (replay protection). - pub fn distribute(env: Env, token: Address, from: Address, amount: i128) -> Result<(), RevenueSplitError> { + pub fn distribute( + env: Env, + token: Address, + from: Address, + amount: i128, + ) -> Result<(), RevenueSplitError> { if amount <= 0 { return Ok(()); } @@ -254,9 +256,7 @@ impl RevenueSplitContract { // Accumulate total distributed for this token let td_key = DataKey::TotalDistributed(token.clone()); let prev: i128 = env.storage().persistent().get(&td_key).unwrap_or(0); - env.storage() - .persistent() - .set(&td_key, &(prev + amount)); + env.storage().persistent().set(&td_key, &(prev + amount)); env.storage().persistent().extend_ttl( &td_key, PERSISTENT_TTL_THRESHOLD, @@ -470,5 +470,4 @@ impl RevenueSplitContract { } } } - } diff --git a/contracts/revenue_split/src/test.rs b/contracts/revenue_split/src/test.rs index 15d4b3bb..c33fe165 100644 --- a/contracts/revenue_split/src/test.rs +++ b/contracts/revenue_split/src/test.rs @@ -1,16 +1,21 @@ #![cfg(test)] -use crate::{RevenueSplitContract, RevenueSplitContractClient, RecipientShare, RevenueSplitError}; -use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, Vec}; +use crate::{RecipientShare, RevenueSplitContract, RevenueSplitContractClient, RevenueSplitError}; use soroban_sdk::token::Client as TokenClient; use soroban_sdk::token::StellarAssetClient; +use soroban_sdk::{ + Address, Env, Vec, + testutils::{Address as _, Ledger}, +}; fn create_token_contract<'a>( e: &Env, admin: &Address, ) -> (Address, StellarAssetClient<'a>, TokenClient<'a>) { e.mock_all_auths(); - let contract_id = e.register_stellar_asset_contract_v2(admin.clone()).address(); + let contract_id = e + .register_stellar_asset_contract_v2(admin.clone()) + .address(); let stellar_asset_client = StellarAssetClient::new(e, &contract_id); let token_client = TokenClient::new(e, &contract_id); (contract_id, stellar_asset_client, token_client) @@ -30,10 +35,19 @@ fn test_initialization() { let recipient1 = Address::generate(&env); let recipient2 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 6000 }, - RecipientShare { destination: recipient2.clone(), basis_points: 4000 }, - ]); + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 6000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 4000, + }, + ], + ); let result = client.try_init(&admin, &shares); assert_eq!(result, Ok(Ok(()))); @@ -48,9 +62,13 @@ fn test_init_invalid_shares_sum() { let admin = Address::generate(&env); let recipient1 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 5000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient1.clone(), + basis_points: 5000, + }], + ); let result = client.try_init(&admin, &shares); assert_eq!(result, Err(Ok(RevenueSplitError::BasisPointsSumMismatch))); @@ -65,10 +83,19 @@ fn test_init_duplicate_recipient() { let admin = Address::generate(&env); let recipient = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 5000 }, - RecipientShare { destination: recipient, basis_points: 5000 }, - ]); + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient.clone(), + basis_points: 5000, + }, + RecipientShare { + destination: recipient, + basis_points: 5000, + }, + ], + ); let result = client.try_init(&admin, &shares); assert_eq!(result, Err(Ok(RevenueSplitError::DuplicateRecipient))); @@ -85,9 +112,13 @@ fn test_double_init() { let admin = Address::generate(&env); let recipient = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10000, + }], + ); client.init(&admin, &shares); let result = client.try_init(&admin, &shares); @@ -114,11 +145,23 @@ fn test_distribution() { let recipient2 = Address::generate(&env); let recipient3 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 5000 }, - RecipientShare { destination: recipient2.clone(), basis_points: 3000 }, - RecipientShare { destination: recipient3.clone(), basis_points: 2000 }, - ]); + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 5000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 3000, + }, + RecipientShare { + destination: recipient3.clone(), + basis_points: 2000, + }, + ], + ); contract_client.init(&admin, &shares); @@ -144,16 +187,29 @@ fn test_update_recipients() { let admin = Address::generate(&env); let recipient1 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 10000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient1.clone(), + basis_points: 10000, + }], + ); client.init(&admin, &shares); let recipient2 = Address::generate(&env); - let new_shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 5000 }, - RecipientShare { destination: recipient2.clone(), basis_points: 5000 }, - ]); + let new_shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 5000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 5000, + }, + ], + ); client.update_recipients(&new_shares); } @@ -169,16 +225,29 @@ fn test_update_recipients_rejects_zero_share() { let admin = Address::generate(&env); let recipient1 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 10000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient1.clone(), + basis_points: 10000, + }], + ); client.init(&admin, &shares); let recipient2 = Address::generate(&env); - let new_shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1, basis_points: 10000 }, - RecipientShare { destination: recipient2, basis_points: 0 }, - ]); + let new_shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1, + basis_points: 10000, + }, + RecipientShare { + destination: recipient2, + basis_points: 0, + }, + ], + ); let result = client.try_update_recipients(&new_shares); assert_eq!(result, Err(Ok(RevenueSplitError::ZeroBasisPoints))); @@ -195,9 +264,13 @@ fn test_set_admin() { let new_admin = Address::generate(&env); let recipient = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10000, + }], + ); client.init(&admin, &shares); client.set_admin(&new_admin); @@ -223,9 +296,13 @@ fn test_distribute_replay_same_ledger() { let admin = Address::generate(&env); let recipient = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10000, + }], + ); client.init(&admin, &shares); @@ -264,8 +341,7 @@ fn test_distribute_allowed_different_ledgers() { env.ledger().set_sequence_number(50); let token_admin = Address::generate(&env); - let (token_id, stellar_asset_client, token_client) = - create_token_contract(&env, &token_admin); + let (token_id, stellar_asset_client, token_client) = create_token_contract(&env, &token_admin); let contract_id = env.register(RevenueSplitContract, ()); let contract_client = RevenueSplitContractClient::new(&env, &contract_id); @@ -274,10 +350,19 @@ fn test_distribute_allowed_different_ledgers() { let recipient1 = Address::generate(&env); let recipient2 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 3333 }, - RecipientShare { destination: recipient2.clone(), basis_points: 6667 }, - ]); + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 3333, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 6667, + }, + ], + ); contract_client.init(&admin, &shares); @@ -303,16 +388,29 @@ fn test_update_recipients_invalid_sum() { let admin = Address::generate(&env); let recipient1 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 10000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient1.clone(), + basis_points: 10000, + }], + ); client.init(&admin, &shares); let recipient2 = Address::generate(&env); - let bad_shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 4000 }, - RecipientShare { destination: recipient2.clone(), basis_points: 5000 }, - ]); + let bad_shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 4000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 5000, + }, + ], + ); let result = client.try_update_recipients(&bad_shares); assert_eq!(result, Err(Ok(RevenueSplitError::BasisPointsSumMismatch))); } @@ -324,17 +422,20 @@ fn test_distribute_updates_ledger_state() { env.ledger().set_sequence_number(50); let token_admin = Address::generate(&env); - let (token_id, stellar_asset_client, token_client) = - create_token_contract(&env, &token_admin); + let (token_id, stellar_asset_client, token_client) = create_token_contract(&env, &token_admin); let contract_id = env.register(RevenueSplitContract, ()); let client = RevenueSplitContractClient::new(&env, &contract_id); let admin = Address::generate(&env); let recipient = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10000, + }], + ); client.init(&admin, &shares); let sender = Address::generate(&env); @@ -361,10 +462,19 @@ fn test_get_recipients_returns_current_configuration() { let recipient1 = Address::generate(&env); let recipient2 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 7000 }, - RecipientShare { destination: recipient2.clone(), basis_points: 3000 }, - ]); + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 7000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 3000, + }, + ], + ); client.init(&admin, &shares); @@ -384,10 +494,19 @@ fn test_preview_distribution_preserves_remainder_on_last_recipient() { let recipient1 = Address::generate(&env); let recipient2 = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 3333 }, - RecipientShare { destination: recipient2.clone(), basis_points: 6667 }, - ]); + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 3333, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 6667, + }, + ], + ); client.init(&admin, &shares); @@ -411,17 +530,25 @@ fn test_total_distributed_accumulates_across_calls() { let recipient1 = Address::generate(&env); let recipient2 = Address::generate(&env); - let (token_contract, token_admin_client, token_client) = - create_token_contract(&env, &admin); + let (token_contract, token_admin_client, token_client) = create_token_contract(&env, &admin); token_admin_client.mint(&sender, &100_000); let contract_id = env.register(RevenueSplitContract, ()); let client = RevenueSplitContractClient::new(&env, &contract_id); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 5000 }, - RecipientShare { destination: recipient2.clone(), basis_points: 5000 }, - ]); + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 5000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 5000, + }, + ], + ); client.init(&admin, &shares); env.ledger().set_sequence_number(1); @@ -447,12 +574,13 @@ fn test_total_distributed_starts_at_zero() { let contract_id = env.register(RevenueSplitContract, ()); let client = RevenueSplitContractClient::new(&env, &contract_id); - let shares = Vec::from_array(&env, [ - RecipientShare { + let shares = Vec::from_array( + &env, + [RecipientShare { destination: Address::generate(&env), basis_points: 10_000, - }, - ]); + }], + ); client.init(&admin, &shares); assert_eq!(client.get_total_distributed(&token_contract), 0); @@ -462,8 +590,14 @@ fn test_total_distributed_starts_at_zero() { // ── CIRCUIT BREAKER TESTS (Issue #191 / Part 46) ───────────────────────────── // ══════════════════════════════════════════════════════════════════════════════ -fn setup_with_token( -) -> (Env, RevenueSplitContractClient<'static>, Address, Address, Address, Address) { +fn setup_with_token() -> ( + Env, + RevenueSplitContractClient<'static>, + Address, + Address, + Address, + Address, +) { let env = Env::default(); env.mock_all_auths(); @@ -475,10 +609,19 @@ fn setup_with_token( let contract_id = env.register(RevenueSplitContract, ()); let client = RevenueSplitContractClient::new(&env, &contract_id); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient1.clone(), basis_points: 6000 }, - RecipientShare { destination: recipient2.clone(), basis_points: 4000 }, - ]); + let shares = Vec::from_array( + &env, + [ + RecipientShare { + destination: recipient1.clone(), + basis_points: 6000, + }, + RecipientShare { + destination: recipient2.clone(), + basis_points: 4000, + }, + ], + ); client.init(&admin, &shares); (env, client, admin, sender, recipient1, recipient2) @@ -521,9 +664,13 @@ fn test_distribute_blocked_when_paused() { let contract_id = env.register(RevenueSplitContract, ()); let client = RevenueSplitContractClient::new(&env, &contract_id); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10_000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10_000, + }], + ); client.init(&admin, &shares); client.set_paused(&true); @@ -548,9 +695,13 @@ fn test_distribute_succeeds_after_unpause() { let contract_id = env.register(RevenueSplitContract, ()); let client = RevenueSplitContractClient::new(&env, &contract_id); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10_000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10_000, + }], + ); client.init(&admin, &shares); client.set_paused(&true); client.set_paused(&false); @@ -575,9 +726,13 @@ fn test_distribution_count_increments() { let contract_id = env.register(RevenueSplitContract, ()); let client = RevenueSplitContractClient::new(&env, &contract_id); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10_000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10_000, + }], + ); client.init(&admin, &shares); assert_eq!(client.get_distribution_count(), 0); @@ -607,16 +762,32 @@ fn test_update_recipients_emits_event_and_stores_new_config() { let r2 = Address::generate(&env); let r3 = Address::generate(&env); - let initial = Vec::from_array(&env, [ - RecipientShare { destination: r1.clone(), basis_points: 10_000 }, - ]); + let initial = Vec::from_array( + &env, + [RecipientShare { + destination: r1.clone(), + basis_points: 10_000, + }], + ); client.init(&admin, &initial); - let updated = Vec::from_array(&env, [ - RecipientShare { destination: r1.clone(), basis_points: 4000 }, - RecipientShare { destination: r2.clone(), basis_points: 3000 }, - RecipientShare { destination: r3.clone(), basis_points: 3000 }, - ]); + let updated = Vec::from_array( + &env, + [ + RecipientShare { + destination: r1.clone(), + basis_points: 4000, + }, + RecipientShare { + destination: r2.clone(), + basis_points: 3000, + }, + RecipientShare { + destination: r3.clone(), + basis_points: 3000, + }, + ], + ); client.update_recipients(&updated); let stored = client.get_recipients(); @@ -635,9 +806,13 @@ fn test_set_admin_updates_stored_admin() { let new_admin = Address::generate(&env); let recipient = Address::generate(&env); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10_000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10_000, + }], + ); client.init(&admin, &shares); client.set_admin(&new_admin); @@ -661,9 +836,13 @@ fn test_distribute_noop_on_zero_amount() { let contract_id = env.register(RevenueSplitContract, ()); let client = RevenueSplitContractClient::new(&env, &contract_id); - let shares = Vec::from_array(&env, [ - RecipientShare { destination: recipient.clone(), basis_points: 10_000 }, - ]); + let shares = Vec::from_array( + &env, + [RecipientShare { + destination: recipient.clone(), + basis_points: 10_000, + }], + ); client.init(&admin, &shares); // Zero-amount distribute is a no-op: no transfer, no ledger update diff --git a/contracts/smart_wallet/src/lib.rs b/contracts/smart_wallet/src/lib.rs index 20543171..71f4f9ec 100644 --- a/contracts/smart_wallet/src/lib.rs +++ b/contracts/smart_wallet/src/lib.rs @@ -228,7 +228,9 @@ impl SmartWalletContract { j += 1; } - env.storage().instance().set(&DataKey::Signers, &new_signers); + env.storage() + .instance() + .set(&DataKey::Signers, &new_signers); SignerRemovedEvent { removed: signer, diff --git a/contracts/smart_wallet/src/test.rs b/contracts/smart_wallet/src/test.rs index 925c6eb6..1ad2656f 100644 --- a/contracts/smart_wallet/src/test.rs +++ b/contracts/smart_wallet/src/test.rs @@ -1,6 +1,8 @@ #![cfg(test)] -use crate::{SignatureProof, SignerKey, SmartWalletContract, SmartWalletContractClient, WalletError}; +use crate::{ + SignatureProof, SignerKey, SmartWalletContract, SmartWalletContractClient, WalletError, +}; use core::convert::TryInto; use ed25519_dalek::{Signer as _, SigningKey as Ed25519SigningKey}; use k256::ecdsa::SigningKey as SecpSigningKey; diff --git a/contracts/vesting_escrow/src/lib.rs b/contracts/vesting_escrow/src/lib.rs index d0f55486..cd89133b 100644 --- a/contracts/vesting_escrow/src/lib.rs +++ b/contracts/vesting_escrow/src/lib.rs @@ -10,29 +10,29 @@ use soroban_sdk::{ #[derive(Copy, Clone, Debug, PartialEq)] #[repr(u32)] pub enum ContractError { - AlreadyInitialized = 1, - NotInitialized = 2, - Unauthorized = 3, + AlreadyInitialized = 1, + NotInitialized = 2, + Unauthorized = 3, /// Duration must be >= cliff duration. - InvalidDuration = 4, + InvalidDuration = 4, /// Amount must be > 0. - InvalidAmount = 5, + InvalidAmount = 5, /// Grant has already been revoked or is inactive. - AlreadyRevoked = 6, + AlreadyRevoked = 6, /// Contract is paused — claim and clawback operations are suspended. - ContractPaused = 7, + ContractPaused = 7, /// Operation already processed in this ledger sequence. - LedgerReplayDetected = 8, + LedgerReplayDetected = 8, /// Vesting grant is no longer active (required for beneficiary transfer). - GrantInactive = 9, + GrantInactive = 9, /// Same admin address supplied — no change required. - SameAdmin = 10, + SameAdmin = 10, /// Clawback amount must be positive. - InvalidClawbackAmount = 11, + InvalidClawbackAmount = 11, /// Extension seconds must be positive. - InvalidExtension = 12, + InvalidExtension = 12, /// Partial clawback would reduce the grant below what has already been claimed. - ClawbackBelowClaimed = 13, + ClawbackBelowClaimed = 13, } // ── Events ──────────────────────────────────────────────────────────────────── @@ -254,14 +254,18 @@ impl VestingContract { /// Only the current admin may call this function. If `new_admin` equals /// the current admin the call returns `SameAdmin` without side effects. pub fn set_admin(env: Env, new_admin: Address) -> Result<(), ContractError> { - let admin: Address = env.storage().persistent() + let admin: Address = env + .storage() + .persistent() .get(&DataKey::Admin) .ok_or(ContractError::NotInitialized)?; admin.require_auth(); - env.storage() - .persistent() - .extend_ttl(&DataKey::Admin, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO); + env.storage().persistent().extend_ttl( + &DataKey::Admin, + PERSISTENT_TTL_THRESHOLD, + PERSISTENT_TTL_EXTEND_TO, + ); if admin == new_admin { return Err(ContractError::SameAdmin); @@ -290,18 +294,16 @@ impl VestingContract { /// /// Only the current admin may call this. pub fn set_paused(env: Env, paused: bool) -> Result<(), ContractError> { - let admin: Address = env.storage().persistent() + let admin: Address = env + .storage() + .persistent() .get(&DataKey::Admin) .ok_or(ContractError::NotInitialized)?; admin.require_auth(); env.storage().instance().set(&DataKey::Paused, &paused); - ContractStatusChangedEvent { - paused, - admin, - } - .publish(&env); + ContractStatusChangedEvent { paused, admin }.publish(&env); Ok(()) } @@ -326,17 +328,22 @@ impl VestingContract { /// Marks the contract as upgraded to a new version (admin only). /// Used for tracking contract state evolution and enabling state migrations. pub fn mark_upgrade(env: Env, new_version: u32) -> Result<(), ContractError> { - let admin: Address = env.storage().persistent() + let admin: Address = env + .storage() + .persistent() .get(&DataKey::Admin) .ok_or(ContractError::NotInitialized)?; admin.require_auth(); - let old_version = env.storage() + let old_version = env + .storage() .persistent() .get(&DataKey::Version) .unwrap_or(1); - env.storage().persistent().set(&DataKey::Version, &new_version); + env.storage() + .persistent() + .set(&DataKey::Version, &new_version); env.storage().persistent().extend_ttl( &DataKey::Version, PERSISTENT_TTL_THRESHOLD, @@ -512,10 +519,7 @@ impl VestingContract { /// This allows the clawback admin to prolong the vesting period without /// changing the cliff. Only callable while the grant is active. The /// additional seconds must be positive. - pub fn extend_vesting( - env: Env, - additional_seconds: u64, - ) -> Result<(), ContractError> { + pub fn extend_vesting(env: Env, additional_seconds: u64) -> Result<(), ContractError> { let mut config: VestingConfig = env .storage() .persistent() @@ -535,9 +539,7 @@ impl VestingContract { let previous_duration = config.duration_seconds; let previous_end = config.start_time.saturating_add(previous_duration); - config.duration_seconds = config - .duration_seconds - .saturating_add(additional_seconds); + config.duration_seconds = config.duration_seconds.saturating_add(additional_seconds); let new_end = config.start_time.saturating_add(config.duration_seconds); env.storage().persistent().set(&DataKey::Config, &config); @@ -650,10 +652,7 @@ impl VestingContract { /// Transfers the vesting grant to a new beneficiary address. Only the /// `clawback_admin` may call this (e.g. to handle account migration). /// The new beneficiary inherits all unclaimed vested and future tokens. - pub fn transfer_beneficiary( - e: Env, - new_beneficiary: Address, - ) -> Result<(), ContractError> { + pub fn transfer_beneficiary(e: Env, new_beneficiary: Address) -> Result<(), ContractError> { let mut config: VestingConfig = e .storage() .persistent() @@ -749,11 +748,7 @@ impl VestingContract { } fn calc_locked(total: i128, claimed: i128) -> i128 { - if total <= claimed { - 0 - } else { - total - claimed - } + if total <= claimed { 0 } else { total - claimed } } fn calc_progress_bps(vested: i128, total: i128) -> u32 { @@ -783,11 +778,7 @@ impl VestingContract { } fn max_i128(lhs: i128, rhs: i128) -> i128 { - if lhs >= rhs { - lhs - } else { - rhs - } + if lhs >= rhs { lhs } else { rhs } } /// Ensures the operation has not already been executed in the current ledger diff --git a/contracts/vesting_escrow/src/test.rs b/contracts/vesting_escrow/src/test.rs index 4577b0d9..d0562751 100644 --- a/contracts/vesting_escrow/src/test.rs +++ b/contracts/vesting_escrow/src/test.rs @@ -232,7 +232,13 @@ fn test_initialize_zero_cliff() { fn test_vested_amount_before_cliff() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -245,7 +251,13 @@ fn test_vested_amount_before_cliff() { fn test_vested_amount_at_cliff_boundary() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -257,7 +269,13 @@ fn test_vested_amount_at_cliff_boundary() { fn test_vested_amount_linear_progression() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -276,7 +294,13 @@ fn test_vested_amount_linear_progression() { fn test_vested_amount_at_duration_end() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -288,7 +312,13 @@ fn test_vested_amount_at_duration_end() { fn test_vested_amount_after_duration_end() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -305,7 +335,13 @@ fn test_claim_before_cliff_does_nothing() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -322,7 +358,13 @@ fn test_claim_partial_vesting() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -339,7 +381,13 @@ fn test_claim_multiple_partial_claims() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -365,7 +413,13 @@ fn test_claim_full_after_duration() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -382,7 +436,13 @@ fn test_claim_idempotent_when_nothing_new() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -405,7 +465,13 @@ fn test_clawback_before_any_vesting() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.clawback(); @@ -421,7 +487,13 @@ fn test_clawback_partial_vesting() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -440,7 +512,13 @@ fn test_clawback_then_beneficiary_can_claim_vested() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -460,7 +538,13 @@ fn test_clawback_then_beneficiary_can_claim_vested() { fn test_clawback_twice_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); e.ledger().set_sequence_number(10); @@ -475,7 +559,13 @@ fn test_clawback_after_full_vesting() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -494,7 +584,13 @@ fn test_clawback_after_partial_claim() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -526,7 +622,13 @@ fn test_contract_balance_after_full_claim() { setup(); let contract_id = client.address.clone(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -544,7 +646,13 @@ fn test_contract_balance_after_clawback_and_claim() { setup(); let contract_id = client.address.clone(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -572,7 +680,13 @@ fn test_contract_balance_after_clawback_and_claim() { fn test_claim_replay_same_ledger_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -589,7 +703,13 @@ fn test_claim_allowed_different_ledgers() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -762,7 +882,13 @@ fn test_contract_metadata() { fn test_transfer_beneficiary_updates_config() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let new_beneficiary = Address::generate(&e); @@ -777,7 +903,13 @@ fn test_transferred_beneficiary_can_claim() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let new_beneficiary = Address::generate(&e); @@ -795,7 +927,13 @@ fn test_transferred_beneficiary_can_claim() { fn test_transfer_beneficiary_fails_when_inactive() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.clawback(); @@ -813,7 +951,13 @@ fn test_transfer_beneficiary_fails_when_inactive() { fn test_set_admin_success() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let new_admin = Address::generate(&e); @@ -826,7 +970,13 @@ fn test_set_admin_success() { fn test_set_admin_same_admin_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let result = client.try_set_admin(&admin); @@ -841,7 +991,13 @@ fn test_set_admin_same_admin_fails() { fn test_pause_and_unpause() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); assert!(!client.is_paused()); @@ -857,7 +1013,13 @@ fn test_pause_and_unpause() { fn test_claim_blocked_when_paused() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.set_paused(&true); @@ -873,7 +1035,13 @@ fn test_claim_blocked_when_paused() { fn test_clawback_blocked_when_paused() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.set_paused(&true); @@ -887,7 +1055,13 @@ fn test_claim_resumes_after_unpause() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.set_paused(&true); @@ -911,7 +1085,13 @@ fn test_partial_clawback_reduces_grant() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -930,7 +1110,13 @@ fn test_partial_clawback_preserves_vesting() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -950,7 +1136,13 @@ fn test_partial_clawback_preserves_vesting() { fn test_partial_clawback_below_claimed_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -966,7 +1158,13 @@ fn test_partial_clawback_below_claimed_fails() { fn test_partial_clawback_inactive_grant_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.clawback(); @@ -979,7 +1177,13 @@ fn test_partial_clawback_inactive_grant_fails() { fn test_partial_clawback_paused_contract_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.set_paused(&true); @@ -992,7 +1196,13 @@ fn test_partial_clawback_paused_contract_fails() { fn test_partial_clawback_zero_amount_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let result = client.try_partial_clawback(&0); @@ -1007,7 +1217,13 @@ fn test_partial_clawback_zero_amount_fails() { fn test_extend_vesting_increases_duration() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.extend_vesting(&500); @@ -1020,7 +1236,13 @@ fn test_extend_vesting_increases_duration() { fn test_extend_vesting_slows_vesting_rate() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); @@ -1041,7 +1263,13 @@ fn test_extend_vesting_slows_vesting_rate() { fn test_extend_vesting_inactive_grant_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.clawback(); @@ -1054,7 +1282,13 @@ fn test_extend_vesting_inactive_grant_fails() { fn test_extend_vesting_zero_seconds_fails() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let result = client.try_extend_vesting(&0); @@ -1065,7 +1299,13 @@ fn test_extend_vesting_zero_seconds_fails() { fn test_extend_vesting_multiple_extensions() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); client.extend_vesting(&500); @@ -1080,7 +1320,13 @@ fn test_extend_vesting_then_clawback_still_works() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, token_client, _, client) = setup(); init_default( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, ); let start = e.ledger().timestamp(); diff --git a/contracts/vesting_escrow/src/test_escrow_logic.rs b/contracts/vesting_escrow/src/test_escrow_logic.rs index dd968f1a..28c22847 100644 --- a/contracts/vesting_escrow/src/test_escrow_logic.rs +++ b/contracts/vesting_escrow/src/test_escrow_logic.rs @@ -166,8 +166,16 @@ fn test_escrow_locks_funds_on_initialization() { let escrow_amount = 50_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 100, + 1000, ); assert_eq!( @@ -195,8 +203,16 @@ fn test_escrow_holds_funds_during_cliff_period() { let escrow_amount = 100_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 500, 2000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 500, + 2000, ); let start = e.ledger().timestamp(); @@ -228,8 +244,16 @@ fn test_escrow_prevents_unauthorized_withdrawal() { let escrow_amount = 75_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -246,15 +270,31 @@ fn test_escrow_multiple_schedules_independent() { let contract_id_1 = e.register(VestingContract, ()); let client_1 = VestingContractClient::new(&e, &contract_id_1); init_escrow( - &client_1, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 100, 1000, + &client_1, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 100, + 1000, ); let contract_id_2 = e.register(VestingContract, ()); let client_2 = VestingContractClient::new(&e, &contract_id_2); init_escrow( - &client_2, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 20_000, 200, 2000, + &client_2, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 20_000, + 200, + 2000, ); assert_eq!(token_client.balance(&contract_id_1), 10_000); @@ -279,8 +319,16 @@ fn test_linear_vesting_calculation() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 0, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 0, + 1000, ); let start = e.ledger().timestamp(); @@ -315,8 +363,16 @@ fn test_vesting_with_cliff_calculation() { let cliff = 300; let duration = 1200; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 12_000, cliff, duration, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 12_000, + cliff, + duration, ); let start = e.ledger().timestamp(); @@ -340,8 +396,16 @@ fn test_claimable_amount_calculation() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -366,8 +430,16 @@ fn test_vesting_precision_no_rounding_errors() { let amount = 999_997; let duration = 997; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - amount, 0, duration, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + amount, + 0, + duration, ); let start = e.ledger().timestamp(); @@ -400,8 +472,16 @@ fn test_partial_claim_releases_correct_amount() { let escrow_amount = 20_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -427,8 +507,16 @@ fn test_multiple_small_claims() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 0, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 0, + 1000, ); let start = e.ledger().timestamp(); @@ -464,8 +552,16 @@ fn test_claim_after_full_vesting_releases_all() { let escrow_amount = 50_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 200, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 200, + 1000, ); let start = e.ledger().timestamp(); @@ -498,8 +594,16 @@ fn test_clawback_returns_unvested_to_admin() { let escrow_amount = 10_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -518,8 +622,16 @@ fn test_clawback_before_cliff_returns_all() { let escrow_amount = 25_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 500, 2000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 500, + 2000, ); let start = e.ledger().timestamp(); @@ -537,8 +649,16 @@ fn test_clawback_after_partial_claim() { let escrow_amount = 10_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -566,8 +686,16 @@ fn test_clawback_deactivates_future_vesting() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -589,8 +717,16 @@ fn test_clawback_twice_fails() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -624,8 +760,16 @@ fn test_total_supply_conservation() { let escrow_amount = 30_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 100, + 1000, ); let total = token_client.balance(&funder) @@ -661,8 +805,16 @@ fn test_escrow_balance_equals_unclaimed_vested() { let escrow_amount = 10_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 0, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 0, + 1000, ); let start = e.ledger().timestamp(); @@ -704,8 +856,16 @@ fn test_no_token_loss_after_clawback_and_full_claim() { let escrow_amount = 10_000; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - escrow_amount, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + escrow_amount, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -790,8 +950,16 @@ fn test_very_large_escrow_amount() { token_admin_client.mint(&funder, &large_amount); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - large_amount, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + large_amount, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -809,8 +977,16 @@ fn test_very_long_vesting_duration() { let ten_years = 10 * 365 * 24 * 60 * 60; init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 0, ten_years, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 0, + ten_years, ); let start = e.ledger().timestamp(); @@ -825,8 +1001,16 @@ fn test_claim_with_no_vested_amount_is_noop() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 500, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 500, + 1000, ); let start = e.ledger().timestamp(); @@ -848,15 +1032,31 @@ fn test_concurrent_escrows_same_beneficiary() { let contract_1 = e.register(VestingContract, ()); let client_1 = VestingContractClient::new(&e, &contract_1); init_escrow( - &client_1, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 5_000, 0, 1000, + &client_1, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 5_000, + 0, + 1000, ); let contract_2 = e.register(VestingContract, ()); let client_2 = VestingContractClient::new(&e, &contract_2); init_escrow( - &client_2, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 8_000, 0, 2000, + &client_2, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 8_000, + 0, + 2000, ); let start = e.ledger().timestamp(); @@ -890,8 +1090,16 @@ fn property_vested_preview_is_monotonic_capped_and_query_consistent() { let (e, funder, beneficiary, clawback_admin, admin, token_contract, _, _, client, _) = setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - amount, cliff, duration, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + amount, + cliff, + duration, ); let start = e.ledger().timestamp(); @@ -951,8 +1159,16 @@ fn property_claiming_preserves_supply_and_locks_only_unclaimed_tokens() { let initial_supply = token_client.balance(&funder); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - amount, cliff, duration, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + amount, + cliff, + duration, ); let start = e.ledger().timestamp(); @@ -1003,8 +1219,16 @@ fn property_clawback_caps_future_vesting_and_preserves_accounting() { let initial_supply = token_client.balance(&funder); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - amount, cliff, duration, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + amount, + cliff, + duration, ); let start = e.ledger().timestamp(); @@ -1107,8 +1331,16 @@ fn property_admin_can_pause_unpause_multiple_times() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 100, + 1000, ); for i in 0..5 { @@ -1122,7 +1354,11 @@ fn property_admin_can_pause_unpause_multiple_times() { assert!(!client.is_paused()); let claim_result = client.try_claim(); - assert!(claim_result.is_ok(), "Claim should succeed after unpause (iteration {})", i); + assert!( + claim_result.is_ok(), + "Claim should succeed after unpause (iteration {})", + i + ); } } @@ -1132,8 +1368,16 @@ fn property_admin_transfer_preserves_pause_state() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 10_000, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 10_000, + 100, + 1000, ); client.set_paused(&true); @@ -1167,8 +1411,16 @@ fn property_partial_clawback_maintains_supply_conservation() { let initial_supply = token_client.balance(&funder); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 20_000, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 20_000, + 100, + 1000, ); let start = e.ledger().timestamp(); @@ -1189,8 +1441,16 @@ fn property_extend_vesting_and_partial_clawback_compose() { setup_escrow(); init_escrow( - &client, &e, &funder, &beneficiary, &token_contract, &clawback_admin, &admin, - 20_000, 100, 1000, + &client, + &e, + &funder, + &beneficiary, + &token_contract, + &clawback_admin, + &admin, + 20_000, + 100, + 1000, ); let start = e.ledger().timestamp();