From b7e8a6771b525c035c565e859a5ab9974dc826c2 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Sun, 5 Apr 2026 06:52:18 +0000 Subject: [PATCH] feat: add deposit credit rollover for excess deposits #54 --- src/contracts/SafetyNet.sol | 47 +++- src/interfaces/ISafetyNet.sol | 7 + .../fuzz/SafetyNetFuzz_DepositWithdraw.t.sol | 16 +- test/unit/SafetyNetCredit.t.sol | 240 ++++++++++++++++++ test/unit/SafetyNetUnit.sol | 7 +- 5 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 test/unit/SafetyNetCredit.t.sol diff --git a/src/contracts/SafetyNet.sol b/src/contracts/SafetyNet.sol index b89cf8b..833d264 100644 --- a/src/contracts/SafetyNet.sol +++ b/src/contracts/SafetyNet.sol @@ -91,6 +91,9 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { /// @notice Tracks used nonces for invites to prevent replay attacks mapping(uint256 safetyNetId => mapping(uint256 nonce => bool used)) public usedNonces; + /// @notice Tracks excess deposit credit that rolls over to future epochs for each member + mapping(uint256 id => mapping(address member => uint256)) public memberDepositCredit; + /// @notice Thrown if a transfer fails error TransferFailed(); @@ -186,6 +189,14 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { for (uint256 i = 0; i < _safetyNetMembersLength; i++) { address _member = _safetyNet.members[i]; uint256 _amount = memberWithdrawableBalance[_id][_member]; + + // Return any stored deposit credit (tokens already in contract but not yet counted) + uint256 _creditAmount = memberDepositCredit[_id][_member]; + if (_creditAmount > 0) { + memberDepositCredit[_id][_member] = 0; + _amount += _creditAmount; + } + if (_amount > 0) { memberWithdrawableBalance[_id][_member] = 0; _balance -= _amount; @@ -387,6 +398,11 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { return trimmed; } + /// @inheritdoc ISafetyNet + function getMemberDepositCredit(uint256 _id, address _member) external view override returns (uint256) { + return memberDepositCredit[_id][_member]; + } + /// @inheritdoc ISafetyNet function hasMemberDepositedInEpoch(uint256 _safetyNetId, address _member, uint256 _epochIndex) external view override returns (bool) { ISafetyNet.SafetyNet storage _safetyNet = safetyNets[_safetyNetId]; @@ -454,6 +470,7 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { uint256 epochPaid = epochMemberDepositedAmount[_id][epoch][_member]; // Onboarding status is derived: not onboarded if no monthly contribution set yet. bool onboarding = (safetyNetMemberContribute[_id][_member] == 0); + uint256 _withdrawableContribution; if (onboarding) { uint256 initial = _safetyNet.initialDeposit; @@ -464,14 +481,36 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { // Set epoch paid to full initial epochPaid = initial; + _withdrawableContribution = _value; } else { - // Subsequent months: partials allowed up to fixedDeposit - if (epochPaid + _value > _safetyNet.fixedDeposit) revert ExceedsDepositAmount(); - epochPaid += _value; + // Subsequent months: partials allowed, with credit rollover support + uint256 _remaining = epochPaid >= _safetyNet.fixedDeposit ? 0 : _safetyNet.fixedDeposit - epochPaid; + + // Auto-apply any existing credit toward this epoch's dues + uint256 _credit = memberDepositCredit[_id][_member]; + uint256 _creditUsed = 0; + if (_credit > 0 && _remaining > 0) { + _creditUsed = _credit >= _remaining ? _remaining : _credit; + memberDepositCredit[_id][_member] -= _creditUsed; + _remaining -= _creditUsed; + } + + // Handle the actual token deposit amount + uint256 _effectiveDeposit; + if (_value > _remaining) { + // Store excess as credit for future epochs (withdrawable granted when credit is consumed) + uint256 _excess = _value - _remaining; + memberDepositCredit[_id][_member] += _excess; + _effectiveDeposit = _remaining; + } else { + _effectiveDeposit = _value; + } + epochPaid += _creditUsed + _effectiveDeposit; + _withdrawableContribution = _creditUsed + _effectiveDeposit; } safetyNetBalance[_id] += _value; - memberWithdrawableBalance[_id][_member] += _value * _safetyNet.redeemRatio; + memberWithdrawableBalance[_id][_member] += _withdrawableContribution * _safetyNet.redeemRatio; epochMemberDepositedAmount[_id][epoch][_member] = epochPaid; if (!IERC20(_safetyNet.token).transferFrom(_member, address(this), _value)) revert TransferFailed(); diff --git a/src/interfaces/ISafetyNet.sol b/src/interfaces/ISafetyNet.sol index 594e750..97c7ed3 100644 --- a/src/interfaces/ISafetyNet.sol +++ b/src/interfaces/ISafetyNet.sol @@ -370,4 +370,11 @@ interface ISafetyNet { /// @param epochIndex The epoch index to check /// @return hasDeposited True if the member deposited in that epoch function hasMemberDepositedInEpoch(uint256 safetyNetId, address member, uint256 epochIndex) external view returns (bool); + /// @notice Returns the deposit credit balance for a member in a Safety Net + /// @dev Credit accumulates when a member deposits more than the fixedDeposit in a given epoch + /// and is automatically applied in future epochs to reduce required deposits + /// @param id Safety Net ID + /// @param member Member address + /// @return credit The amount of deposit credit available for the member + function getMemberDepositCredit(uint256 id, address member) external view returns (uint256 credit); } diff --git a/test/invariants/fuzz/SafetyNetFuzz_DepositWithdraw.t.sol b/test/invariants/fuzz/SafetyNetFuzz_DepositWithdraw.t.sol index 8bac46a..84b07c6 100644 --- a/test/invariants/fuzz/SafetyNetFuzz_DepositWithdraw.t.sol +++ b/test/invariants/fuzz/SafetyNetFuzz_DepositWithdraw.t.sol @@ -42,12 +42,14 @@ contract SafetyNetFuzz_DepositWithdraw is SafetyNetFuzzBase { uint256 epochIndex = _safetyNet.getCurrentEpochIndex(id); uint256 beforeWithdrawable = _safetyNet.memberWithdrawableBalance(id, actor); uint256 duesRemainingBefore = _safetyNet.duesRemainingThisEpoch(id, actor); + uint256 creditBefore = _safetyNet.memberDepositCredit(id, actor); if (duesRemainingBefore == 0) { - // already fully paid this epoch → any extra should exceed cap + // already fully paid this epoch → excess goes to credit (no revert) + uint256 creditBefore2 = _safetyNet.memberDepositCredit(id, actor); vm.prank(actor); - vm.expectRevert(ISafetyNet.ExceedsDepositAmount.selector); - try _safetyNet.deposit(id, 1) {} catch {} + _safetyNet.deposit(id, 1); + assertEq(_safetyNet.memberDepositCredit(id, actor), creditBefore2 + 1, 'excess stored as credit'); return; } @@ -77,7 +79,13 @@ contract SafetyNetFuzz_DepositWithdraw is SafetyNetFuzzBase { assertEq(paidAfter, duesRemainingAfter == 0, 'flag only when epoch fully paid'); uint256 afterWithdrawable = _safetyNet.memberWithdrawableBalance(id, actor); - assertEq(afterWithdrawable, beforeWithdrawable + depositedAmount, 'withdrawable += deposited amt'); + uint256 afterCredit2 = _safetyNet.memberDepositCredit(id, actor); + int256 creditDelta2 = int256(afterCredit2) - int256(creditBefore); + assertEq( + int256(afterWithdrawable) - int256(beforeWithdrawable), + int256(depositedAmount) - creditDelta2, + 'withdrawable += deposited - creditDelta' + ); } // ── Case 2: Small withdrawals — within limit, ≤ autoThreshold, and ≤ balance diff --git a/test/unit/SafetyNetCredit.t.sol b/test/unit/SafetyNetCredit.t.sol new file mode 100644 index 0000000..dfbe119 --- /dev/null +++ b/test/unit/SafetyNetCredit.t.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +import {ProxyAdmin} from '@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol'; +import {TransparentUpgradeableProxy} from '@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol'; +import {Test} from 'forge-std/Test.sol'; + +import {SafetyNet} from 'src/contracts/SafetyNet.sol'; +import {ISafetyNet} from 'src/interfaces/ISafetyNet.sol'; +import {MockERC20} from 'test/mocks/MockERC20.sol'; + +contract SafetyNetCreditTest is Test { + SafetyNet internal _sn; + MockERC20 internal _token; + + address internal _owner; + address internal _alice = makeAddr('alice'); + address internal _bob = makeAddr('bob'); + address internal _carol = makeAddr('carol'); + + uint256 internal constant INITIAL_DEPOSIT = 100 ether; + uint256 internal constant FIXED_DEPOSIT = 10 ether; + uint256 internal constant EPOCH_DURATION = 30 days; + + uint256 internal _snId; + + function setUp() public { + (_owner,) = makeAddrAndKey('owner'); + + address impl = address(new SafetyNet()); + address admin = address(new ProxyAdmin(_owner)); + address proxy = address( + new TransparentUpgradeableProxy(impl, admin, abi.encodeWithSelector(SafetyNet.initialize.selector, _owner)) + ); + _sn = SafetyNet(proxy); + + _token = new MockERC20('Mock', 'MOCK'); + vm.prank(_owner); + _sn.setTokenAllowed(address(_token), true); + + // Fund all members generously + _token.mint(_alice, 1_000_000 ether); + _token.mint(_bob, 1_000_000 ether); + _token.mint(_carol, 1_000_000 ether); + + vm.prank(_alice); + _token.approve(address(_sn), type(uint256).max); + vm.prank(_bob); + _token.approve(address(_sn), type(uint256).max); + vm.prank(_carol); + _token.approve(address(_sn), type(uint256).max); + + // Create a safety net with alice and bob + address[] memory members = new address[](2); + members[0] = _alice; + members[1] = _bob; + + ISafetyNet.SafetyNet memory snConfig = ISafetyNet.SafetyNet({ + id: 0, + owner: _owner, + minimumMembers: 2, + maximumMembers: 5, + consensusThreshold: 60, + safetyNetStart: block.timestamp, + token: address(_token), + members: members, + initialDeposit: INITIAL_DEPOSIT, + fixedDeposit: FIXED_DEPOSIT, + redeemRatio: 1, + autoThreshold: 50 ether, + contestWindow: 3 days, + votingWindow: 7 days, + epochDuration: EPOCH_DURATION, + smallWithdrawsLimit: 3 + }); + + vm.prank(_owner); + _snId = _sn.create(snConfig); + + // Onboard alice and bob in epoch 0 + vm.prank(_alice); + _sn.deposit(_snId, INITIAL_DEPOSIT); + vm.prank(_bob); + _sn.deposit(_snId, INITIAL_DEPOSIT); + } + + // ── Helper: advance to epoch N ────────────────────────────────────────────── + + function _advanceToEpoch(uint256 n) internal { + vm.warp(block.timestamp + EPOCH_DURATION * n + 1); + } + + // ── test_ExcessDepositStoresCredit ────────────────────────────────────────── + // Depositing more than fixedDeposit stores the excess as credit instead of reverting. + + function test_ExcessDepositStoresCredit() external { + _advanceToEpoch(1); + + uint256 creditBefore = _sn.memberDepositCredit(_snId, _alice); + assertEq(creditBefore, 0, 'no credit before over-deposit'); + + // Deposit double the fixedDeposit + uint256 depositAmt = FIXED_DEPOSIT * 2; + vm.prank(_alice); + _sn.deposit(_snId, depositAmt); + + uint256 creditAfter = _sn.memberDepositCredit(_snId, _alice); + assertEq(creditAfter, FIXED_DEPOSIT, 'excess stored as credit'); + + // duesRemaining should be 0 (epoch fully paid) + assertEq(_sn.duesRemainingThisEpoch(_snId, _alice), 0, 'epoch should be fully paid'); + + // getMemberDepositCredit view function works + assertEq(_sn.getMemberDepositCredit(_snId, _alice), FIXED_DEPOSIT, 'getMemberDepositCredit matches'); + } + + // ── test_CreditAppliedNextEpoch ───────────────────────────────────────────── + // Credit from an over-deposit is automatically consumed the next epoch. + + function test_CreditAppliedNextEpoch() external { + _advanceToEpoch(1); + + // Over-deposit: store fixedDeposit as credit + vm.prank(_alice); + _sn.deposit(_snId, FIXED_DEPOSIT * 2); + + uint256 creditAfterEpoch1 = _sn.memberDepositCredit(_snId, _alice); + assertEq(creditAfterEpoch1, FIXED_DEPOSIT, 'credit stored for epoch 2'); + + _advanceToEpoch(2); + + // Track state before the epoch-2 deposit + uint256 withdrawableBefore = _sn.memberWithdrawableBalance(_snId, _alice); + + // Deposit exactly fixedDeposit again; credit will be consumed but the payment + // already covers the dues by itself. The credit stays (nothing more to cover). + // To see credit consumption, deposit less than fixedDeposit. + uint256 smallDeposit = FIXED_DEPOSIT / 2; + vm.prank(_alice); + _sn.deposit(_snId, smallDeposit); + + uint256 creditAfterEpoch2 = _sn.memberDepositCredit(_snId, _alice); + // Credit consumed: the smaller deposit left remaining = FIXED_DEPOSIT/2, + // and credit covered that exactly (credit=FIXED_DEPOSIT >= FIXED_DEPOSIT/2). + uint256 expectedCreditConsumed = FIXED_DEPOSIT / 2; + assertEq( + creditAfterEpoch2, creditAfterEpoch1 - expectedCreditConsumed, 'credit consumed to cover remaining dues' + ); + + // Epoch fully paid (credit + deposit = fixedDeposit) + assertEq(_sn.duesRemainingThisEpoch(_snId, _alice), 0, 'epoch 2 fully paid with credit'); + + // Withdrawable balance increased by (creditConsumed + smallDeposit) = fixedDeposit + uint256 withdrawableAfter = _sn.memberWithdrawableBalance(_snId, _alice); + assertEq(withdrawableAfter - withdrawableBefore, FIXED_DEPOSIT, 'withdrawable += creditConsumed + effectiveDeposit'); + } + + // ── test_CreditConsumptionPartial ─────────────────────────────────────────── + // Partial credit consumption: credit larger than dues uses only what's needed. + + function test_CreditConsumptionPartial() external { + _advanceToEpoch(1); + + // Store 2x fixedDeposit as credit (deposit 3x total, 2x excess) + vm.prank(_alice); + _sn.deposit(_snId, FIXED_DEPOSIT * 3); + uint256 credit = _sn.memberDepositCredit(_snId, _alice); + assertEq(credit, FIXED_DEPOSIT * 2, '2x credit stored'); + + _advanceToEpoch(2); + + // Deposit 1 token — credit should cover remaining FIXED_DEPOSIT - 1 + uint256 tinyDeposit = 1; + vm.prank(_alice); + _sn.deposit(_snId, tinyDeposit); + + // epochPaid should be FIXED_DEPOSIT (fully paid via credit + tinyDeposit) + assertEq(_sn.duesRemainingThisEpoch(_snId, _alice), 0, 'epoch 2 fully covered'); + + uint256 creditAfter = _sn.memberDepositCredit(_snId, _alice); + // Credit used = FIXED_DEPOSIT - 1, credit remaining = 2*FIXED_DEPOSIT - (FIXED_DEPOSIT - 1) + assertEq(creditAfter, FIXED_DEPOSIT + 1, 'partial credit consumed'); + } + + // ── test_ExcessDepositDoesNotRevert ───────────────────────────────────────── + // Old behavior was to revert with ExceedsDepositAmount; now it stores as credit. + + function test_ExcessDepositDoesNotRevert() external { + _advanceToEpoch(1); + + // First pay exact dues + vm.prank(_alice); + _sn.deposit(_snId, FIXED_DEPOSIT); + assertEq(_sn.duesRemainingThisEpoch(_snId, _alice), 0, 'epoch paid'); + + // Then deposit more — should NOT revert, goes to credit + vm.prank(_alice); + _sn.deposit(_snId, FIXED_DEPOSIT); + + assertEq(_sn.memberDepositCredit(_snId, _alice), FIXED_DEPOSIT, 'over-deposit goes to credit'); + } + + // ── test_DecommissionReturnsCredit ───────────────────────────────────────── + // On decommission, any stored credit is returned to the member in addition + // to their withdrawable balance. + + function test_DecommissionReturnsCredit() external { + _advanceToEpoch(1); + + // Alice over-deposits, generating credit + vm.prank(_alice); + _sn.deposit(_snId, FIXED_DEPOSIT * 2); + uint256 aliceCredit = _sn.memberDepositCredit(_snId, _alice); + assertEq(aliceCredit, FIXED_DEPOSIT, 'alice has credit before decommission'); + + // Bob pays normally + vm.prank(_bob); + _sn.deposit(_snId, FIXED_DEPOSIT); + + // Skip an epoch so bob misses a payment → safety net becomes decommissionable + _advanceToEpoch(3); + + assertTrue(_sn.isDecommissionable(_snId), 'should be decommissionable'); + + uint256 aliceTokensBefore = _token.balanceOf(_alice); + uint256 aliceWithdrawable = _sn.memberWithdrawableBalance(_snId, _alice); + + _sn.decommission(_snId); + + uint256 aliceTokensAfter = _token.balanceOf(_alice); + uint256 aliceReceived = aliceTokensAfter - aliceTokensBefore; + + // Alice should receive withdrawable balance + credit + uint256 expectedMin = aliceWithdrawable + aliceCredit; + assertGe(aliceReceived, expectedMin, 'alice receives withdrawable + credit on decommission'); + + // Credit zeroed out + assertEq(_sn.memberDepositCredit(_snId, _alice), 0, 'credit cleared after decommission'); + } +} diff --git a/test/unit/SafetyNetUnit.sol b/test/unit/SafetyNetUnit.sol index 0bae2ac..df90fc7 100644 --- a/test/unit/SafetyNetUnit.sol +++ b/test/unit/SafetyNetUnit.sol @@ -484,10 +484,10 @@ contract SafetyNetUnit is Test { vm.prank(_alice); _sn.deposit(id, 9 ether); - // Now any extra exceeds the epoch cap - vm.expectRevert(ISafetyNet.ExceedsDepositAmount.selector); + // Now any extra goes to credit (no revert with credit rollover) vm.prank(_alice); _sn.deposit(id, 1 ether); + assertEq(_sn.getMemberDepositCredit(id, _alice), 1 ether); // Fully paid flag (derived) is now true assertTrue(_sn.hasMemberDepositedInEpoch(id, _alice, _sn.getCurrentEpochIndex(id))); @@ -599,9 +599,10 @@ contract SafetyNetUnit is Test { vm.prank(_bob); _sn.depositFor(id, 4 ether, _alice); - vm.expectRevert(ISafetyNet.ExceedsDepositAmount.selector); + // Excess goes to credit (no revert with credit rollover) vm.prank(_bob); _sn.depositFor(id, 1 ether, _alice); + assertEq(_sn.getMemberDepositCredit(id, _alice), 1 ether); } function test_DepositForWhenTokenTransferFromFailsFromSender() external {