diff --git a/src/Membership.sol b/src/Membership.sol index 222549e..0ef6939 100644 --- a/src/Membership.sol +++ b/src/Membership.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.24; import { IPriceCalculator } from "./IPriceCalculator.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"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; // The rate limit is outside the expected limits @@ -208,6 +209,63 @@ abstract contract MembershipUpgradeable is Initializable { IERC20(token).safeTransferFrom(_sender, address(this), depositAmount); } + /// @dev acquire a membership and transfer the deposit to the contract + /// Uses the RC20 Permit extension allowing approvals to be made via signatures, as defined in + /// [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612). + /// @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 _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 _acquireMembershipWithPermit( + address _owner, + uint256 _deadline, + 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); + + ERC20Permit(token).permit(_owner, address(this), depositAmount, _deadline, _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 @@ -233,7 +291,7 @@ abstract contract MembershipUpgradeable is Initializable { /// @dev Extend a grace-period membership /// @param _sender the address of the transaction sender /// @param _idCommitment the idCommitment of the membership - function _extendMembership(address _sender, uint256 _idCommitment) public { + function _extendMembership(address _sender, uint256 _idCommitment) internal { MembershipInfo storage membership = memberships[_idCommitment]; if (!_isInPeriod(membership.gracePeriodStartTimestamp, membership.gracePeriodDuration)) { diff --git a/src/WakuRlnV2.sol b/src/WakuRlnV2.sol index 7b831ef..e984de1 100644 --- a/src/WakuRlnV2.sol +++ b/src/WakuRlnV2.sol @@ -170,14 +170,55 @@ contract WakuRlnV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable, Member { // erase memberships without overwriting membership set data to zero (save gas) _eraseMemberships(idCommitmentsToErase, false); - _register(idCommitment, rateLimit); + + (uint32 index, bool indexReused) = _acquireMembership(_msgSender(), idCommitment, rateLimit); + + _upsertInTree(idCommitment, rateLimit, index, indexReused); + + emit MembershipRegistered(idCommitment, rateLimit, index); + } + + /// @notice Register a membership while erasing some expired memberships to reuse their rate limit. + /// Uses the RC20 Permit extension allowing approvals to be made via signatures, as defined in + /// [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612). + /// @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 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 + function registerWithPermit( + address owner, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s, + uint256 idCommitment, + uint32 rateLimit, + uint256[] calldata idCommitmentsToErase + ) + external + onlyValidIdCommitment(idCommitment) + noDuplicateMembership(idCommitment) + membershipSetNotFull + { + // erase memberships without overwriting membership set data to zero (save gas) + _eraseMemberships(idCommitmentsToErase, false); + + (uint32 index, bool indexReused) = + _acquireMembershipWithPermit(owner, deadline, 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 - function _register(uint256 idCommitment, uint32 rateLimit) internal { - (uint32 index, bool indexReused) = _acquireMembership(_msgSender(), idCommitment, rateLimit); + function _upsertInTree(uint256 idCommitment, uint32 rateLimit, uint32 index, bool indexReused) internal { uint256 rateCommitment = PoseidonT3.hash([idCommitment, rateLimit]); if (indexReused) { LazyIMT.update(merkleTree, rateCommitment, index); @@ -185,8 +226,6 @@ contract WakuRlnV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable, Member LazyIMT.insert(merkleTree, rateCommitment); nextFreeIndex += 1; } - - emit MembershipRegistered(idCommitment, rateLimit, index); } /// @notice Returns the root of the Merkle tree that stores rate commitments of memberships diff --git a/test/TestToken.sol b/test/TestToken.sol index 51ac926..c259884 100644 --- a/test/TestToken.sol +++ b/test/TestToken.sol @@ -3,9 +3,10 @@ pragma solidity >=0.8.19 <0.9.0; import { BaseScript } from "../script/Base.s.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; -contract TestToken is ERC20 { - constructor() ERC20("TestToken", "TTT") { } +contract TestToken is ERC20, ERC20Permit { + constructor() ERC20("TestToken", "TTT") ERC20Permit("TestToken") { } function mint(address to, uint256 amount) public { _mint(to, amount); diff --git a/test/WakuRlnV2.t.sol b/test/WakuRlnV2.t.sol index 7cd54b9..e372d96 100644 --- a/test/WakuRlnV2.t.sol +++ b/test/WakuRlnV2.t.sol @@ -11,6 +11,7 @@ import { TestToken } from "./TestToken.sol"; import { PoseidonT3 } from "poseidon-solidity/PoseidonT3.sol"; import { UUPSUpgradeable } from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; // For signature manipulation import "forge-std/console.sol"; contract WakuRlnV2Test is Test { @@ -121,6 +122,48 @@ contract WakuRlnV2Test is Test { assertEq(w.currentTotalRateLimit(), membershipRateLimit); } + function test__ValidRegistrationWithPermit() external { + vm.pauseGasMetering(); + uint256 idCommitment = 2; + uint32 membershipRateLimit = w.minMembershipRateLimit(); + (, uint256 price) = w.priceCalculator().calculate(membershipRateLimit); + + // 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); + + // Prepare the permit parameters + bytes32 permitHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + aliceAddr, // Owner of the membership + address(w), // Spender (The rln proxy contract) + price, + token.nonces(aliceAddr), + block.timestamp + 1 hours // Deadline + ) + ); + + // Sign the permit hash using the owner's private key + (uint8 v, bytes32 r, bytes32 s) = + vm.sign(alicePrivK, ECDSA.toTypedDataHash(token.DOMAIN_SEPARATOR(), permitHash)); + + vm.resumeGasMetering(); + + // Call the function on-chain using the generated signature + w.registerWithPermit( + aliceAddr, block.timestamp + 1 hours, v, r, s, idCommitment, membershipRateLimit, noIdCommitmentsToErase + ); + + (,,,, uint32 fetchedMembershipRateLimit,, address holder,) = w.memberships(idCommitment); + assertEq(fetchedMembershipRateLimit, membershipRateLimit); + assertEq(holder, aliceAddr); + assertEq(token.balanceOf(address(w)), price); + } + function test__LinearPriceCalculation(uint32 membershipRateLimit) external view { IPriceCalculator priceCalculator = w.priceCalculator(); uint256 pricePerMessagePerPeriod = LinearPriceCalculator(address(priceCalculator)).pricePerMessagePerEpoch();