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
57 changes: 48 additions & 9 deletions src/contracts/SafetyNet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@
/// @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 16 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;

/// @notice Minimum redeem ratio
/// @notice Minimum redeem ratio (inclusive lower bound for SafetyNet.redeemRatio).
/// A ratio of 1 means each deposited token grants exactly 1 token of withdrawal capacity —
/// members can withdraw up to the total amount they have deposited (no amplification).
uint256 public constant MINIMUM_REDEEM_RATIO = 1;

/// @notice Maximum redeem ratio
/// @notice Maximum redeem ratio (inclusive upper bound for SafetyNet.redeemRatio).
/// A ratio of 22 means each deposited token grants 22 tokens of withdrawal capacity,
/// allowing members to withdraw up to 22x the value of their fixed deposits.
/// This cap prevents extreme liquidity mismatches within the collective pool.
uint256 public constant MAXIMUM_REDEEM_RATIO = 22;

/// @notice Invite signing domain name used for EIP-712 signatures
Expand Down Expand Up @@ -498,12 +503,29 @@
}

/**
* @dev Make a withdrawal
* @param _id The ID of the Safety Net
* @param _member The address of the member making the withdrawal
* @param _daysRequested The number of days for which the member is requesting a withdrawal
* @notice If the requested amount is small, it is transferred directly to the member
* If the requested amount is large, a request is created for approval
* @dev Execute a withdrawal for a Safety Net member.
*
* Withdrawal amount is computed in two steps using the redeemRatio multiplier:
*
* Step 1 — Daily entitlement:
* dailyWithdrawableAmount = (memberContribute * redeemRatio) / DAYS_IN_A_MONTH
* where `memberContribute` is the member's fixed deposit (`safetyNetMemberContribute[_id][_member]`),
* `redeemRatio` is the Safety Net's configured multiplier, and `DAYS_IN_A_MONTH` is 30.
*
* Step 2 — Requested amount:
* withdrawAmount = dailyWithdrawableAmount * daysRequested
*
* Step 3 — Routing:
* - If withdrawAmount <= autoThreshold => small withdrawal: tokens are transferred directly to the member.
* - If withdrawAmount > autoThreshold => large withdrawal: a Request is created and must be approved by peers.
*
* Worked example (redeemRatio = 5, fixedDeposit = 10e18, DAYS_IN_A_MONTH = 30):
* dailyWithdrawableAmount = (10e18 * 5) / 30 = 1_666_666_666_666_666_666 (~1.667 tokens/day)
* For 3 days requested: withdrawAmount = 1_666_666_666_666_666_666 * 3 = 4_999_999_999_999_999_998 (~5 tokens)
*
* @param _id The ID of the Safety Net
* @param _member The address of the member making the withdrawal
* @param _daysRequested The number of days for which the member is requesting a withdrawal
*/
function _withdraw(uint256 _id, address _member, uint256 _daysRequested) internal {
SafetyNet memory _safetyNet = safetyNets[_id];
Expand Down Expand Up @@ -536,7 +558,24 @@
}
}

