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
47 changes: 43 additions & 4 deletions src/contracts/SafetyNet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
/// @author @exo404
/// @author @valeriooconte
/// @author @RonTuretzky
contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable {

Check warning on line 16 in src/contracts/SafetyNet.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

Contract has 17 states declarations but allowed no more than 15
/// @notice Number of days in a month (used for calculating monthly withdrawals)
uint256 public constant DAYS_IN_A_MONTH = 30;

Expand Down Expand Up @@ -91,6 +91,9 @@
/// @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();

Expand Down Expand Up @@ -186,6 +189,14 @@
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;
Expand Down Expand Up @@ -387,6 +398,11 @@
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];
Expand Down Expand Up @@ -454,6 +470,7 @@
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;
Expand All @@ -464,14 +481,36 @@

// 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();
Expand Down
7 changes: 7 additions & 0 deletions src/interfaces/ISafetyNet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
16 changes: 12 additions & 4 deletions test/invariants/fuzz/SafetyNetFuzz_DepositWithdraw.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
240 changes: 240 additions & 0 deletions test/unit/SafetyNetCredit.t.sol
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 21 in test/unit/SafetyNetCredit.t.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

'INITIAL_DEPOSIT' should start with _
uint256 internal constant FIXED_DEPOSIT = 10 ether;

Check warning on line 22 in test/unit/SafetyNetCredit.t.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

'FIXED_DEPOSIT' should start with _
uint256 internal constant EPOCH_DURATION = 30 days;

Check warning on line 23 in test/unit/SafetyNetCredit.t.sol

View workflow job for this annotation

GitHub Actions / Lint Commit Messages

'EPOCH_DURATION' should start with _

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');
}
}
7 changes: 4 additions & 3 deletions test/unit/SafetyNetUnit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -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 {
Expand Down
Loading