Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/contracts/SavingCircles.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
36 changes: 36 additions & 0 deletions test/mocks/MockFeeOnTransferERC20.sol
Original file line number Diff line number Diff line change
@@ -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) {

Check warning on line 10 in test/mocks/MockFeeOnTransferERC20.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

Variable "symbol" is unused

Check warning on line 10 in test/mocks/MockFeeOnTransferERC20.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

Variable "name" is unused

Check warning on line 10 in test/mocks/MockFeeOnTransferERC20.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

Variable "symbol" is unused

Check warning on line 10 in test/mocks/MockFeeOnTransferERC20.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

Variable "name" is unused
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;
}
}
44 changes: 39 additions & 5 deletions test/unit/SavingCirclesUnit.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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();

Expand Down
Loading