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
30 changes: 17 additions & 13 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 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;

Expand Down Expand Up @@ -210,7 +210,7 @@

/// @inheritdoc ISafetyNet
function deposit(uint256 _id, uint256 _value) external override nonReentrant {
_deposit(_id, _value, msg.sender);
_deposit(_id, _value, _msgSender());
}

/// @inheritdoc ISafetyNet
Expand All @@ -224,7 +224,7 @@

if (_safetyNet.owner == address(0)) revert NotCommissioned();
if (usedNonces[_invite.safetyNetId][_invite.nonce]) revert InviteAlreadyUsed();
if (isMember[_invite.safetyNetId][msg.sender]) revert AlreadyMember();
if (isMember[_invite.safetyNetId][_msgSender()]) revert AlreadyMember();
if (_safetyNet.members.length >= _safetyNet.maximumMembers) revert SafetyNetFull();

bytes32 _digest = _hashInvite(_invite);
Expand All @@ -233,21 +233,21 @@
if (_signer != _safetyNet.owner) revert InvalidSigner();

usedNonces[_invite.safetyNetId][_invite.nonce] = true;
isMember[_invite.safetyNetId][msg.sender] = true;
memberSafetyNets[msg.sender].push(_invite.safetyNetId);
_safetyNet.members.push(msg.sender);
isMember[_invite.safetyNetId][_msgSender()] = true;
memberSafetyNets[_msgSender()].push(_invite.safetyNetId);
_safetyNet.members.push(_msgSender());

emit InviteRedeemed(_invite.safetyNetId, msg.sender);
emit InviteRedeemed(_invite.safetyNetId, _msgSender());
}

/// @inheritdoc ISafetyNet
function withdraw(uint256 _id, uint256 _daysRequested) external override nonReentrant {
_withdraw(_id, msg.sender, _daysRequested);
_withdraw(_id, _msgSender(), _daysRequested);
}

/// @inheritdoc ISafetyNet
function createRequest(Request memory _request) external override onlyMemberOf(_request.safetyNetId) returns (uint256) {
if (_request.owner != msg.sender) revert InvalidOwner();
if (_request.owner != _msgSender()) revert InvalidOwner();
if (safetyNets[_request.safetyNetId].owner == address(0)) revert NotCommissioned();
if (_request.amount == 0) revert InvalidRequest();

Expand Down Expand Up @@ -282,15 +282,16 @@
_deduct(_request.safetyNetId, _request.owner, _request.amount);

isExecuted[_idRequest] = true;
emit VoteResolved(_idRequest, true, 0, 0, _safetyNet.members.length);
emit WithdrawalAutoExecuted(_idRequest, _request.owner, _request.amount);

if (!IERC20(_safetyNet.token).transfer(_request.owner, _request.amount)) revert TransferFailed();
}
}

function vote(uint256 _requestId, bool _vote) external override nonReentrant {
if (!isMember[requests[_requestId].safetyNetId][msg.sender]) revert NotMember();
if (requestVotes[_requestId][msg.sender]) revert AlreadyVoted();
if (!isMember[requests[_requestId].safetyNetId][_msgSender()]) revert NotMember();
if (requestVotes[_requestId][_msgSender()]) revert AlreadyVoted();
if (!_isVotingOngoing(_requestId)) revert VotingWindowClosed();
if (isExecuted[_requestId]) revert AlreadyExecuted();

Expand All @@ -299,8 +300,8 @@
} else {
requests[_requestId].noVotes++;
}
requestVotes[_requestId][msg.sender] = true;
emit Voted(_requestId, msg.sender, _vote);
requestVotes[_requestId][_msgSender()] = true;
emit Voted(_requestId, _msgSender(), _vote);

// Check if consensus has been reached after this vote
Request memory _request = requests[_requestId];
Expand All @@ -311,6 +312,7 @@
_deduct(_request.safetyNetId, _request.owner, _request.amount);

isExecuted[_requestId] = true;
emit VoteResolved(_requestId, true, _request.yesVotes, _request.noVotes, _safetyNet.members.length);
emit WithdrawalApproved(_requestId, _request.owner, _request.amount);
if (!IERC20(_safetyNet.token).transfer(_request.owner, _request.amount)) revert TransferFailed();
}
Expand Down Expand Up @@ -524,6 +526,7 @@
revert ExceedsSmallWithdrawalLimit();
}
memberWithdrawableBalance[_id][_member] -= _withdrawAmount;
if (_withdrawAmount > safetyNetBalance[_id]) revert InsufficientPoolLiquidity();
safetyNetBalance[_id] -= _withdrawAmount;
if (!IERC20(_safetyNet.token).transfer(_member, _withdrawAmount)) revert TransferFailed();

