From 27474983a9b79761c361e59ce11216c0d1c9fdf6 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow Date: Sun, 5 Apr 2026 06:13:01 +0000 Subject: [PATCH] feat: add VoteResolved event for frontend/indexer tracking #24 --- src/contracts/SafetyNet.sol | 30 +++++++------ src/interfaces/ISafetyNet.sol | 6 +++ test/unit/SafetyNetUnit.sol | 83 +++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 13 deletions(-) diff --git a/src/contracts/SafetyNet.sol b/src/contracts/SafetyNet.sol index b89cf8b..e2da57c 100644 --- a/src/contracts/SafetyNet.sol +++ b/src/contracts/SafetyNet.sol @@ -210,7 +210,7 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { /// @inheritdoc ISafetyNet function deposit(uint256 _id, uint256 _value) external override nonReentrant { - _deposit(_id, _value, msg.sender); + _deposit(_id, _value, _msgSender()); } /// @inheritdoc ISafetyNet @@ -224,7 +224,7 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { 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); @@ -233,21 +233,21 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { 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(); @@ -282,6 +282,7 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { _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(); @@ -289,8 +290,8 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { } 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(); @@ -299,8 +300,8 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { } 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]; @@ -311,6 +312,7 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { _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(); } @@ -524,6 +526,7 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { revert ExceedsSmallWithdrawalLimit(); } memberWithdrawableBalance[_id][_member] -= _withdrawAmount; + if (_withdrawAmount > safetyNetBalance[_id]) revert InsufficientPoolLiquidity(); safetyNetBalance[_id] -= _withdrawAmount; if (!IERC20(_safetyNet.token).transfer(_member, _withdrawAmount)) revert TransferFailed(); @@ -562,7 +565,7 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { /// @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 @@ -577,6 +580,7 @@ contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable { revert NotWithdrawable(); } memberWithdrawableBalance[_safetyNetId][_member] -= _amount; + if (_amount > safetyNetBalance[_safetyNetId]) revert InsufficientPoolLiquidity(); safetyNetBalance[_safetyNetId] -= _amount; } diff --git a/src/interfaces/ISafetyNet.sol b/src/interfaces/ISafetyNet.sol index 594e750..79f6600 100644 --- a/src/interfaces/ISafetyNet.sol +++ b/src/interfaces/ISafetyNet.sol @@ -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 //////////////////////////////////////////////////////////////*/ @@ -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 //////////////////////////////////////////////////////////////*/ diff --git a/test/unit/SafetyNetUnit.sol b/test/unit/SafetyNetUnit.sol index 0bae2ac..58ebfda 100644 --- a/test/unit/SafetyNetUnit.sol +++ b/test/unit/SafetyNetUnit.sol @@ -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)); + } }