diff --git a/src/MetaMorpho.sol b/src/MetaMorpho.sol index 9746434b..b589ee03 100644 --- a/src/MetaMorpho.sol +++ b/src/MetaMorpho.sol @@ -25,8 +25,9 @@ import { Math, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol"; +import {IERC20Metadata, ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; -contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { +contract MetaMorpho is ERC4626, ERC20Permit, Ownable2Step, IMetaMorpho { using Math for uint256; using UtilsLib for uint256; using SafeCast for uint256; @@ -71,6 +72,7 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { constructor(address morpho, uint256 initialTimelock, address _asset, string memory _name, string memory _symbol) ERC4626(IERC20(_asset)) + ERC20Permit(_name) ERC20(_name, _symbol) { require(initialTimelock <= MAX_TIMELOCK, ErrorsLib.MAX_TIMELOCK_EXCEEDED); @@ -258,6 +260,10 @@ contract MetaMorpho is ERC4626, Ownable2Step, IMetaMorpho { /* ERC4626 (PUBLIC) */ + function decimals() public view override(IERC20Metadata, ERC20, ERC4626) returns (uint8) { + return ERC4626.decimals(); + } + function maxWithdraw(address owner) public view override(IERC4626, ERC4626) returns (uint256 assets) { (assets,) = _maxWithdraw(owner); } diff --git a/test/forge/PermitTest.sol b/test/forge/PermitTest.sol new file mode 100644 index 00000000..ce7028a0 --- /dev/null +++ b/test/forge/PermitTest.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +import "./helpers/SigUtils.sol"; +import "./helpers/BaseTest.sol"; + +contract PermitTest is BaseTest { + SigUtils internal sigUtils; + + uint256 internal constant OWNER_PK = 0xA11CE; + uint256 internal constant SPENDER_PK = 0xB0B; + + address internal owner; + address internal spender; + + function setUp() public override { + super.setUp(); + + sigUtils = new SigUtils(vault.DOMAIN_SEPARATOR()); + + owner = vm.addr(OWNER_PK); + spender = vm.addr(SPENDER_PK); + + deal(address(vault), owner, 1e18); + } + + function testPermit() public { + Permit memory permit = Permit({owner: owner, spender: spender, value: 1e18, nonce: 0, deadline: 1 days}); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(OWNER_PK, digest); + + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + + assertEq(vault.allowance(owner, spender), 1e18); + assertEq(vault.nonces(owner), 1); + } + + function testRevertExpiredPermit() public { + Permit memory permit = + Permit({owner: owner, spender: spender, value: 1e18, nonce: vault.nonces(owner), deadline: 1 days}); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(OWNER_PK, digest); + + vm.warp(1 days + 1 seconds); // fast forward one second past the deadline + + vm.expectRevert("ERC20Permit: expired deadline"); + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + } + + function testRevertInvalidSigner() public { + Permit memory permit = + Permit({owner: owner, spender: spender, value: 1e18, nonce: vault.nonces(owner), deadline: 1 days}); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(SPENDER_PK, digest); // spender signs owner's approval + + vm.expectRevert("ERC20Permit: invalid signature"); + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + } + + function testRevertInvalidNonce() public { + Permit memory permit = Permit({ + owner: owner, + spender: spender, + value: 1e18, + nonce: 1, // owner nonce stored on-chain is 0 + deadline: 1 days + }); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(OWNER_PK, digest); + + vm.expectRevert("ERC20Permit: invalid signature"); + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + } + + function testRevertSignatureReplay() public { + Permit memory permit = Permit({owner: owner, spender: spender, value: 1e18, nonce: 0, deadline: 1 days}); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(OWNER_PK, digest); + + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + + vm.expectRevert("ERC20Permit: invalid signature"); + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + } + + function testTransferFromLimitedPermit() public { + Permit memory permit = Permit({owner: owner, spender: spender, value: 1e18, nonce: 0, deadline: 1 days}); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(OWNER_PK, digest); + + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + + vm.prank(spender); + vault.transferFrom(owner, spender, 1e18); + + assertEq(vault.balanceOf(owner), 0); + assertEq(vault.balanceOf(spender), 1e18); + assertEq(vault.allowance(owner, spender), 0); + } + + function testTransferFromMaxPermit() public { + Permit memory permit = + Permit({owner: owner, spender: spender, value: type(uint256).max, nonce: 0, deadline: 1 days}); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(OWNER_PK, digest); + + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + + vm.prank(spender); + vault.transferFrom(owner, spender, 1e18); + + assertEq(vault.balanceOf(owner), 0); + assertEq(vault.balanceOf(spender), 1e18); + assertEq(vault.allowance(owner, spender), type(uint256).max); + } + + function testFailInvalidAllowance() public { + Permit memory permit = Permit({ + owner: owner, + spender: spender, + value: 5e17, // approve only 0.5 tokens + nonce: 0, + deadline: 1 days + }); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(OWNER_PK, digest); + + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + + vm.prank(spender); + vault.transferFrom(owner, spender, 1e18); // attempt to transfer 1 vault + } + + function testFailInvalidBalance() public { + Permit memory permit = Permit({ + owner: owner, + spender: spender, + value: 2e18, // approve 2 tokens + nonce: 0, + deadline: 1 days + }); + + bytes32 digest = sigUtils.getTypedDataHash(permit); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(OWNER_PK, digest); + + vault.permit(permit.owner, permit.spender, permit.value, permit.deadline, v, r, s); + + vm.prank(spender); + vault.transferFrom(owner, spender, 2e18); // attempt to transfer 2 tokens (owner only owns 1) + } +} diff --git a/test/forge/helpers/BaseTest.sol b/test/forge/helpers/BaseTest.sol index 3b805dc9..80ba6e89 100644 --- a/test/forge/helpers/BaseTest.sol +++ b/test/forge/helpers/BaseTest.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.0; import "@morpho-blue/interfaces/IMorpho.sol"; -import {IOracle} from "@morpho-blue/interfaces/IOracle.sol"; import {MarketParamsLib} from "@morpho-blue/libraries/MarketParamsLib.sol"; import {MorphoLib} from "@morpho-blue/libraries/periphery/MorphoLib.sol"; diff --git a/test/forge/helpers/SigUtils.sol b/test/forge/helpers/SigUtils.sol new file mode 100644 index 00000000..11d90252 --- /dev/null +++ b/test/forge/helpers/SigUtils.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.0; + +struct Permit { + address owner; + address spender; + uint256 value; + uint256 nonce; + uint256 deadline; +} + +// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"); +bytes32 constant PERMIT_TYPEHASH = 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + +contract SigUtils { + bytes32 internal DOMAIN_SEPARATOR; + + constructor(bytes32 _DOMAIN_SEPARATOR) { + DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR; + } + + // computes the hash of a permit + function getStructHash(Permit memory _permit) internal pure returns (bytes32) { + return keccak256( + abi.encode(PERMIT_TYPEHASH, _permit.owner, _permit.spender, _permit.value, _permit.nonce, _permit.deadline) + ); + } + + // computes the hash of the fully encoded EIP-712 message for the domain, which can be used to recover the signer + function getTypedDataHash(Permit memory _permit) public view returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR, getStructHash(_permit))); + } +}