Skip to content

Commit

Permalink
feat: add a withdrawFor function and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
bagelface committed Dec 30, 2024
1 parent b395d7d commit ee54c69
Show file tree
Hide file tree
Showing 3 changed files with 129 additions and 21 deletions.
51 changes: 31 additions & 20 deletions src/contracts/SavingCircles.sol
Original file line number Diff line number Diff line change
Expand Up @@ -82,26 +82,17 @@ contract SavingCircles is ISavingCircles, ReentrancyGuard, OwnableUpgradeable {
* @param _id Identifier of the circle
*/
function withdraw(uint256 _id) external override nonReentrant {
Circle storage _circle = circles[_id];

if (!_withdrawable(_id)) revert NotWithdrawable();
if (_circle.members[_circle.currentIndex] != msg.sender) revert NotWithdrawable();
if (_circle.currentIndex >= _circle.maxDeposits) revert NotWithdrawable();

uint256 _withdrawAmount = _circle.depositAmount * (_circle.members.length);

for (uint256 i = 0; i < _circle.members.length; i++) {
balances[_id][_circle.members[i]] = 0;
}

_circle.currentIndex = (_circle.currentIndex + 1) % _circle.members.length;
bool success = IERC20(_circle.token).transfer(msg.sender, _withdrawAmount);
if (!success) revert TransferFailed();

emit FundsWithdrawn(_id, msg.sender, _withdrawAmount);
_withdraw(_id, msg.sender);
}

function withdrawFor(uint256 _id, address _member) external override nonReentrant {}
/**
* @notice Make a withdrawal from a specified circle on behalf of another member
* @param _id Identifier of the circle
* @param _member Address of the member to make a withdrawal for
*/
function withdrawFor(uint256 _id, address _member) external override nonReentrant {
_withdraw(_id, _member);
}

/**
* @notice Set if a token can be used for saving circles
Expand Down Expand Up @@ -223,9 +214,29 @@ contract SavingCircles is ISavingCircles, ReentrancyGuard, OwnableUpgradeable {
}

/**
* @dev TODO
* @dev Make a withdrawal from a specified circle
* A withdrawal must be made by a member of the circle, even if it is for another member.
*/
function _withdraw() internal {}
function _withdraw(uint256 _id, address _member) internal {
Circle storage _circle = circles[_id];

if (!isMember[_id][msg.sender]) revert NotMember();
if (!_withdrawable(_id)) revert NotWithdrawable();
if (_circle.members[_circle.currentIndex] != _member) revert NotWithdrawable();
if (_circle.currentIndex >= _circle.maxDeposits) revert NotWithdrawable();

uint256 _withdrawAmount = _circle.depositAmount * (_circle.members.length);

for (uint256 i = 0; i < _circle.members.length; i++) {
balances[_id][_circle.members[i]] = 0;
}

_circle.currentIndex = (_circle.currentIndex + 1) % _circle.members.length;
bool success = IERC20(_circle.token).transfer(_member, _withdrawAmount);
if (!success) revert TransferFailed();

emit FundsWithdrawn(_id, _member, _withdrawAmount);
}

/**
* @dev Make a deposit into a specified circle
Expand Down
48 changes: 48 additions & 0 deletions test/integration/SavingCircles.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,54 @@ contract SavingCirclesIntegration is IntegrationBase {
circle.withdraw(baseCircleId);
}

function test_WithdrawForWithInterval() public {
createBaseCircle();

// Initial deposits from all members
vm.prank(alice);
circle.deposit(baseCircleId, DEPOSIT_AMOUNT);

vm.prank(bob);
circle.deposit(baseCircleId, DEPOSIT_AMOUNT);

vm.prank(carol);
circle.deposit(baseCircleId, DEPOSIT_AMOUNT);

// Bob tries to withdraw for Alice (who is first in line)
uint256 balanceBefore = token.balanceOf(alice);
vm.prank(bob);
circle.withdrawFor(baseCircleId, alice);
uint256 balanceAfter = token.balanceOf(alice);

// Alice should receive DEPOSIT_AMOUNT * 3 (from all members)
assertEq(balanceAfter - balanceBefore, DEPOSIT_AMOUNT * 3);

// Try to withdraw for Bob before interval
vm.prank(alice);
vm.expectRevert(ISavingCircles.NotWithdrawable.selector);
circle.withdrawFor(baseCircleId, bob);

// Wait for interval (need to wait for index 1's interval)
vm.warp(block.timestamp + DEPOSIT_INTERVAL);

// New round of deposits
vm.prank(alice);
circle.deposit(baseCircleId, DEPOSIT_AMOUNT);
vm.prank(bob);
circle.deposit(baseCircleId, DEPOSIT_AMOUNT);
vm.prank(carol);
circle.deposit(baseCircleId, DEPOSIT_AMOUNT);

// Alice withdraws for Bob (who is now next in line)
balanceBefore = token.balanceOf(bob);
vm.prank(alice);
circle.withdrawFor(baseCircleId, bob);
balanceAfter = token.balanceOf(bob);

// Bob should receive DEPOSIT_AMOUNT * 3
assertEq(balanceAfter - balanceBefore, DEPOSIT_AMOUNT * 3);
}

function test_DecommissionCircle() public {
createBaseCircle();

Expand Down
51 changes: 50 additions & 1 deletion test/unit/SavingCirclesUnit.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,18 @@ contract SavingCirclesUnit is Test {

vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(ISavingCircles.NotCommissioned.selector));
savingCircles.withdrawable(nonExistentCircleId);

vm.prank(alice);
vm.expectRevert(abi.encodeWithSelector(ISavingCircles.NotMember.selector));
savingCircles.withdraw(nonExistentCircleId);
}

function test_WithdrawWhenUserIsNotACircleMember() external {
address nonMember = makeAddr('nonMember');

vm.prank(nonMember);
vm.expectRevert(abi.encodeWithSelector(ISavingCircles.NotWithdrawable.selector));
vm.expectRevert(abi.encodeWithSelector(ISavingCircles.NotMember.selector));
savingCircles.withdraw(baseCircleId);
}

Expand Down Expand Up @@ -263,6 +267,51 @@ contract SavingCirclesUnit is Test {
assertEq(circle.currentIndex, 1);
}

function test_WithdrawForWhenParametersAreValid() external {
// Complete deposits from all members
vm.startPrank(alice);
token.mint(alice, DEPOSIT_AMOUNT);
token.approve(address(savingCircles), DEPOSIT_AMOUNT);
savingCircles.deposit(baseCircleId, DEPOSIT_AMOUNT);
vm.stopPrank();

vm.startPrank(bob);
token.mint(bob, DEPOSIT_AMOUNT);
token.approve(address(savingCircles), DEPOSIT_AMOUNT);
savingCircles.deposit(baseCircleId, DEPOSIT_AMOUNT);
vm.stopPrank();

vm.startPrank(carol);
token.mint(carol, DEPOSIT_AMOUNT);
token.approve(address(savingCircles), DEPOSIT_AMOUNT);
savingCircles.deposit(baseCircleId, DEPOSIT_AMOUNT);
vm.stopPrank();

// Move time past first round
vm.warp(block.timestamp + DEPOSIT_INTERVAL);

uint256 withdrawAmount = DEPOSIT_AMOUNT * members.length;

// Bob should be able to withdraw for Alice (who is first in line)
vm.prank(bob);
vm.expectEmit(true, true, true, true);
emit ISavingCircles.FundsWithdrawn(baseCircleId, alice, withdrawAmount);
savingCircles.withdrawFor(baseCircleId, alice);

// Verify alice received the tokens
assertEq(token.balanceOf(alice), withdrawAmount);

// Verify all member balances were reset
(, uint256[] memory balances) = savingCircles.memberBalances(baseCircleId);
for (uint256 i = 0; i < balances.length; i++) {
assertEq(balances[i], 0);
}

// Verify current index moved to next member
ISavingCircles.Circle memory circle = savingCircles.circle(baseCircleId);
assertEq(circle.currentIndex, 1);
}

function test_CircleInfoWhenCircleDoesNotExist() external {
uint256 nonExistentCircleId = uint256(keccak256(abi.encodePacked('Non Existent Circle')));

Expand Down

0 comments on commit ee54c69

Please sign in to comment.