Skip to content

Commit

Permalink
Implement, test, and document permit-based stake & stakeMore methods
Browse files Browse the repository at this point in the history
  • Loading branch information
apbendi committed Feb 8, 2024
1 parent b278fd8 commit 888f501
Show file tree
Hide file tree
Showing 4 changed files with 327 additions and 7 deletions.
54 changes: 54 additions & 0 deletions src/UniStaker.sol
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,33 @@ contract UniStaker is INotifiableRewardReceiver, ReentrancyGuard, Multicall, EIP
_depositId = _stake(msg.sender, _amount, _delegatee, _beneficiary);
}

/// @notice Method to stake tokens to a new deposit. The caller must approve the staking
/// contract to spend at least the would-be staked amount of the token via a signature which is
/// is also provided, and is passed to the token contract's permit method before the staking
/// operation occurs.
/// @param _amount Quantity of the staking token to stake.
/// @param _delegatee Address to assign the governance voting weight of the staked tokens.
/// @param _beneficiary Address that will accrue rewards for this stake.
/// @param _deadline The timestamp at which the permit signature should expire.
/// @param _v ECDSA signature component: Parity of the `y` coordinate of point `R`
/// @param _r ECDSA signature component: x-coordinate of `R`
/// @param _s ECDSA signature component: `s` value of the signature
/// @return _depositId Unique identifier for this deposit.
/// @dev Neither the delegatee nor the beneficiary may be the zero address. The deposit will be
/// owned by the message sender.
function permitAndStake(
uint256 _amount,
address _delegatee,
address _beneficiary,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external nonReentrant returns (DepositIdentifier _depositId) {
STAKE_TOKEN.permit(msg.sender, address(this), _amount, _deadline, _v, _r, _s);
_depositId = _stake(msg.sender, _amount, _delegatee, _beneficiary);
}

/// @notice Stake tokens to a new deposit on behalf of a user, using a signature to validate the
/// user's intent. The caller must pre-approve the staking contract to spend at least the
/// would-be staked amount of the token.
Expand Down Expand Up @@ -318,6 +345,33 @@ contract UniStaker is INotifiableRewardReceiver, ReentrancyGuard, Multicall, EIP
_stakeMore(deposit, _depositId, _amount);
}

/// @notice Add more staking tokens to an existing deposit. A staker should call this method when
/// they have an existing deposit, and wish to stake more while retaining the same delegatee and
/// beneficiary. The caller must approve the staking contract to spend at least the would-be
/// staked amount of the token via a signature which is is also provided, and is passed to the
/// token contract's permit method before the staking operation occurs.
/// @param _depositId Unique identifier of the deposit to which stake will be added.
/// @param _amount Quantity of stake to be added.
/// @param _deadline The timestamp at which the permit signature should expire.
/// @param _v ECDSA signature component: Parity of the `y` coordinate of point `R`
/// @param _r ECDSA signature component: x-coordinate of `R`
/// @param _s ECDSA signature component: `s` value of the signature
/// @dev The message sender must be the owner of the deposit.
function permitAndStakeMore(
DepositIdentifier _depositId,
uint256 _amount,
uint256 _deadline,
uint8 _v,
bytes32 _r,
bytes32 _s
) external nonReentrant {
Deposit storage deposit = deposits[_depositId];
_revertIfNotDepositOwner(deposit, msg.sender);

STAKE_TOKEN.permit(msg.sender, address(this), _amount, _deadline, _v, _r, _s);
_stakeMore(deposit, _depositId, _amount);
}