Expand Down Expand Up @@ -562,7 +565,7 @@

/// @dev Reverts with {NotMember} if msg.sender is not a member of `_safetyNetId`.
function _onlyMemberOf(uint256 _safetyNetId) internal view {
if (!isMember[_safetyNetId][msg.sender]) revert NotMember();
if (!isMember[_safetyNetId][_msgSender()]) revert NotMember();
}

/// @dev Return if a specified Safety Net is decommissioned by checking if an owner is set
Expand All @@ -577,6 +580,7 @@
revert NotWithdrawable();
}
memberWithdrawableBalance[_safetyNetId][_member] -= _amount;
if (_amount > safetyNetBalance[_safetyNetId]) revert InsufficientPoolLiquidity();
safetyNetBalance[_safetyNetId] -= _amount;
}

Expand Down
6 changes: 6 additions & 0 deletions src/interfaces/ISafetyNet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ interface ISafetyNet {
/// @notice Emitted when an invite is successfully redeemed
event InviteRedeemed(uint256 indexed safetyNetId, address indexed redeemer);

/// @notice Emitted when a contested withdrawal vote is resolved (either auto-executed or by consensus)
event VoteResolved(uint256 indexed requestId, bool approved, uint256 yesVotes, uint256 noVotes, uint256 totalMembers);

/*///////////////////////////////////////////////////////////////
ERRORS
//////////////////////////////////////////////////////////////*/
Expand Down Expand Up @@ -252,6 +255,9 @@ interface ISafetyNet {
/// @notice Thrown when attempting to add members beyond the maximum allowed
error SafetyNetFull();

/// @notice Thrown when the pool balance is insufficient to cover a withdrawal
error InsufficientPoolLiquidity();

/*///////////////////////////////////////////////////////////////
EXTERNAL
//////////////////////////////////////////////////////////////*/
Expand Down
83 changes: 83 additions & 0 deletions test/unit/SafetyNetUnit.sol
Original file line number Diff line number Diff line change
Expand Up @@ -1039,4 +1039,87 @@ contract SafetyNetUnit is Test {
vm.expectRevert(ISafetyNet.NotCommissioned.selector);
_sn.redeemInvite(invite, signature);
}

// ---------- VoteResolved event ----------

function test_VoteResolvedEmittedOnAutoExecute() external {
_allowToken(address(_token));
ISafetyNet.SafetyNet memory _safetyNet = _defaultSafetyNet(address(_token));
uint256 id = _sn.create(_safetyNet);

// Alice pays initial deposit
_payInitial(id, _alice);
_payInitial(id, _bob);

// Advance past contest window so alice can withdraw accumulated balance
vm.warp(block.timestamp + 151 days);

// Request a large withdrawal (above autoThreshold=50 ether) to create a contested request
vm.prank(_alice);
_sn.withdraw(id, 151);
uint256 reqId = 0;

// Advance past contest window without contesting
vm.warp(block.timestamp + 4 days);

// Expect VoteResolved emitted with (reqId, true, 0, 0, 2 members)
vm.expectEmit(true, false, false, true, address(_sn));
emit ISafetyNet.VoteResolved(reqId, true, 0, 0, 2);
_sn.executeContestedWithdrawal(reqId);

assertTrue(_sn.isExecuted(reqId));
}

function test_VoteResolvedEmittedOnConsensus() external {
_allowToken(address(_token));

// 3 members so we can test yes/no counts
address[] memory members = new address[](3);
members[0] = _alice;
members[1] = _bob;
members[2] = _carol;
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: 1,
autoThreshold: 1,
contestWindow: 3 days,
votingWindow: 30 days,
epochDuration: 30 days,
smallWithdrawsLimit: 3
});
uint256 id = _sn.create(_safetyNet);

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

// Withdraw to create request (autoThreshold=1 so any withdrawal creates a request)
vm.prank(_alice);
_sn.withdraw(id, 2);
uint256 reqId = 0;

// Contest the request so it enters voting
vm.prank(_bob);
_sn.contest(reqId);

// Alice votes yes (1/3), Bob votes yes (2/3 = 66% > 60% = consensus)
vm.prank(_alice);
_sn.vote(reqId, true);

// Expect VoteResolved emitted when consensus is reached on Bob's vote
vm.expectEmit(true, false, false, true, address(_sn));
emit ISafetyNet.VoteResolved(reqId, true, 2, 0, 3);
vm.prank(_bob);
_sn.vote(reqId, true);

assertTrue(_sn.isExecuted(reqId));
}
}
Loading