diff --git a/contracts/agent-vault/src/lib.rs b/contracts/agent-vault/src/lib.rs
index 7379d3c..22b13f4 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,46 @@ 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 +962,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 +1175,155 @@ 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..ff1d38c 100644
--- a/contracts/agent-vault/src/tests.rs
+++ b/contracts/agent-vault/src/tests.rs
@@ -3452,3 +3452,399 @@ 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);
+}