feat: DAI permit

This commit is contained in:
Richard Ramos 2025-06-19 10:00:25 -04:00
parent b7e9a9b1bc
commit 0cb3563ced
No known key found for this signature in database
GPG Key ID: 2F8C6A547B5DB7FD
5 changed files with 231 additions and 16 deletions

16
src/IDAIPermit.sol Normal file
View File

@ -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;
}

View File

@ -2,6 +2,7 @@
pragma solidity 0.8.24; pragma solidity 0.8.24;
import { IPriceCalculator } from "./IPriceCalculator.sol"; import { IPriceCalculator } from "./IPriceCalculator.sol";
import { IDAIPermit } from "./IDAIPermit.sol";
import { IERC20 } from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol"; import { IERC20 } from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import { SafeERC20 } from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol"; import { SafeERC20 } from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";
import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.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); 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 /// @notice Checks if a rate limit is within the allowed bounds
/// @param rateLimit The rate limit /// @param rateLimit The rate limit
/// @return true if the rate limit is within the allowed bounds, false otherwise /// @return true if the rate limit is within the allowed bounds, false otherwise

View File

@ -20,6 +20,15 @@ error InvalidIdCommitment(uint256 idCommitment);
/// Invalid pagination query /// Invalid pagination query
error InvalidPaginationQuery(uint256 startIndex, uint256 endIndex); 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 { contract WakuRlnV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable, MembershipUpgradeable {
/// @notice The Field /// @notice The Field
uint256 public constant Q = uint256 public constant Q =
@ -186,24 +195,25 @@ contract WakuRlnV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable, Member
/// @param v The recovery byte of the signature. /// @param v The recovery byte of the signature.
/// @param r Half of the ECDSA signature pair. /// @param r Half of the ECDSA signature pair.
/// @param s Half of the ECDSA signature pair. /// @param s Half of the ECDSA signature pair.
/// @param idCommitment The idCommitment of the new membership /// @param membership Parameters for 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( function registerWithPermit(
address owner, address owner,
uint256 deadline, uint256 deadline,
uint8 v, uint8 v,
bytes32 r, bytes32 r,
bytes32 s, bytes32 s,
uint256 idCommitment, MembershipPermitParams calldata membership
uint32 rateLimit,
uint256[] calldata idCommitmentsToErase
) )
external external
onlyValidIdCommitment(idCommitment)
noDuplicateMembership(idCommitment)
membershipSetNotFull 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) // erase memberships without overwriting membership set data to zero (save gas)
_eraseMemberships(idCommitmentsToErase, false); _eraseMemberships(idCommitmentsToErase, false);
@ -215,6 +225,45 @@ contract WakuRlnV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable, Member
emit MembershipRegistered(idCommitment, rateLimit, index); 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) /// @dev Register a membership (internal function)
/// @param idCommitment The idCommitment of the membership /// @param idCommitment The idCommitment of the membership
/// @param rateLimit The rate limit of the membership /// @param rateLimit The rate limit of the membership

View File

@ -6,11 +6,54 @@ import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; import { ERC20Permit } from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract TestToken is ERC20, ERC20Permit { 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 { function mint(address to, uint256 amount) public {
_mint(to, amount); _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 { contract TestTokenFactory is BaseScript {

View File

@ -124,9 +124,14 @@ contract WakuRlnV2Test is Test {
function test__ValidRegistrationWithPermit() external { function test__ValidRegistrationWithPermit() external {
vm.pauseGasMetering(); vm.pauseGasMetering();
uint256 idCommitment = 2;
uint32 membershipRateLimit = w.minMembershipRateLimit(); MembershipPermitParams memory membership = MembershipPermitParams({
(, uint256 price) = w.priceCalculator().calculate(membershipRateLimit); idCommitment: 2,
rateLimit: w.minMembershipRateLimit(),
idCommitmentsToErase: noIdCommitmentsToErase
});
(, uint256 price) = w.priceCalculator().calculate(membership.rateLimit);
// Creating an owner for a membership (Alice) // Creating an owner for a membership (Alice)
uint256 alicePrivK = 0xA11CE; uint256 alicePrivK = 0xA11CE;
@ -154,12 +159,55 @@ contract WakuRlnV2Test is Test {
vm.resumeGasMetering(); vm.resumeGasMetering();
// Call the function on-chain using the generated signature // Call the function on-chain using the generated signature
w.registerWithPermit( w.registerWithPermit(aliceAddr, block.timestamp + 1 hours, v, r, s, membership);
aliceAddr, block.timestamp + 1 hours, v, r, s, idCommitment, membershipRateLimit, noIdCommitmentsToErase
(,,,, 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); // Sign the permit hash using the owner's private key
assertEq(fetchedMembershipRateLimit, membershipRateLimit); (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(holder, aliceAddr);
assertEq(token.balanceOf(address(w)), price); assertEq(token.balanceOf(address(w)), price);
} }