/// @notice Add more staking tokens to an existing deposit on behalf of a user, using a signature
/// to validate the user's intent. A staker should call this method when they have an existing
/// deposit, and wish to stake more while retaining the same delegatee and beneficiary.
Expand Down
9 changes: 9 additions & 0 deletions src/interfaces/IERC20Delegates.sol
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ interface IERC20Delegates {
function totalSupply() external view returns (uint256);
function transfer(address dst, uint256 rawAmount) external returns (bool);
function transferFrom(address src, address dst, uint256 rawAmount) external returns (bool);
function permit(
address owner,
address spender,
uint256 rawAmount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external;

// ERC20Votes delegation methods
function delegate(address delegatee) external;
Expand Down
246 changes: 245 additions & 1 deletion test/UniStaker.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ pragma solidity 0.8.23;
import {Vm, Test, stdStorage, StdStorage, console2} from "forge-std/Test.sol";
import {UniStaker, DelegationSurrogate, IERC20, IERC20Delegates} from "src/UniStaker.sol";
import {UniStakerHarness} from "test/harnesses/UniStakerHarness.sol";
import {ERC20VotesMock} from "test/mocks/MockERC20Votes.sol";
import {ERC20VotesMock, ERC20Permit} from "test/mocks/MockERC20Votes.sol";
import {ERC20Fake} from "test/fakes/ERC20Fake.sol";

contract UniStakerTest is Test {
Expand All @@ -21,6 +21,9 @@ contract UniStakerTest is Test {
)
);

bytes32 constant PERMIT_TYPEHASH =
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

event RewardNotifierSet(address indexed account, bool isEnabled);
event AdminSet(address indexed oldAdmin, address indexed newAdmin);

Expand Down Expand Up @@ -706,6 +709,93 @@ contract Stake is UniStakerTest {
}
}

contract PermitAndStake is UniStakerTest {
using stdStorage for StdStorage;

function testFuzz_PerformsTheApprovalByCallingPermitThenPerformsStake(
uint256 _depositorPrivateKey,
uint256 _depositAmount,
address _delegatee,
address _beneficiary,
uint256 _deadline,
uint256 _currentNonce
) public {
vm.assume(_delegatee != address(0) && _beneficiary != address(0));
_deadline = bound(_deadline, block.timestamp + 1, type(uint256).max);
_depositorPrivateKey = bound(_depositorPrivateKey, 1, 100e18);
address _depositor = vm.addr(_depositorPrivateKey);
_depositAmount = _boundMintAmount(_depositAmount);
_mintGovToken(_depositor, _depositAmount);

stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write(
_currentNonce
);

bytes32 _message = keccak256(
abi.encode(
PERMIT_TYPEHASH,
_depositor,
address(uniStaker),
_depositAmount,
govToken.nonces(_depositor),
_deadline
)
);

bytes32 _messageHash =
keccak256(abi.encodePacked("\x19\x01", govToken.DOMAIN_SEPARATOR(), _message));
(uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(_depositorPrivateKey, _messageHash);

vm.prank(_depositor);
UniStaker.DepositIdentifier _depositId =
uniStaker.permitAndStake(_depositAmount, _delegatee, _beneficiary, _deadline, _v, _r, _s);
UniStaker.Deposit memory _deposit = _fetchDeposit(_depositId);

assertEq(_deposit.balance, _depositAmount);
assertEq(_deposit.owner, _depositor);
assertEq(_deposit.delegatee, _delegatee);
assertEq(_deposit.beneficiary, _beneficiary);
}

function testFuzz_RevertIf_ThePermitSignatureIsInvalid(
address _notDepositor,
uint256 _depositorPrivateKey,
uint256 _depositAmount,
address _delegatee,
address _beneficiary,
uint256 _deadline
) public {
vm.assume(_delegatee != address(0) && _beneficiary != address(0));
_deadline = bound(_deadline, block.timestamp + 1, type(uint256).max);
_depositorPrivateKey = bound(_depositorPrivateKey, 1, 100e18);
address _depositor = vm.addr(_depositorPrivateKey);
vm.assume(_notDepositor != _depositor);
_depositAmount = _boundMintAmount(_depositAmount);
_mintGovToken(_depositor, _depositAmount);

bytes32 _message = keccak256(
abi.encode(
PERMIT_TYPEHASH,
_notDepositor,
address(uniStaker),
_depositAmount,
govToken.nonces(_depositor),
_deadline
)
);

bytes32 _messageHash =
keccak256(abi.encodePacked("\x19\x01", govToken.DOMAIN_SEPARATOR(), _message));
(uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(_depositorPrivateKey, _messageHash);

vm.prank(_notDepositor);
vm.expectRevert(
abi.encodeWithSelector(ERC20Permit.ERC2612InvalidSigner.selector, _depositor, _notDepositor)
);
uniStaker.permitAndStake(_depositAmount, _delegatee, _beneficiary, _deadline, _v, _r, _s);
}
}

contract StakeOnBehalf is UniStakerTest {
using stdStorage for StdStorage;

Expand Down Expand Up @@ -1052,6 +1142,160 @@ contract StakeMore is UniStakerTest {
}
}

contract PermitAndStakeMore is UniStakerTest {
using stdStorage for StdStorage;

function testFuzz_PerformsTheApprovalByCallingPermitThenPerformsStakeMore(
uint256 _depositorPrivateKey,
uint256 _initialDepositAmount,
uint256 _stakeMoreAmount,
address _delegatee,
address _beneficiary,
uint256 _currentNonce,
uint256 _deadline
) public {
vm.assume(_delegatee != address(0) && _beneficiary != address(0));
_depositorPrivateKey = bound(_depositorPrivateKey, 1, 100e18);
address _depositor = vm.addr(_depositorPrivateKey);
_deadline = bound(_deadline, block.timestamp + 1, type(uint256).max);

UniStaker.DepositIdentifier _depositId;
(_initialDepositAmount, _depositId) =
_boundMintAndStake(_depositor, _initialDepositAmount, _delegatee, _beneficiary);

_stakeMoreAmount = _boundToRealisticStake(_stakeMoreAmount);
_mintGovToken(_depositor, _stakeMoreAmount);

stdstore.target(address(govToken)).sig("nonces(address)").with_key(_depositor).checked_write(
_currentNonce
);

// Separate scope to avoid stack to deep errors
{
bytes32 _message = keccak256(
abi.encode(
PERMIT_TYPEHASH,
_depositor,
address(uniStaker),
_stakeMoreAmount,
govToken.nonces(_depositor),
_deadline
)
);

bytes32 _messageHash =
keccak256(abi.encodePacked("\x19\x01", govToken.DOMAIN_SEPARATOR(), _message));
(uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(_depositorPrivateKey, _messageHash);

vm.prank(_depositor);
uniStaker.permitAndStakeMore(_depositId, _stakeMoreAmount, _deadline, _v, _r, _s);
}

UniStaker.Deposit memory _deposit = _fetchDeposit(_depositId);

assertEq(_deposit.balance, _initialDepositAmount + _stakeMoreAmount);
assertEq(_deposit.owner, _depositor);
assertEq(_deposit.delegatee, _delegatee);
assertEq(_deposit.beneficiary, _beneficiary);
}

function testFuzz_RevertIf_CallerIsNotTheDepositOwner(
address _depositor,
uint256 _notDepositorPrivateKey,
uint256 _initialDepositAmount,
uint256 _stakeMoreAmount,
address _delegatee,
address _beneficiary,
uint256 _deadline
) public {
vm.assume(_delegatee != address(0) && _beneficiary != address(0));
_notDepositorPrivateKey = bound(_notDepositorPrivateKey, 1, 100e18);
address _notDepositor = vm.addr(_notDepositorPrivateKey);
vm.assume(_depositor != _notDepositor);
_deadline = bound(_deadline, block.timestamp + 1, type(uint256).max);

UniStaker.DepositIdentifier _depositId;
(_initialDepositAmount, _depositId) =
_boundMintAndStake(_depositor, _initialDepositAmount, _delegatee, _beneficiary);

_stakeMoreAmount = _boundToRealisticStake(_stakeMoreAmount);
_mintGovToken(_depositor, _stakeMoreAmount);

// Separate scope to avoid stack to deep errors
{
bytes32 _message = keccak256(
abi.encode(
PERMIT_TYPEHASH,
_notDepositor,
address(uniStaker),
_stakeMoreAmount,
govToken.nonces(_depositor),
_deadline
)
);

bytes32 _messageHash =
keccak256(abi.encodePacked("\x19\x01", govToken.DOMAIN_SEPARATOR(), _message));
(uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(_notDepositorPrivateKey, _messageHash);

vm.prank(_notDepositor);
vm.expectRevert(
abi.encodeWithSelector(
UniStaker.UniStaker__Unauthorized.selector, bytes32("not owner"), _notDepositor
)
);
uniStaker.permitAndStakeMore(_depositId, _stakeMoreAmount, _deadline, _v, _r, _s);
}
}

function testFuzz_RevertIf_ThePermitSignatureIsInvalid(
uint256 _initialDepositAmount,
address _delegatee,
address _beneficiary
) public {
vm.assume(_delegatee != address(0) && _beneficiary != address(0));

// We can't fuzz the these values because we need to pre-compute the invalid
// recovered signer so we can expect it in the revert error message thrown
(address _depositor, uint256 _depositorPrivateKey) = makeAddrAndKey("depositor");
uint256 _stakeMoreAmount = 1578e18;
uint256 _deadline = 1e18 days;
uint256 _wrongNonce = 1;
// If any of the values defined above are changed, the expected recovered address must also
// be recalculated and updated.
address _expectedRecoveredSigner = address(0xF03C6C880C40b5698e466C136C460ea71A0C5E33);

UniStaker.DepositIdentifier _depositId;
(_initialDepositAmount, _depositId) =
_boundMintAndStake(_depositor, _initialDepositAmount, _delegatee, _beneficiary);
_mintGovToken(_depositor, _stakeMoreAmount);

bytes32 _message = keccak256(
abi.encode(
PERMIT_TYPEHASH,
_depositor,
address(uniStaker),
_stakeMoreAmount,
_wrongNonce, // intentionally incorrect nonce, which should be 0
_deadline
)
);

bytes32 _messageHash =
keccak256(abi.encodePacked("\x19\x01", govToken.DOMAIN_SEPARATOR(), _message));

(uint8 _v, bytes32 _r, bytes32 _s) = vm.sign(_depositorPrivateKey, _messageHash);

vm.prank(_depositor);
vm.expectRevert(
abi.encodeWithSelector(
ERC20Permit.ERC2612InvalidSigner.selector, _expectedRecoveredSigner, _depositor
)
);
uniStaker.permitAndStakeMore(_depositId, _stakeMoreAmount, _deadline, _v, _r, _s);
}
}

contract StakeMoreOnBehalf is UniStakerTest {
using stdStorage for StdStorage;

Expand Down
25 changes: 19 additions & 6 deletions test/mocks/MockERC20Votes.sol
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity 0.8.23;

import {ERC20} from "openzeppelin/token/ERC20/ERC20.sol";
import {IERC20Delegates} from "src/interfaces/IERC20Delegates.sol";
import {ERC20, ERC20Permit} from "openzeppelin/token/ERC20/extensions/ERC20Permit.sol";

/// @dev An ERC20 token that allows for public minting and mocks the delegation methods used in
/// ERC20Votes governance tokens. It does not included check pointing functionality. This contract
/// is intended only for use as a stand in for contracts that interface with ERC20Votes tokens.
contract ERC20VotesMock is IERC20Delegates, ERC20 {
/// @dev An ERC20Permit token that allows for public minting and mocks the delegation methods used
/// in ERC20Votes governance tokens. It does not included check pointing functionality. This
/// contract is intended only for use as a stand in for contracts that interface with ERC20Votes
// tokens.
contract ERC20VotesMock is IERC20Delegates, ERC20Permit {
/// @dev Track delegations for mocked delegation methods
mapping(address account => address delegate) private delegations;

constructor() ERC20("Governance Token", "GOV") {}
constructor() ERC20("Governance Token", "GOV") ERC20Permit("Governance Token") {}

/// @dev Public mint function useful for testing
function mint(address _account, uint256 _value) public {
Expand Down Expand Up @@ -88,4 +89,16 @@ contract ERC20VotesMock is IERC20Delegates, ERC20 {
{
return ERC20.transferFrom(src, dst, rawAmount);
}

function permit(
address owner,
address spender,
uint256 rawAmount,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public override(IERC20Delegates, ERC20Permit) {
return ERC20Permit.permit(owner, spender, rawAmount, deadline, v, r, s);
}
}

0 comments on commit 888f501

Please sign in to comment.