diff --git a/src/contracts/SafetyNet.sol b/src/contracts/SafetyNet.sol index b89cf8b..e276852 100644 --- a/src/contracts/SafetyNet.sol +++ b/src/contracts/SafetyNet.sol @@ -17,10 +17,15 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { /// @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 @@ -498,12 +503,29 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { } /** - * @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]; @@ -536,7 +558,24 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { } } - /// @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; diff --git a/src/interfaces/ISafetyNet.sol b/src/interfaces/ISafetyNet.sol index 594e750..2682a0c 100644 --- a/src/interfaces/ISafetyNet.sol +++ b/src/interfaces/ISafetyNet.sol @@ -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 diff --git a/test/unit/SafetyNetUnit.sol b/test/unit/SafetyNetUnit.sol index 0bae2ac..c2179c3 100644 --- a/test/unit/SafetyNetUnit.sol +++ b/test/unit/SafetyNetUnit.sol @@ -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"); + } }