/// @dev Calculates the daily withdrawal for a member in a Safety Net
/// @dev Calculates the daily withdrawal entitlement for a member in a Safety Net.
///
/// Formula (step by step):
/// 1. Retrieve `memberContribute` = safetyNetMemberContribute[_id][_member]
/// This is the member's fixed deposit amount set after their onboarding deposit.
/// 2. Compute monthly entitlement:
/// monthlyWithdrawalAmount = memberContribute * redeemRatio
/// 3. Divide by DAYS_IN_A_MONTH (30) to obtain the per-day amount:
/// dailyWithdrawableAmount = monthlyWithdrawalAmount / DAYS_IN_A_MONTH
///
/// Example (memberContribute = 10e18, redeemRatio = 5):
/// monthlyWithdrawalAmount = 10e18 * 5 = 50e18
/// dailyWithdrawableAmount = 50e18 / 30 = 1_666_666_666_666_666_666 (~1.667 tokens/day)
///
/// @param _id The Safety Net ID
/// @param _member The member address
/// @param _redeemRatio The ratio multiplier from the SafetyNet config
/// @return The amount the member may withdraw per day (in token wei)
function _getDailyWithdrawableAmount(uint256 _id, address _member, uint256 _redeemRatio) internal view returns (uint256) {
uint256 _memberContribute = safetyNetMemberContribute[_id][_member];
uint256 _monthlyWithdrawalAmount = _memberContribute * _redeemRatio;
Expand Down
8 changes: 7 additions & 1 deletion src/interfaces/ISafetyNet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ interface ISafetyNet {
/// @param members List of member addresses
/// @param initialDeposit Initial deposit required to join
/// @param fixedDeposit Fixed deposit fee amount
/// @param redeemRatio Ratio of deposit to withdrawal
/// @param redeemRatio Multiplier applied to each deposit to determine a member's withdrawal entitlement.
/// Valid range: MINIMUM_REDEEM_RATIO (1) to MAXIMUM_REDEEM_RATIO (22).
/// When a member deposits `V` tokens, their `memberWithdrawableBalance` increases by `V * redeemRatio`.
/// The daily withdrawal entitlement is computed as:
/// dailyWithdrawableAmount = (fixedDeposit * redeemRatio) / DAYS_IN_A_MONTH
/// A ratio of 1 means members can withdraw exactly what they deposit (no multiplier).
/// A ratio of 5 means each token deposited entitles the member to 5 tokens of withdrawal capacity.
/// @param contestWindow Duration of the contest period for requests
/// @param votingWindow Duration of the voting period for requests
/// @param epochDuration Duration of each epoch in seconds
Expand Down
74 changes: 74 additions & 0 deletions test/unit/SafetyNetUnit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -1039,4 +1039,78 @@ contract SafetyNetUnit is Test {
vm.expectRevert(ISafetyNet.NotCommissioned.selector);
_sn.redeemInvite(invite, signature);
}

// ---------- redeemRatio formula verification ----------

/// @notice Verifies the redeemRatio multiplier is applied correctly at every stage:
/// 1. initialDeposit: memberWithdrawableBalance = initialDeposit * redeemRatio
/// 2. fixedDeposit: memberWithdrawableBalance increases by fixedDeposit * redeemRatio
/// 3. withdraw(1 day): deducted amount = (fixedDeposit * redeemRatio) / DAYS_IN_A_MONTH
/// with fixedDeposit=10e18, redeemRatio=5, DAYS_IN_A_MONTH=30 => 1_666_666_666_666_666_666
function test_RedeemRatioFormulaVerification() external {
_allowToken(address(_token));

// Build a custom safety net with redeemRatio = 5 and a large autoThreshold so
// the 1-day withdrawal goes through the small (direct transfer) path.
address[] memory members = new address[](2);
members[0] = _alice;
members[1] = _bob;
ISafetyNet.SafetyNet memory _safetyNet = ISafetyNet.SafetyNet({
id: 0,
owner: _owner,
minimumMembers: 2,
maximumMembers: 5,
consensusThreshold: 60,
safetyNetStart: block.timestamp,
token: address(_token),
members: members,
initialDeposit: 100 ether,
fixedDeposit: 10 ether,
redeemRatio: 5,
autoThreshold: 500 ether,
contestWindow: 3 days,
votingWindow: 7 days,
epochDuration: 30 days,
smallWithdrawsLimit: 10
});

uint256 id = _sn.create(_safetyNet);

// --- epoch 0: alice deposits initialDeposit (100 ether) ---
vm.prank(_alice);
_sn.deposit(id, _safetyNet.initialDeposit);

// memberWithdrawableBalance should equal initialDeposit * redeemRatio = 100e18 * 5 = 500e18
uint256 balanceAfterInitial = _sn.memberWithdrawableBalance(id, _alice);
assertEq(balanceAfterInitial, 500 ether, "initial: balance should be initialDeposit * redeemRatio");

// --- epoch 1: alice deposits fixedDeposit (10 ether) ---
vm.warp(_safetyNet.safetyNetStart + _safetyNet.epochDuration + 1);

vm.prank(_alice);
_sn.deposit(id, _safetyNet.fixedDeposit);

uint256 balanceAfterFixed = _sn.memberWithdrawableBalance(id, _alice);
// increase should be fixedDeposit * redeemRatio = 10e18 * 5 = 50e18
assertEq(balanceAfterFixed - balanceAfterInitial, 50 ether, "epoch1 deposit: increase should be fixedDeposit * redeemRatio");

// --- withdraw 1 day ---
// dailyWithdrawableAmount = (fixedDeposit * redeemRatio) / 30
// = (10e18 * 5) / 30
// = 1_666_666_666_666_666_666
// Compute daily entitlement as a runtime expression to avoid Solidity constant-folding
// refusing to convert rational constants to uint256.
uint256 expectedDaily = (_safetyNet.fixedDeposit * _safetyNet.redeemRatio) / 30;
assertEq(expectedDaily, 1_666_666_666_666_666_666, "expectedDaily computation sanity check");

uint256 tokenBefore = _token.balanceOf(_alice);
vm.prank(_alice);
_sn.withdraw(id, 1);

uint256 withdrawn = _token.balanceOf(_alice) - tokenBefore;
assertEq(withdrawn, expectedDaily, "withdrawn amount should equal (fixedDeposit * redeemRatio) / DAYS_IN_A_MONTH");

uint256 balanceAfterWithdraw = _sn.memberWithdrawableBalance(id, _alice);
assertEq(balanceAfterWithdraw, balanceAfterFixed - expectedDaily, "withdrawable balance should decrease by daily amount");
}
}
Loading