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
25 changes: 25 additions & 0 deletions src/contracts/SafetyNet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import {IERC20} from '@openzeppelin/contracts/token/ERC20/IERC20.sol';
import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol';
import {ECDSA} from '@openzeppelin/contracts/utils/cryptography/ECDSA.sol';
import {IERC20Permit} from '@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol';

import {ISafetyNet} from '../interfaces/ISafetyNet.sol';

Expand All @@ -13,7 +14,7 @@
/// @author @exo404
/// @author @valeriooconte
/// @author @RonTuretzky
contract SafetyNet is ISafetyNet, ReentrancyGuard, OwnableUpgradeable {

Check warning on line 17 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 @@ -218,6 +219,30 @@
_deposit(_id, _value, _member);
}

/// @inheritdoc ISafetyNet
function depositWithPermit(
uint256 _id,
uint256 _value,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external override nonReentrant {
SafetyNet storage _safetyNet = safetyNets[_id];
if (_safetyNet.owner == address(0)) revert NotCommissioned();

uint256 _permitAmount;
bool _onboarding = (safetyNetMemberContribute[_id][msg.sender] == 0);
if (_onboarding) {
_permitAmount = _safetyNet.initialDeposit;
} else {
_permitAmount = _value;
}

IERC20Permit(_safetyNet.token).permit(msg.sender, address(this), _permitAmount, _deadline, _v, _r, _s);
_deposit(_id, _value, msg.sender);
}

/// @inheritdoc ISafetyNet
function redeemInvite(Invite calldata _invite, bytes calldata _signature) external override nonReentrant {
SafetyNet storage _safetyNet = safetyNets[_invite.safetyNetId];
Expand Down
9 changes: 9 additions & 0 deletions src/interfaces/ISafetyNet.sol
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,15 @@ interface ISafetyNet {
/// @param member The member address making the deposit
function depositFor(uint256 id, uint256 value, address member) external;

/// @notice Makes a deposit using EIP-2612 permit for single-transaction approval + deposit
/// @param id The Safety Net ID
/// @param value Amount to deposit
/// @param deadline Permit signature deadline
/// @param v Signature v component
/// @param r Signature r component
/// @param s Signature s component
function depositWithPermit(uint256 id, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s) external;

/// @notice Redeems an invite signed by the Safety Net owner
/// @param invite The invite data containing the Safety Net ID and nonce
/// @param signature The owner's EIP-712 signature
Expand Down
13 changes: 13 additions & 0 deletions test/mocks/MockERC20Permit.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.28;

import {ERC20} from '@openzeppelin/contracts/token/ERC20/ERC20.sol';
import {ERC20Permit} from '@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol';

contract MockERC20Permit is ERC20, ERC20Permit {
constructor(string memory name, string memory symbol) ERC20(name, symbol) ERC20Permit(name) {}

function mint(address to, uint256 amount) external {
_mint(to, amount);
}
}
103 changes: 103 additions & 0 deletions test/unit/SafetyNetPermit.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// 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 {MockERC20Permit} from 'test/mocks/MockERC20Permit.sol';

contract SafetyNetPermit is Test {
SafetyNet internal _sn;
MockERC20Permit internal _token;
address internal _owner;
address internal _alice;
uint256 internal _aliceKey;
address internal _bob;
uint256 internal _bobKey;

function setUp() public {
_owner = makeAddr('owner');
(_alice, _aliceKey) = makeAddrAndKey('alice');
(_bob, _bobKey) = makeAddrAndKey('bob');

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 MockERC20Permit('Mock', 'MOCK');

vm.prank(_owner);
_sn.setTokenAllowed(address(_token), true);

_token.mint(_alice, 1_000_000 ether);
_token.mint(_bob, 1_000_000 ether);
}

function _buildSafetyNet() internal view returns (ISafetyNet.SafetyNet memory) {
address[] memory members = new address[](2);
members[0] = _alice;
members[1] = _bob;
return 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: 50 ether, contestWindow: 3 days, votingWindow: 7 days,
epochDuration: 30 days, smallWithdrawsLimit: 3
});
}

function _getPermitSignature(uint256 key, address spender, uint256 amount, uint256 deadline) internal view returns (uint8 v, bytes32 r, bytes32 s) {
address signer = vm.addr(key);
bytes32 PERMIT_TYPEHASH = keccak256('Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)');
bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, signer, spender, amount, _token.nonces(signer), deadline));
bytes32 digest = keccak256(abi.encodePacked('\x19\x01', _token.DOMAIN_SEPARATOR(), structHash));
(v, r, s) = vm.sign(key, digest);
}

function test_DepositWithPermitOnboarding() external {
uint256 id = _sn.create(_buildSafetyNet());
uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _getPermitSignature(_aliceKey, address(_sn), 100 ether, deadline);

vm.prank(_alice);
_sn.depositWithPermit(id, 100 ether, deadline, v, r, s);

assertEq(_sn.safetyNetBalance(id), 100 ether);
assertEq(_sn.memberWithdrawableBalance(id, _alice), 100 ether);
}

function test_DepositWithPermitRegular() external {
uint256 id = _sn.create(_buildSafetyNet());

vm.prank(_alice);
_token.approve(address(_sn), type(uint256).max);
vm.prank(_alice);
_sn.deposit(id, 100 ether);

ISafetyNet.SafetyNet memory sn = _sn.getSafetyNet(id);
vm.warp(sn.safetyNetStart + sn.epochDuration);

uint256 deadline = block.timestamp + 1 hours;
(uint8 v, bytes32 r, bytes32 s) = _getPermitSignature(_aliceKey, address(_sn), 10 ether, deadline);

vm.prank(_alice);
_sn.depositWithPermit(id, 10 ether, deadline, v, r, s);

assertEq(_sn.safetyNetBalance(id), 110 ether);
}

function test_DepositWithPermitExpiredDeadline() external {
uint256 id = _sn.create(_buildSafetyNet());
uint256 deadline = block.timestamp - 1;
(uint8 v, bytes32 r, bytes32 s) = _getPermitSignature(_aliceKey, address(_sn), 100 ether, deadline);

vm.expectRevert();
vm.prank(_alice);
_sn.depositWithPermit(id, 100 ether, deadline, v, r, s);
}
}
Loading