From 713ad517a2ea6c17f44c2544f55820fc0fccc6c1 Mon Sep 17 00:00:00 2001 From: DevSolex Date: Tue, 18 Aug 2026 21:10:46 +0100 Subject: [PATCH 1/2] feat(vault): configurable protocol fee on payment release (#100) Implements issue #100: admin-configurable basis-points fee deducted from each release_payment, accrued per asset, and claimable by the configured recipient. Changes to lib.rs: - New events: FeeSetEvent, FeeAccruedEvent, FeeClaimedEvent - New errors: FeeBpsExceedsCap (24), NoFeesAccrued (25) - New DataKey variants: FeeConfig, AccruedFees(Address) - New struct: FeeConfig { bps: u32, recipient: Option
} - Constant: MAX_FEE_BPS = 1000 (10% hard cap) - New methods: set_fee, get_fee, get_accrued_fees, claim_fees - Private helper: compute_fee (rounds fee DOWN; orchestrator gets remainder so no unit of USDC is created or lost) - release_payment: deducts fee, pays orchestrator the remainder, accrues fee to recipient's per-asset claimable balance - Zero bps or absent recipient = byte-for-byte identical to prior behavior (regression safe) - CONTRACT_VERSION bumped 4 -> 5 Changes to tests.rs: - 16 new fee tests covering: set/get, cap enforcement, accrual, claim, wrong-caller rejection, zero-fee path, no-recipient path, dust rounding, cumulative accrual, accounting invariant, and retroactivity guarantee - Updated test_version_returns_contract_version to expect 5 cargo test: 136/136 pass cargo clippy --all-targets -- -D warnings: clean --- contracts/agent-vault/src/lib.rs | 264 +++++++++++++++++- contracts/agent-vault/src/tests.rs | 414 +++++++++++++++++++++++++++++ 2 files changed, 676 insertions(+), 2 deletions(-) diff --git a/contracts/agent-vault/src/lib.rs b/contracts/agent-vault/src/lib.rs index 7379d3c..201f9e4 100644 --- a/contracts/agent-vault/src/lib.rs +++ b/contracts/agent-vault/src/lib.rs @@ -129,6 +129,33 @@ pub struct DisputeResolvedEvent { pub payout_to_orchestrator: i128, } +#[contractevent] +pub struct FeeSetEvent { + #[topic] + pub admin: Address, + pub bps: u32, + pub recipient: Option
, +} + +#[contractevent] +pub struct FeeAccruedEvent { + #[topic] + pub asset: Address, + #[topic] + pub recipient: Address, + pub fee_amount: i128, + pub task_id: u64, +} + +#[contractevent] +pub struct FeeClaimedEvent { + #[topic] + pub asset: Address, + #[topic] + pub recipient: Address, + pub amount: i128, +} + #[contracterror] #[derive(Clone, Copy, PartialEq, Eq)] pub enum VaultError { @@ -157,6 +184,8 @@ pub enum VaultError { TaskNotDisputed = 23, ReleaseConflict = 24, TooManyStepReleases = 25, + FeeBpsExceedsCap = 26, + NoFeesAccrued = 27, } // Storage keys @@ -196,6 +225,10 @@ pub enum DataKey { TaskStepRelease(u64, u64), /// Enumerable list of released step IDs for cleanup on task finalization. TaskStepIds(u64), + /// Protocol fee configuration: basis points and recipient address. + FeeConfig, + /// Per-asset accrued (but unclaimed) protocol fees: asset → i128. + AccruedFees(Address), } // Data structs @@ -294,11 +327,30 @@ pub enum TaskStatus { Completed, } +/// Protocol fee configuration stored in instance storage. +/// +/// `bps` is the fee in basis points (1 bps = 0.01%). The hard cap is 1000 +/// (10%). A zero `bps` or absent `recipient` disables fee collection and +/// behaves identically to the no-fee path, so the zero-fee invariant is +/// regression-safe. +#[contracttype] +#[derive(Clone)] +pub struct FeeConfig { + /// Fee in basis points. Must be <= `MAX_FEE_BPS`. Zero disables fees. + pub bps: u32, + /// Address that accrues and can claim the collected fees. + /// `None` disables fee collection even if `bps > 0`. + pub recipient: Option
, +} + // Constants /// Tasks older than this that haven't completed can be force-finalized by anyone. const STALE_TASK_THRESHOLD_SECONDS: u64 = 1800; // 30 minutes +/// Hard cap on the configurable protocol fee: 1000 bps = 10%. +const MAX_FEE_BPS: u32 = 1000; + /// Default cap on concurrent active tasks per user. Normal usage — even an /// orchestrator juggling several in-flight plans for one user — sits well /// under this; it exists to bound storage growth from a buggy or hostile @@ -855,7 +907,54 @@ impl AgentVault { Self::extend_instance_ttl(&env); let token_client = token::Client::new(&env, &asset); - token_client.transfer(&env.current_contract_address(), &orchestrator, &amount); + + // ── Protocol fee deduction ────────────────────────────────────── + // Fee rounds DOWN (integer division), so the orchestrator always + // receives the remainder. No unit of USDC is created or lost: + // orchestrator_payout + fee == amount (exactly). + // A zero bps or absent recipient skips the fee path entirely, + // making the zero-fee code path byte-for-byte equivalent to the + // previous behavior. + let fee = Self::compute_fee(&env, amount); + let orchestrator_payout = amount + .checked_sub(fee) + .expect("fee arithmetic underflow"); + + token_client.transfer( + &env.current_contract_address(), + &orchestrator, + &orchestrator_payout, + ); + + // Accrue the fee (if any) to the configured recipient's claimable balance. + if fee > 0 { + if let Some(fee_config) = env + .storage() + .instance() + .get::<_, FeeConfig>(&DataKey::FeeConfig) + { + if let Some(ref recipient) = fee_config.recipient { + let fee_key = DataKey::AccruedFees(asset.clone()); + let current: i128 = env + .storage() + .instance() + .get(&fee_key) + .unwrap_or(0i128); + let new_accrued = current + .checked_add(fee) + .expect("fee accrual overflow"); + env.storage().instance().set(&fee_key, &new_accrued); + + FeeAccruedEvent { + asset: asset.clone(), + recipient: recipient.clone(), + fee_amount: fee, + task_id, + } + .publish(&env); + } + } + } task.spent += amount; env.storage().persistent().set(&task_key, &task); @@ -871,10 +970,12 @@ impl AgentVault { .publish(&env); log!( &env, - "release_payment task={} asset={} amount={} total_spent={}", + "release_payment task={} asset={} amount={} fee={} orchestrator_payout={} total_spent={}", task_id, asset, amount, + fee, + orchestrator_payout, task.spent ); @@ -1082,6 +1183,165 @@ impl AgentVault { Ok(()) } + // ── Protocol Fee Management ────────────────────────────────────────── + + /// Admin sets the protocol fee in basis points and the recipient address. + /// + /// - `bps` must be <= `MAX_FEE_BPS` (1000 = 10%). + /// - Setting `bps` to 0 **or** passing `recipient = None` effectively + /// disables fee collection; `release_payment` behaves as if no fee + /// config exists. + /// - Changing the fee does NOT retroactively alter already-released + /// amounts; only future `release_payment` calls use the new rate. + pub fn set_fee( + env: Env, + admin: Address, + bps: u32, + recipient: Option
, + ) -> Result<(), VaultError> { + admin.require_auth(); + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .expect("Not initialized"); + if admin != stored_admin { + return Err(VaultError::Unauthorized); + } + if bps > MAX_FEE_BPS { + return Err(VaultError::FeeBpsExceedsCap); + } + + let config = FeeConfig { + bps, + recipient: recipient.clone(), + }; + env.storage() + .instance() + .set(&DataKey::FeeConfig, &config); + Self::extend_instance_ttl(&env); + + FeeSetEvent { + admin: admin.clone(), + bps, + recipient, + } + .publish(&env); + log!(&env, "set_fee bps={}", bps); + Ok(()) + } + + /// Returns the current fee config `(bps, recipient)`. + /// Returns `(0, None)` when no fee has ever been configured. + pub fn get_fee(env: Env) -> (u32, Option
) { + Self::extend_instance_ttl(&env); + match env + .storage() + .instance() + .get::<_, FeeConfig>(&DataKey::FeeConfig) + { + Some(c) => (c.bps, c.recipient), + None => (0, None), + } + } + + /// Returns the amount of fees accrued (but not yet claimed) for `asset`. + pub fn get_accrued_fees(env: Env, asset: Address) -> i128 { + Self::extend_instance_ttl(&env); + env.storage() + .instance() + .get::<_, i128>(&DataKey::AccruedFees(asset)) + .unwrap_or(0) + } + + /// Transfers all accrued fees for `asset` to the configured fee recipient. + /// + /// Only the configured recipient may call this. Fails with + /// `NoFeesAccrued` if there is nothing to claim (prevents a no-op + /// transfer). Zeroes the accrual after the transfer. + pub fn claim_fees( + env: Env, + recipient: Address, + asset: Address, + ) -> Result { + recipient.require_auth(); + Self::require_not_paused(&env)?; + + // Verify the caller is the configured recipient. + let fee_config: FeeConfig = env + .storage() + .instance() + .get(&DataKey::FeeConfig) + .ok_or(VaultError::Unauthorized)?; + match &fee_config.recipient { + None => return Err(VaultError::Unauthorized), + Some(r) if *r != recipient => return Err(VaultError::Unauthorized), + Some(_) => {} + } + + let fee_key = DataKey::AccruedFees(asset.clone()); + let accrued: i128 = env + .storage() + .instance() + .get(&fee_key) + .unwrap_or(0); + + if accrued == 0 { + return Err(VaultError::NoFeesAccrued); + } + + // Zero the accrual before the transfer (checks-effects-interactions). + env.storage().instance().set(&fee_key, &0i128); + Self::extend_instance_ttl(&env); + + let token_client = token::Client::new(&env, &asset); + token_client.transfer(&env.current_contract_address(), &recipient, &accrued); + + FeeClaimedEvent { + asset: asset.clone(), + recipient: recipient.clone(), + amount: accrued, + } + .publish(&env); + log!( + &env, + "claim_fees asset={} recipient={} amount={}", + asset, + recipient, + accrued + ); + Ok(accrued) + } + + /// Computes the fee to deduct from `amount` based on the current fee + /// config. Returns 0 when no fee is configured or the recipient is absent. + /// + /// Rounding rule: **round down** (integer division). The orchestrator + /// always receives the remainder, so no unit of USDC is created or lost. + /// Checked arithmetic is used throughout; overflow would require an + /// `amount` close to `i128::MAX` which is unreachable in practice but + /// is defended explicitly. + fn compute_fee(env: &Env, amount: i128) -> i128 { + let config = match env + .storage() + .instance() + .get::<_, FeeConfig>(&DataKey::FeeConfig) + { + Some(c) => c, + None => return 0, + }; + // Zero bps or absent recipient → no fee. + if config.bps == 0 || config.recipient.is_none() { + return 0; + } + // fee = floor(amount * bps / 10_000) + // Use checked multiplication to guard against absurdly large amounts. + let numerator = amount + .checked_mul(i128::from(config.bps)) + .expect("fee numerator overflow"); + numerator / 10_000 + } + /// Uses the live threshold so status queries and force completion cannot drift. fn is_task_stale(env: &Env, task: &TaskInfo) -> bool { let elapsed = env.ledger().timestamp() - task.created_at; diff --git a/contracts/agent-vault/src/tests.rs b/contracts/agent-vault/src/tests.rs index c4834c0..3e1d85a 100644 --- a/contracts/agent-vault/src/tests.rs +++ b/contracts/agent-vault/src/tests.rs @@ -3452,3 +3452,417 @@ fn test_dispute_resolution_does_not_claw_back_spent() { assert_eq!(account.balance, 200); // 500 - plan_cost(300) assert_eq!(account.total_spent, 200); // only the released amount is spending } + +// ── Protocol Fee Tests ──────────────────────────────────────────────────────── + +// Helper: set up a full task scenario with a funded user+orchestrator ready to +// call release_payment. Returns (user, orchestrator, task_id). +fn setup_fee_task(test_env: &TestEnv, plan_cost: i128) -> (Address, Address, u64) { + let user = Address::generate(&test_env.env); + let orchestrator = Address::generate(&test_env.env); + test_env.token_admin_client.mint(&user, &plan_cost); + test_env.client.deposit(&user, &test_env.usdc_sac, &plan_cost); + test_env.client.register_orchestrator( + &user, + &orchestrator, + &soroban_sdk::String::from_str(&test_env.env, "fee-orchestrator"), + ); + let task_id = test_env + .client + .create_task(&orchestrator, &test_env.usdc_sac, &plan_cost); + (user, orchestrator, task_id) +} + +// 18a. set_fee — basic read-back + +#[test] +fn test_set_fee_and_get_fee() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + test_env + .client + .set_fee(&test_env.admin, &50, &Some(recipient.clone())); + + let (bps, rec) = test_env.client.get_fee(); + assert_eq!(bps, 50); + assert_eq!(rec, Some(recipient)); +} + +// 18b. set_fee — default is zero / None + +#[test] +fn test_get_fee_default() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let (bps, rec) = test_env.client.get_fee(); + assert_eq!(bps, 0); + assert_eq!(rec, None); +} + +// 18c. set_fee — exceeds cap is rejected + +#[test] +fn test_set_fee_exceeds_cap() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let result = test_env + .client + .try_set_fee(&test_env.admin, &1001, &None); + assert!(result == Err(Ok(VaultError::FeeBpsExceedsCap))); +} + +// 18d. set_fee — exact cap (1000 bps) is accepted + +#[test] +fn test_set_fee_at_cap() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + test_env + .client + .set_fee(&test_env.admin, &1000, &Some(recipient.clone())); + let (bps, _) = test_env.client.get_fee(); + assert_eq!(bps, 1000); +} + +// 18e. set_fee — non-admin is rejected + +#[test] +fn test_set_fee_unauthorized() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let attacker = Address::generate(&test_env.env); + let result = test_env.client.try_set_fee(&attacker, &50, &None); + assert!(result == Err(Ok(VaultError::Unauthorized))); +} + +// 18f. release_payment with fee — correct split and accrual + +#[test] +fn test_release_payment_fee_accrual() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + // 100 bps = 1% + test_env + .client + .set_fee(&test_env.admin, &100, &Some(recipient.clone())); + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 10_000); + // release 1000; fee = floor(1000 * 100 / 10_000) = 10 + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &1000); + + // Orchestrator receives 990 + assert_eq!(test_env.token_client.balance(&orchestrator), 990); + // 10 stays in contract, accrued for recipient + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 10 + ); +} + +// 18g. claim_fees — transfers accrued fees and zeroes the balance + +#[test] +fn test_claim_fees() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + test_env + .client + .set_fee(&test_env.admin, &200, &Some(recipient.clone())); // 2% + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 5_000); + // fee = floor(5000 * 200 / 10_000) = 100 + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &5_000); + + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 100 + ); + + let claimed = test_env + .client + .claim_fees(&recipient, &test_env.usdc_sac); + assert_eq!(claimed, 100); + assert_eq!(test_env.token_client.balance(&recipient), 100); + // Accrual zeroed after claim + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 0 + ); +} + +// 18h. claim_fees — no-op when nothing accrued returns NoFeesAccrued + +#[test] +fn test_claim_fees_nothing_accrued() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + test_env + .client + .set_fee(&test_env.admin, &100, &Some(recipient.clone())); + + let result = test_env + .client + .try_claim_fees(&recipient, &test_env.usdc_sac); + assert!(result == Err(Ok(VaultError::NoFeesAccrued))); +} + +// 18i. claim_fees — wrong caller is rejected + +#[test] +fn test_claim_fees_wrong_caller() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + let attacker = Address::generate(&test_env.env); + test_env + .client + .set_fee(&test_env.admin, &100, &Some(recipient.clone())); + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 1_000); + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &1_000); + + let result = test_env + .client + .try_claim_fees(&attacker, &test_env.usdc_sac); + assert!(result == Err(Ok(VaultError::Unauthorized))); +} + +// 18j. Zero-fee path — zero bps behaves exactly like no fee configured +// (orchestrator receives full amount, no accrual) + +#[test] +fn test_zero_fee_no_deduction() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + // Explicitly set 0 bps + test_env + .client + .set_fee(&test_env.admin, &0, &Some(recipient.clone())); + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 1_000); + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &1_000); + + // Orchestrator receives full amount; nothing accrued + assert_eq!(test_env.token_client.balance(&orchestrator), 1_000); + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 0 + ); +} + +// 18k. Zero-fee path — fee set but recipient is None + +#[test] +fn test_fee_no_recipient_no_deduction() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + // bps set but no recipient + test_env.client.set_fee(&test_env.admin, &500, &None); + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 1_000); + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &1_000); + + assert_eq!(test_env.token_client.balance(&orchestrator), 1_000); + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 0 + ); +} + +// 18l. Dust: amount so small fee rounds to 0 — orchestrator gets full amount + +#[test] +fn test_fee_dust_rounds_to_zero() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + // 1 bps = 0.01%; on amount=9 the fee = floor(9*1/10_000) = 0 + test_env + .client + .set_fee(&test_env.admin, &1, &Some(recipient.clone())); + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 9); + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &9); + + assert_eq!(test_env.token_client.balance(&orchestrator), 9); + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 0 + ); +} + +// 18m. Recipient equals orchestrator — allowed by spec + +#[test] +fn test_fee_recipient_is_orchestrator() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 10_000); + // Set recipient = orchestrator after task creation (fee config doesn't affect tasks retroactively) + test_env + .client + .set_fee(&test_env.admin, &100, &Some(orchestrator.clone())); // 1% + + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &10_000); + + // Orchestrator payout = 9900, fee accrued = 100 + assert_eq!(test_env.token_client.balance(&orchestrator), 9_900); + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 100 + ); +} + +// 18n. Cumulative accrual across multiple releases + +#[test] +fn test_fee_cumulative_accrual() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + // 200 bps = 2% + test_env + .client + .set_fee(&test_env.admin, &200, &Some(recipient.clone())); + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 3_000); + // Three releases of 1000 each: fee per release = 20; total = 60 + for step_id in 1u64..=3 { + test_env + .client + .release_payment(&orchestrator, &task_id, &step_id, &test_env.usdc_sac, &1_000); + } + + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 60 + ); + // Orchestrator received 980 × 3 = 2940 + assert_eq!(test_env.token_client.balance(&orchestrator), 2_940); +} + +// 18o. Invariant: sum(orchestrator payouts) + sum(fees) + refund == plan_cost +// Randomised-style invariant over 5 partial releases + completion. + +#[test] +fn test_fee_accounting_invariant() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + let bps: i128 = 150; // 1.5% + test_env + .client + .set_fee(&test_env.admin, &(bps as u32), &Some(recipient.clone())); + + let plan_cost: i128 = 10_000; + let (user, orchestrator, task_id) = setup_fee_task(&test_env, plan_cost); + let contract_before = test_env.token_client.balance(&test_env.contract_id); + + // Partial release sequence: 1000, 2000, 1500, 2500, 1000 = 8000 total released + let releases: [i128; 5] = [1_000, 2_000, 1_500, 2_500, 1_000]; + let mut total_released = 0i128; + let mut expected_fees = 0i128; + for (step_id, &r) in releases.iter().enumerate() { + test_env + .client + .release_payment(&orchestrator, &task_id, &((step_id + 1) as u64), &test_env.usdc_sac, &r); + let fee = r * bps / 10_000; + expected_fees += fee; + total_released += r; + } + + test_env.client.complete_task(&orchestrator, &task_id); + + let refund = plan_cost - total_released; + let orchestrator_balance = test_env.token_client.balance(&orchestrator); + let accrued = test_env.client.get_accrued_fees(&test_env.usdc_sac); + let contract_after = test_env.token_client.balance(&test_env.contract_id); + + // orchestrator received total_released - total_fees + assert_eq!(orchestrator_balance, total_released - expected_fees); + // fees accrued match our expected sum + assert_eq!(accrued, expected_fees); + // contract balance decreased by exactly total_released - fees (fees stay in contract) + assert_eq!(contract_before - contract_after, total_released - expected_fees); + // The full invariant: payout + fees + refund == plan_cost + assert_eq!(orchestrator_balance + accrued + refund, plan_cost); + + // Verify user got refund back into available balance + let account = test_env + .client + .get_account(&user, &test_env.usdc_sac) + .unwrap(); + assert_eq!(account.balance, plan_cost - total_released); +} + +// 18p. Fee config change does NOT affect already-released amounts +// (i.e., changing bps mid-task only affects future release_payment calls) + +#[test] +fn test_fee_change_not_retroactive() { + let test_env = setup_test(); + test_env.client.init(&test_env.admin, &test_env.usdc_sac); + + let recipient = Address::generate(&test_env.env); + test_env + .client + .set_fee(&test_env.admin, &0, &Some(recipient.clone())); + + let (_, orchestrator, task_id) = setup_fee_task(&test_env, 2_000); + // First release at 0 bps — no fee + test_env + .client + .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &1_000); + assert_eq!(test_env.token_client.balance(&orchestrator), 1_000); + + // Change fee to 10% mid-task + test_env + .client + .set_fee(&test_env.admin, &1000, &Some(recipient.clone())); + + // Second release at 10% — fee = 100 + test_env + .client + .release_payment(&orchestrator, &task_id, &2, &test_env.usdc_sac, &1_000); + // Total orchestrator: 1000 (full, no fee) + 900 (after 10% fee) = 1900 + assert_eq!(test_env.token_client.balance(&orchestrator), 1_900); + assert_eq!( + test_env.client.get_accrued_fees(&test_env.usdc_sac), + 100 + ); +} From d85ce25df3d7134e58f09e981d75ee430ac7dccc Mon Sep 17 00:00:00 2001 From: DevSolex Date: Sat, 22 Aug 2026 18:12:20 +0100 Subject: [PATCH 2/2] style: run cargo fmt to fix CI formatting check --- contracts/agent-vault/src/lib.rs | 30 +++-------- contracts/agent-vault/src/tests.rs | 82 ++++++++++++------------------ 2 files changed, 38 insertions(+), 74 deletions(-) diff --git a/contracts/agent-vault/src/lib.rs b/contracts/agent-vault/src/lib.rs index 201f9e4..22b13f4 100644 --- a/contracts/agent-vault/src/lib.rs +++ b/contracts/agent-vault/src/lib.rs @@ -916,9 +916,7 @@ impl AgentVault { // making the zero-fee code path byte-for-byte equivalent to the // previous behavior. let fee = Self::compute_fee(&env, amount); - let orchestrator_payout = amount - .checked_sub(fee) - .expect("fee arithmetic underflow"); + let orchestrator_payout = amount.checked_sub(fee).expect("fee arithmetic underflow"); token_client.transfer( &env.current_contract_address(), @@ -935,14 +933,8 @@ impl AgentVault { { if let Some(ref recipient) = fee_config.recipient { let fee_key = DataKey::AccruedFees(asset.clone()); - let current: i128 = env - .storage() - .instance() - .get(&fee_key) - .unwrap_or(0i128); - let new_accrued = current - .checked_add(fee) - .expect("fee accrual overflow"); + let current: i128 = env.storage().instance().get(&fee_key).unwrap_or(0i128); + let new_accrued = current.checked_add(fee).expect("fee accrual overflow"); env.storage().instance().set(&fee_key, &new_accrued); FeeAccruedEvent { @@ -1216,9 +1208,7 @@ impl AgentVault { bps, recipient: recipient.clone(), }; - env.storage() - .instance() - .set(&DataKey::FeeConfig, &config); + env.storage().instance().set(&DataKey::FeeConfig, &config); Self::extend_instance_ttl(&env); FeeSetEvent { @@ -1259,11 +1249,7 @@ impl AgentVault { /// Only the configured recipient may call this. Fails with /// `NoFeesAccrued` if there is nothing to claim (prevents a no-op /// transfer). Zeroes the accrual after the transfer. - pub fn claim_fees( - env: Env, - recipient: Address, - asset: Address, - ) -> Result { + pub fn claim_fees(env: Env, recipient: Address, asset: Address) -> Result { recipient.require_auth(); Self::require_not_paused(&env)?; @@ -1280,11 +1266,7 @@ impl AgentVault { } let fee_key = DataKey::AccruedFees(asset.clone()); - let accrued: i128 = env - .storage() - .instance() - .get(&fee_key) - .unwrap_or(0); + let accrued: i128 = env.storage().instance().get(&fee_key).unwrap_or(0); if accrued == 0 { return Err(VaultError::NoFeesAccrued); diff --git a/contracts/agent-vault/src/tests.rs b/contracts/agent-vault/src/tests.rs index 3e1d85a..ff1d38c 100644 --- a/contracts/agent-vault/src/tests.rs +++ b/contracts/agent-vault/src/tests.rs @@ -3461,7 +3461,9 @@ fn setup_fee_task(test_env: &TestEnv, plan_cost: i128) -> (Address, Address, u64 let user = Address::generate(&test_env.env); let orchestrator = Address::generate(&test_env.env); test_env.token_admin_client.mint(&user, &plan_cost); - test_env.client.deposit(&user, &test_env.usdc_sac, &plan_cost); + test_env + .client + .deposit(&user, &test_env.usdc_sac, &plan_cost); test_env.client.register_orchestrator( &user, &orchestrator, @@ -3509,9 +3511,7 @@ fn test_set_fee_exceeds_cap() { let test_env = setup_test(); test_env.client.init(&test_env.admin, &test_env.usdc_sac); - let result = test_env - .client - .try_set_fee(&test_env.admin, &1001, &None); + let result = test_env.client.try_set_fee(&test_env.admin, &1001, &None); assert!(result == Err(Ok(VaultError::FeeBpsExceedsCap))); } @@ -3564,10 +3564,7 @@ fn test_release_payment_fee_accrual() { // Orchestrator receives 990 assert_eq!(test_env.token_client.balance(&orchestrator), 990); // 10 stays in contract, accrued for recipient - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 10 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 10); } // 18g. claim_fees — transfers accrued fees and zeroes the balance @@ -3588,21 +3585,13 @@ fn test_claim_fees() { .client .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &5_000); - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 100 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 100); - let claimed = test_env - .client - .claim_fees(&recipient, &test_env.usdc_sac); + let claimed = test_env.client.claim_fees(&recipient, &test_env.usdc_sac); assert_eq!(claimed, 100); assert_eq!(test_env.token_client.balance(&recipient), 100); // Accrual zeroed after claim - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 0 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 0); } // 18h. claim_fees — no-op when nothing accrued returns NoFeesAccrued @@ -3668,10 +3657,7 @@ fn test_zero_fee_no_deduction() { // Orchestrator receives full amount; nothing accrued assert_eq!(test_env.token_client.balance(&orchestrator), 1_000); - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 0 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 0); } // 18k. Zero-fee path — fee set but recipient is None @@ -3690,10 +3676,7 @@ fn test_fee_no_recipient_no_deduction() { .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &1_000); assert_eq!(test_env.token_client.balance(&orchestrator), 1_000); - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 0 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 0); } // 18l. Dust: amount so small fee rounds to 0 — orchestrator gets full amount @@ -3715,10 +3698,7 @@ fn test_fee_dust_rounds_to_zero() { .release_payment(&orchestrator, &task_id, &1, &test_env.usdc_sac, &9); assert_eq!(test_env.token_client.balance(&orchestrator), 9); - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 0 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 0); } // 18m. Recipient equals orchestrator — allowed by spec @@ -3740,10 +3720,7 @@ fn test_fee_recipient_is_orchestrator() { // Orchestrator payout = 9900, fee accrued = 100 assert_eq!(test_env.token_client.balance(&orchestrator), 9_900); - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 100 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 100); } // 18n. Cumulative accrual across multiple releases @@ -3762,15 +3739,16 @@ fn test_fee_cumulative_accrual() { let (_, orchestrator, task_id) = setup_fee_task(&test_env, 3_000); // Three releases of 1000 each: fee per release = 20; total = 60 for step_id in 1u64..=3 { - test_env - .client - .release_payment(&orchestrator, &task_id, &step_id, &test_env.usdc_sac, &1_000); + test_env.client.release_payment( + &orchestrator, + &task_id, + &step_id, + &test_env.usdc_sac, + &1_000, + ); } - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 60 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 60); // Orchestrator received 980 × 3 = 2940 assert_eq!(test_env.token_client.balance(&orchestrator), 2_940); } @@ -3798,9 +3776,13 @@ fn test_fee_accounting_invariant() { let mut total_released = 0i128; let mut expected_fees = 0i128; for (step_id, &r) in releases.iter().enumerate() { - test_env - .client - .release_payment(&orchestrator, &task_id, &((step_id + 1) as u64), &test_env.usdc_sac, &r); + test_env.client.release_payment( + &orchestrator, + &task_id, + &((step_id + 1) as u64), + &test_env.usdc_sac, + &r, + ); let fee = r * bps / 10_000; expected_fees += fee; total_released += r; @@ -3818,7 +3800,10 @@ fn test_fee_accounting_invariant() { // fees accrued match our expected sum assert_eq!(accrued, expected_fees); // contract balance decreased by exactly total_released - fees (fees stay in contract) - assert_eq!(contract_before - contract_after, total_released - expected_fees); + assert_eq!( + contract_before - contract_after, + total_released - expected_fees + ); // The full invariant: payout + fees + refund == plan_cost assert_eq!(orchestrator_balance + accrued + refund, plan_cost); @@ -3861,8 +3846,5 @@ fn test_fee_change_not_retroactive() { .release_payment(&orchestrator, &task_id, &2, &test_env.usdc_sac, &1_000); // Total orchestrator: 1000 (full, no fee) + 900 (after 10% fee) = 1900 assert_eq!(test_env.token_client.balance(&orchestrator), 1_900); - assert_eq!( - test_env.client.get_accrued_fees(&test_env.usdc_sac), - 100 - ); + assert_eq!(test_env.client.get_accrued_fees(&test_env.usdc_sac), 100); }