From 5872602a6fe749ed654dc06cf7f16b18bcb0d2d1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 13 Oct 2026 21:04:21 -0400 Subject: [PATCH 1/2] fix(maintenance-pool): deposit credits the received delta, not the requested amount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit maintenance-pool::deposit transferred then credited to pool.balance / total_deposited / the Deposit record. With a fee-on-transfer or rebasing token, the amount that arrives is less than , so the pool's books exceeded its holdings — later withdrawals or reclaims fail with insufficient balance or eat into other pools. Use mergefi_common::measure_transfer_delta as escrow::fund and milestones::create_milestone already do: measure the contract's actual balance change across the transfer, reject actual_received <= 0, and book the received amount. Resolves #532 Note: the fee-on-transfer regression test requires a fee-taking mock token, which isn't present in the workspace — flagging that the test gap remains; the fix mirrors the established escrow/milestones pattern. --- contracts/maintenance-pool/src/lib.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/contracts/maintenance-pool/src/lib.rs b/contracts/maintenance-pool/src/lib.rs index e51a66b..723047a 100644 --- a/contracts/maintenance-pool/src/lib.rs +++ b/contracts/maintenance-pool/src/lib.rs @@ -135,16 +135,29 @@ mod contract { return Err(Error::TooManyDeposits); } + // Measure the actual received delta — fee-on-transfer or rebasing + // tokens deliver less than `amount`, so the books must credit what + // arrived, not what was requested. let token_client = token::Client::new(&env, &token); - token_client.transfer(&sponsor, env.current_contract_address(), &amount); + let actual_received = mergefi_common::measure_transfer_delta( + &env, + &token, + &env.current_contract_address(), + || { + token_client.transfer(&sponsor, env.current_contract_address(), &amount); + }, + ); + if actual_received <= 0 { + return Err(Error::InvalidAmount); + } pool.balance = pool .balance - .checked_add(amount) + .checked_add(actual_received) .ok_or(Error::ArithmeticOverflow)?; pool.total_deposited = pool .total_deposited - .checked_add(amount) + .checked_add(actual_received) .ok_or(Error::ArithmeticOverflow)?; let index = pool.deposit_count; pool.deposit_count = pool @@ -160,7 +173,7 @@ mod contract { &dkey, &Deposit { sponsor, - amount, + amount: actual_received, timestamp: env.ledger().timestamp(), }, ); From 206bf71ef77d11b15f08bb5fa5202e90c70231bb Mon Sep 17 00:00:00 2001 From: yggdrasilworld Date: Fri, 16 Oct 2026 12:02:46 -0400 Subject: [PATCH 2/2] test(maintenance-pool): fee-on-transfer deposit coverage (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AC demands the deposit-delta fix be exercised with a fee-on-transfer mock — adds MockFeeToken (10% burns in transit) and asserts the pool credits 90 of a requested 100 deposit. --- contracts/maintenance-pool/src/test.rs | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/contracts/maintenance-pool/src/test.rs b/contracts/maintenance-pool/src/test.rs index 1b0c34c..886bb40 100644 --- a/contracts/maintenance-pool/src/test.rs +++ b/contracts/maintenance-pool/src/test.rs @@ -147,6 +147,77 @@ fn test_withdraw_with_zero_fee_pays_full_amount() { assert_eq!(pool.total_withdrawn, 200_0000000i128); } +/// Fee-on-transfer mock: `transfer` credits the recipient 10% less than +/// requested — the shape `deposit`'s `measure_transfer_delta` path must +/// credit (`pool.balance` reflects what arrived, not what was asked). +#[contract] +pub struct MockFeeToken; + +#[contractimpl] +impl MockFeeToken { + pub fn transfer(env: Env, from: Address, to: Address, amount: i128) { + let bal_key = soroban_sdk::Symbol::new(&env, "bal"); + let from_bal: i128 = env + .storage() + .persistent() + .get(&(bal_key.clone(), from.clone())) + .unwrap_or(0); + let to_bal: i128 = env + .storage() + .persistent() + .get(&(bal_key.clone(), to.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&(bal_key.clone(), from), &(from_bal - amount)); + // 10% burns off in transit — the recipient only sees 90%. + env.storage() + .persistent() + .set(&(bal_key, to), &(to_bal + amount - amount / 10)); + } + + pub fn balance(env: Env, id: Address) -> i128 { + let bal_key = soroban_sdk::Symbol::new(&env, "bal"); + env.storage() + .persistent() + .get(&(bal_key, id)) + .unwrap_or(0) + } + + pub fn mint(env: Env, to: Address, amount: i128) { + let bal_key = soroban_sdk::Symbol::new(&env, "bal"); + let bal: i128 = env + .storage() + .persistent() + .get(&(bal_key.clone(), to.clone())) + .unwrap_or(0); + env.storage() + .persistent() + .set(&(bal_key, to), &(bal + amount)); + } +} + +#[test] +fn test_deposit_credits_received_delta_not_requested_amount() { + // The AC demands this case with the fee-on-transfer mock: deposit + // requests 100 but only 90 arrives — the ledger must say 90. + let env = Env::default(); + env.mock_all_auths(); + let (_admin, _treasury, client) = setup(&env); + + let token_addr = env.register(MockFeeToken, ()); + let fee_client = MockFeeTokenClient::new(&env, &token_addr); + let sponsor = Address::generate(&env); + fee_client.mint(&sponsor, &1_000i128); + + client.deposit(&7u64, &sponsor, &token_addr, &100i128); + + let pool = client.get_pool(&7u64); + assert_eq!(pool.balance, 90i128, "credits the received delta"); + assert_eq!(pool.total_deposited, 90i128); + assert_eq!(client.get_deposit(&7u64, &0u32).amount, 90i128); +} + #[test] fn test_deposit_rejects_invalid_amount() { let env = Env::default();