diff --git a/src/IDAIPermit.sol b/src/IDAIPermit.sol new file mode 100644 index 0000000..ffde527 --- /dev/null +++ b/src/IDAIPermit.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +interface IDAIPermit { + function permit( + address holder, + address spender, + uint256 nonce, + uint256 expiry, + bool allowed, + uint8 v, + bytes32 r, + bytes32 s + ) + external; +} diff --git a/src/Membership.sol b/src/Membership.sol index 0ef6939..e46fd77 100644 --- a/src/Membership.sol +++ b/src/Membership.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.24; import { IPriceCalculator } from "./IPriceCalculator.sol"; +import { IDAIPermit } from "./IDAIPermit.sol"; import { IERC20 } from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import { SafeERC20 } from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; @@ -266,6 +267,64 @@ abstract contract MembershipUpgradeable is Initializable { IERC20(token).safeTransferFrom(_owner, address(this), depositAmount); } + /// @dev acquire a membership and transfer the deposit to the contract + /// Uses DAI permit extension allowing approvals to be made via signatures + /// @param _owner The address of the token owner who is giving permission and will own the membership. + /// @param _deadline The timestamp until when the permit is valid. + /// @param _nonce The nonce used for the permission + /// @param _v The recovery byte of the signature. + /// @param _r Half of the ECDSA signature pair. + /// @param _s Half of the ECDSA signature pair. + /// @param _idCommitment the idCommitment of the new membership + /// @param _rateLimit the membership rate limit + /// @return index the index of the new membership in the membership set + /// @return indexReused true if the index was reused, false otherwise + function _acquireMembershipWithDAIPermit( + address _owner, + uint256 _deadline, + uint256 _nonce, + uint8 _v, + bytes32 _r, + bytes32 _s, + uint256 _idCommitment, + uint32 _rateLimit + ) + internal + returns (uint32 index, bool indexReused) + { + // Check if the rate limit is valid + if (!isValidMembershipRateLimit(_rateLimit)) { + revert InvalidMembershipRateLimit(); + } + + currentTotalRateLimit += _rateLimit; + + // Determine if we exceed the total rate limit + if (currentTotalRateLimit > maxTotalRateLimit) { + revert CannotExceedMaxTotalRateLimit(); + } + + (address token, uint256 depositAmount) = priceCalculator.calculate(_rateLimit); + + IDAIPermit(token).permit(_owner, address(this), _nonce, _deadline, true, _v, _r, _s); + + // Possibly reuse an index of an erased membership + (index, indexReused) = _getFreeIndex(); + + memberships[_idCommitment] = MembershipInfo({ + holder: _owner, + activeDuration: activeDurationForNewMemberships, + gracePeriodStartTimestamp: block.timestamp + uint256(activeDurationForNewMemberships), + gracePeriodDuration: gracePeriodDurationForNewMemberships, + token: token, + depositAmount: depositAmount, + rateLimit: _rateLimit, + index: index + }); + + IERC20(token).safeTransferFrom(_owner, address(this), depositAmount); + } + /// @notice Checks if a rate limit is within the allowed bounds /// @param rateLimit The rate limit /// @return true if the rate limit is within the allowed bounds, false otherwise diff --git a/src/WakuRlnV2.sol b/src/WakuRlnV2.sol index e984de1..7b06d79 100644 --- a/src/WakuRlnV2.sol +++ b/src/WakuRlnV2.sol @@ -20,6 +20,15 @@ error InvalidIdCommitment(uint256 idCommitment); /// Invalid pagination query error InvalidPaginationQuery(uint256 startIndex, uint256 endIndex); +struct MembershipPermitParams { + /// @notice The idCommitment of the new membership + uint256 idCommitment; + /// @notice The rate limit of the new membership + uint32 rateLimit; + /// @notice The list of idCommitments of expired memberships to erase + uint256[] idCommitmentsToErase; +} + contract WakuRlnV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable, MembershipUpgradeable { /// @notice The Field uint256 public constant Q = @@ -186,24 +195,25 @@ contract WakuRlnV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable, Member /// @param v The recovery byte of the signature. /// @param r Half of the ECDSA signature pair. /// @param s Half of the ECDSA signature pair. - /// @param idCommitment The idCommitment of the new membership - /// @param rateLimit The rate limit of the new membership - /// @param idCommitmentsToErase The list of idCommitments of expired memberships to erase + /// @param membership Parameters for the new membership function registerWithPermit( address owner, uint256 deadline, uint8 v, bytes32 r, bytes32 s, - uint256 idCommitment, - uint32 rateLimit, - uint256[] calldata idCommitmentsToErase + MembershipPermitParams calldata membership ) external - onlyValidIdCommitment(idCommitment) - noDuplicateMembership(idCommitment) membershipSetNotFull { + uint256 idCommitment = membership.idCommitment; + uint32 rateLimit = membership.rateLimit; + uint256[] calldata idCommitmentsToErase = membership.idCommitmentsToErase; + + if (!isValidIdCommitment(idCommitment)) revert InvalidIdCommitment(idCommitment); + require(!isInMembershipSet(idCommitment), "Duplicate idCommitment: membership already exists"); + // erase memberships without overwriting membership set data to zero (save gas) _eraseMemberships(idCommitmentsToErase, false); @@ -215,6 +225,45 @@ contract WakuRlnV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable, Member emit MembershipRegistered(idCommitment, rateLimit, index); } + /// @notice Register a membership while erasing some expired memberships to reuse their rate limit. + /// Uses the DAI Permit + /// @param owner The address of the token owner who is giving permission and will own the membership. + /// @param deadline The timestamp until when the permit is valid. + /// @param nonce The nonce used for the permission + /// @param v The recovery byte of the signature. + /// @param r Half of the ECDSA signature pair. + /// @param s Half of the ECDSA signature pair. + /// @param membership Parameters for the new membership + function registerWithDAIPermit( + address owner, + uint256 deadline, + uint256 nonce, + uint8 v, + bytes32 r, + bytes32 s, + MembershipPermitParams calldata membership + ) + external + membershipSetNotFull + { + uint256 idCommitment = membership.idCommitment; + uint32 rateLimit = membership.rateLimit; + uint256[] calldata idCommitmentsToErase = membership.idCommitmentsToErase; + + if (!isValidIdCommitment(idCommitment)) revert InvalidIdCommitment(idCommitment); + require(!isInMembershipSet(idCommitment), "Duplicate idCommitment: membership already exists"); + + // erase memberships without overwriting membership set data to zero (save gas) + _eraseMemberships(idCommitmentsToErase, false); + + (uint32 index, bool indexReused) = + _acquireMembershipWithDAIPermit(owner, deadline, nonce, v, r, s, idCommitment, rateLimit); + + _upsertInTree(idCommitment, rateLimit, index, indexReused); + + emit MembershipRegistered(idCommitment, rateLimit, index); + } + /// @dev Register a membership (internal function) /// @param idCommitment The idCommitment of the membership /// @param rateLimit The rate limit of the membership diff --git a/test/TestToken.sol b/test/TestToken.sol index c259884..5c8b9df 100644 --- a/test/TestToken.sol +++ b/test/TestToken.sol @@ -6,11 +6,54 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; contract TestToken is ERC20, ERC20Permit { - constructor() ERC20("TestToken", "TTT") ERC20Permit("TestToken") { } + bytes32 public DAI_DOMAIN_SEPARATOR; + bytes32 public constant PERMIT_TYPEHASH = 0xea2aa0a1be11a07ed86d755c93467f4f82362b452371d1ba94d1715123511acb; + + constructor() ERC20("TestToken", "TTT") ERC20Permit("TestToken") { + DAI_DOMAIN_SEPARATOR = keccak256( + abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256(bytes("TestToken")), + keccak256(bytes("1")), + block.chainid, + address(this) + ) + ); + } function mint(address to, uint256 amount) public { _mint(to, amount); } + + function permit( + address holder, + address spender, + uint256 nonce, + uint256 expiry, + bool allowed, + uint8 v, + bytes32 r, + bytes32 s + ) + external + { + bytes32 digest = keccak256( + abi.encodePacked( + "\x19\x01", + DAI_DOMAIN_SEPARATOR, + keccak256(abi.encode(PERMIT_TYPEHASH, holder, spender, nonce, expiry, allowed)) + ) + ); + + require(holder != address(0)); + require(holder == ecrecover(digest, v, r, s)); + require(expiry == 0 || block.timestamp <= expiry); + require(nonce == _useNonce(holder)); + + uint256 value = allowed ? type(uint256).max : 0; + + _approve(holder, spender, value); + } } contract TestTokenFactory is BaseScript { diff --git a/test/WakuRlnV2.t.sol b/test/WakuRlnV2.t.sol index e372d96..8552f77 100644 --- a/test/WakuRlnV2.t.sol +++ b/test/WakuRlnV2.t.sol @@ -124,9 +124,14 @@ contract WakuRlnV2Test is Test { function test__ValidRegistrationWithPermit() external { vm.pauseGasMetering(); - uint256 idCommitment = 2; - uint32 membershipRateLimit = w.minMembershipRateLimit(); - (, uint256 price) = w.priceCalculator().calculate(membershipRateLimit); + + MembershipPermitParams memory membership = MembershipPermitParams({ + idCommitment: 2, + rateLimit: w.minMembershipRateLimit(), + idCommitmentsToErase: noIdCommitmentsToErase + }); + + (, uint256 price) = w.priceCalculator().calculate(membership.rateLimit); // Creating an owner for a membership (Alice) uint256 alicePrivK = 0xA11CE; @@ -154,12 +159,55 @@ contract WakuRlnV2Test is Test { vm.resumeGasMetering(); // Call the function on-chain using the generated signature - w.registerWithPermit( - aliceAddr, block.timestamp + 1 hours, v, r, s, idCommitment, membershipRateLimit, noIdCommitmentsToErase + w.registerWithPermit(aliceAddr, block.timestamp + 1 hours, v, r, s, membership); + + (,,,, uint32 fetchedMembershipRateLimit,, address holder,) = w.memberships(membership.idCommitment); + assertEq(fetchedMembershipRateLimit, membership.rateLimit); + assertEq(holder, aliceAddr); + assertEq(token.balanceOf(address(w)), price); + } + + function test__ValidRegistrationWithDAIPermit() external { + vm.pauseGasMetering(); + + MembershipPermitParams memory membership = MembershipPermitParams({ + idCommitment: 2, + rateLimit: w.minMembershipRateLimit(), + idCommitmentsToErase: noIdCommitmentsToErase + }); + + (, uint256 price) = w.priceCalculator().calculate(membership.rateLimit); + + // Creating an owner for a membership (Alice) + uint256 alicePrivK = 0xA11CE; + address aliceAddr = vm.addr(alicePrivK); + + // Minting some tokens so Alice can register a membership + token.mint(aliceAddr, price); + + // Sign the permit hash using the owner's private key + bytes32 permitHash = keccak256( + abi.encode( + keccak256("Permit(address holder,address spender,uint256 nonce,uint256 expiry,bool allowed)"), + aliceAddr, // Owner of the membership + address(w), // Spender (The rln proxy contract) + token.nonces(aliceAddr), + block.timestamp + 1 hours, + true + ) ); - (,,,, uint32 fetchedMembershipRateLimit,, address holder,) = w.memberships(idCommitment); - assertEq(fetchedMembershipRateLimit, membershipRateLimit); + // Sign the permit hash using the owner's private key + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(alicePrivK, ECDSA.toTypedDataHash(token.DAI_DOMAIN_SEPARATOR(), permitHash)); + + vm.resumeGasMetering(); + + // Call the function on-chain using the generated signature + w.registerWithDAIPermit(aliceAddr, block.timestamp + 1 hours, token.nonces(aliceAddr), v, r, s, membership); + + (,,,, uint32 fetchedMembershipRateLimit,, address holder,) = w.memberships(membership.idCommitment); + assertEq(fetchedMembershipRateLimit, membership.rateLimit); assertEq(holder, aliceAddr); assertEq(token.balanceOf(address(w)), price); }