diff --git a/src/contracts/SavingCircles.sol b/src/contracts/SavingCircles.sol index 27257ae..e34f4a3 100644 --- a/src/contracts/SavingCircles.sol +++ b/src/contracts/SavingCircles.sol @@ -422,18 +422,22 @@ contract SavingCircles is ISavingCircles, ReentrancyGuardUpgradeable, OwnableUpg } } + // Use balance-before / balance-after to support fee-on-transfer tokens (#122). + // Only the net amount actually received is credited to the depositor. + uint256 balBefore = IERC20(_circle.token).balanceOf(address(this)); + IERC20(_circle.token).safeTransferFrom(msg.sender, address(this), _value); + uint256 received = IERC20(_circle.token).balanceOf(address(this)) - balBefore; + uint256 depositedSoFar = roundDeposits[_id][currentRound][_member]; - if (depositedSoFar + _value > _circle.depositAmount) revert ExceedsDepositAmount(); + if (depositedSoFar + received > _circle.depositAmount) revert ExceedsDepositAmount(); - uint256 newTotal = depositedSoFar + _value; + uint256 newTotal = depositedSoFar + received; roundDeposits[_id][currentRound][_member] = newTotal; _memberStates[_id][_member].lastDepositRound = currentRound; balances[_id][_member] = newTotal; - IERC20(_circle.token).safeTransferFrom(msg.sender, address(this), _value); - - emit FundsDeposited(_id, _member, _value); + emit FundsDeposited(_id, _member, received); } /** diff --git a/test/mocks/MockFeeOnTransferERC20.sol b/test/mocks/MockFeeOnTransferERC20.sol new file mode 100644 index 0000000..4764444 --- /dev/null +++ b/test/mocks/MockFeeOnTransferERC20.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol'; + +/// @dev Mock ERC20 that takes a fee on every transfer, simulating deflationary tokens +contract MockFeeOnTransferERC20 is ERC20 { + uint256 public feeBps; // fee in basis points (e.g. 100 = 1%) + + constructor(string memory name, string memory symbol, uint256 _feeBps) ERC20(name, symbol) { + feeBps = _feeBps; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function transfer(address to, uint256 amount) public override returns (bool) { + uint256 fee = (amount * feeBps) / 10_000; + uint256 net = amount - fee; + // Burn the fee (simulates deflationary mechanic) + _burn(msg.sender, fee); + return super.transfer(to, net); + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + uint256 fee = (amount * feeBps) / 10_000; + uint256 net = amount - fee; + // Burn the fee + _burn(from, fee); + // Spend allowance on full amount, transfer net + _spendAllowance(from, msg.sender, amount); + _transfer(from, to, net); + return true; + } +} diff --git a/test/unit/SavingCirclesUnit.t.sol b/test/unit/SavingCirclesUnit.t.sol index f582ed8..0e1739a 100644 --- a/test/unit/SavingCirclesUnit.t.sol +++ b/test/unit/SavingCirclesUnit.t.sol @@ -9,6 +9,7 @@ import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol'; import {SavingCircles} from 'src/contracts/SavingCircles.sol'; import {ISavingCircles} from 'src/interfaces/ISavingCircles.sol'; import {MockERC20} from 'test/mocks/MockERC20.sol'; +import {MockFeeOnTransferERC20} from 'test/mocks/MockFeeOnTransferERC20.sol'; import {SavingCirclesTestBase} from 'test/utils/SavingCirclesTestBase.t.sol'; contract SavingCirclesUnit is SavingCirclesTestBase { @@ -177,10 +178,11 @@ contract SavingCirclesUnit is SavingCirclesTestBase { } function test_DepositWhenParametersAreValid() external { - vm.startPrank(alice); + // Use real token transfers to work with balance-before/after pattern + token.mint(alice, DEPOSIT_AMOUNT); - // Mock token transfer - vm.mockCall(address(token), abi.encodeWithSelector(IERC20.transferFrom.selector), abi.encode(true)); + vm.startPrank(alice); + token.approve(address(savingCircles), DEPOSIT_AMOUNT); // Expect deposit event vm.expectEmit(true, true, true, true); @@ -195,6 +197,33 @@ contract SavingCirclesUnit is SavingCirclesTestBase { vm.stopPrank(); } + function test_DepositFeeOnTransferTokenCreditsNetAmount() external { + // Deploy a fee-on-transfer token (1% fee) + MockFeeOnTransferERC20 feeToken = new MockFeeOnTransferERC20('Fee Token', 'FEE', 100); + + // Create a circle with the fee token + vm.prank(owner); + savingCircles.setTokenAllowed(address(feeToken), true); + + ISavingCircles.Circle memory feeCircle = _defaultCircle(alice, DEPOSIT_AMOUNT, DEPOSIT_INTERVAL, address(feeToken)); + uint256 feeCircleId = _createCircleWithMembers(savingCircles, feeCircle, members, _alicePrivateKey); + + // Mint enough tokens (need extra to cover the fee) + uint256 sendAmount = DEPOSIT_AMOUNT; // 1 ether, but 1% fee means only 0.99 ether received + feeToken.mint(alice, sendAmount); + + vm.startPrank(alice); + feeToken.approve(address(savingCircles), sendAmount); + savingCircles.deposit(feeCircleId, sendAmount); + vm.stopPrank(); + + // The contract should credit only the net received amount (99% of 1 ether) + uint256 expectedNet = sendAmount - (sendAmount * 100 / 10_000); // 0.99 ether + uint256 balance = savingCircles.balances(feeCircleId, alice); + assertEq(balance, expectedNet, 'Balance should reflect net received after fee'); + assertLt(balance, DEPOSIT_AMOUNT, 'Balance should be less than nominal deposit for fee tokens'); + } + function test_DepositWhenDepositPeriodHasPassed() external { // Move time past deposit period vm.warp(block.timestamp + DEPOSIT_INTERVAL + 1); @@ -487,18 +516,23 @@ contract SavingCirclesUnit is SavingCirclesTestBase { } function test_WithdrawWhenUserHasAlreadyClaimed() external { - // Complete deposits - vm.mockCall(address(token), abi.encodeWithSelector(IERC20.transferFrom.selector), abi.encode(true)); + // Complete deposits with real token transfers + token.mint(alice, DEPOSIT_AMOUNT); + token.mint(bob, DEPOSIT_AMOUNT); + token.mint(carol, DEPOSIT_AMOUNT); vm.startPrank(alice); + token.approve(address(savingCircles), DEPOSIT_AMOUNT); savingCircles.deposit(baseCircleId, DEPOSIT_AMOUNT); vm.stopPrank(); vm.startPrank(bob); + token.approve(address(savingCircles), DEPOSIT_AMOUNT); savingCircles.deposit(baseCircleId, DEPOSIT_AMOUNT); vm.stopPrank(); vm.startPrank(carol); + token.approve(address(savingCircles), DEPOSIT_AMOUNT); savingCircles.deposit(baseCircleId, DEPOSIT_AMOUNT); vm.stopPrank();