diff --git a/README.md b/README.md index 751af15..ae491fc 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,10 @@ It is comparable to the way that Wrapped Ether (WETH) acts as a wrapper around ETH, providing users with the ability to make use of ETH in any context that requires an ERC20-compatible token. +The wrapper token comes with [EIP-2612](https://eips.ethereum.org/EIPS/eip-2612) +and [EIP-1271](https://eips.ethereum.org/EIPS/eip-1271) support to allow both EOAs +and smart contracts to approve and encumber gaslessly using off-chain signatures. + ## Limitations ### Rebasing tokens diff --git a/src/EncumberableToken.sol b/src/EncumberableToken.sol index e8112b5..a77d02a 100644 --- a/src/EncumberableToken.sol +++ b/src/EncumberableToken.sol @@ -31,6 +31,10 @@ contract EncumberableToken is ERC20, IERC20Permit, IERC7246 { /// @dev The EIP-712 typehash for the contract's domain bytes32 internal constant DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + /// @dev The magic value that a contract's `isValidSignature(bytes32 hash, bytes signature)` function should return for a valid signature + /// See https://eips.ethereum.org/EIPS/eip-1271 + bytes4 internal constant EIP1271_MAGIC_VALUE = 0x1626ba7e; + /// @notice Number of decimals used for the user represenation of the token uint8 private immutable _decimals; @@ -243,18 +247,16 @@ contract EncumberableToken is ERC20, IERC20Permit, IERC7246 { bytes32 r, bytes32 s ) external { - require(uint256(s) <= MAX_VALID_ECDSA_S, "Invalid value s"); - // v ∈ {27, 28} (source: https://ethereum.github.io/yellowpaper/paper.pdf #308) - require(v == 27 || v == 28, 'Invalid value v'); + require(block.timestamp < expiry, "Signature expired"); uint nonce = nonces[owner]; bytes32 structHash = keccak256(abi.encode(AUTHORIZATION_TYPEHASH, owner, spender, amount, nonce, expiry)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); - address signatory = ecrecover(digest, v, r, s); - require(signatory != address(0), 'Bad signatory'); - require(owner == signatory, 'Bad signatory'); - require(block.timestamp < expiry, 'Signature expired'); - nonces[signatory]++; - _approve(owner, spender, amount); + if (isValidSignature(owner, digest, v, r, s)) { + nonces[owner]++; + _approve(owner, spender, amount); + } else { + revert("Bad signatory"); + } } /** @@ -276,18 +278,63 @@ contract EncumberableToken is ERC20, IERC20Permit, IERC7246 { bytes32 r, bytes32 s ) external { - require(uint256(s) <= MAX_VALID_ECDSA_S, "Invalid value s"); - // v ∈ {27, 28} (source: https://ethereum.github.io/yellowpaper/paper.pdf #308) - require(v == 27 || v == 28, 'Invalid value v'); + require(block.timestamp < expiry, "Signature expired"); uint nonce = nonces[owner]; bytes32 structHash = keccak256(abi.encode(ENCUMBER_TYPEHASH, owner, taker, amount, nonce, expiry)); bytes32 digest = keccak256(abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)); - address signatory = ecrecover(digest, v, r, s); - require(signatory != address(0), 'Bad signatory'); - require(owner == signatory, 'Bad signatory'); - require(block.timestamp < expiry, 'Signature expired'); - nonces[signatory]++; - _encumber(owner, taker, amount); + if (isValidSignature(owner, digest, v, r, s)) { + nonces[owner]++; + _encumber(owner, taker, amount); + } else { + revert("Bad signatory"); + } + } + + /** + * @notice Checks if a signature is valid + * @dev Supports EIP-1271 signatures for smart contracts + * @param signer The address that signed the signature + * @param digest The hashed message that is signed + * @param v The recovery byte of the signature + * @param r Half of the ECDSA signature pair + * @param s Half of the ECDSA signature pair + * @return bool Whether the signature is valid + */ + function isValidSignature( + address signer, + bytes32 digest, + uint8 v, + bytes32 r, + bytes32 s + ) internal view returns (bool) { + if (hasCode(signer)) { + bytes memory signature = abi.encodePacked(r, s, v); + (bool success, bytes memory data) = signer.staticcall( + abi.encodeWithSelector(EIP1271_MAGIC_VALUE, digest, signature) + ); + require(success == true, "Call to verify EIP1271 signature failed"); + bytes4 returnValue = abi.decode(data, (bytes4)); + return returnValue == EIP1271_MAGIC_VALUE; + } else { + require(uint256(s) <= MAX_VALID_ECDSA_S, "Invalid value s"); + // v ∈ {27, 28} (source: https://ethereum.github.io/yellowpaper/paper.pdf #308) + require(v == 27 || v == 28, "Invalid value v"); + address signatory = ecrecover(digest, v, r, s); + require(signatory != address(0), "Bad signatory"); + require(signatory == signer, "Bad signatory"); + return true; + } + } + + /** + * @notice Checks if an address has code deployed to it + * @param addr The address to check + * @return bool Whether the address contains code + */ + function hasCode(address addr) internal view returns (bool) { + uint256 size; + assembly { size := extcodesize(addr) } + return size > 0; } /** diff --git a/src/test/EIP1271Signer.sol b/src/test/EIP1271Signer.sol new file mode 100644 index 0000000..bfe924d --- /dev/null +++ b/src/test/EIP1271Signer.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.15; + +contract EIP1271Signer { + bytes4 internal constant EIP1271_MAGIC_VALUE = 0x1626ba7e; + + address public owner; + + constructor(address _owner) { + owner = _owner; + } + + function isValidSignature(bytes32 messageHash, bytes memory signature) external view returns (bytes4) { + if (recoverSigner(messageHash, signature) == owner) { + return EIP1271_MAGIC_VALUE; + } else { + return 0xffffffff; + } + } + + function recoverSigner(bytes32 messageHash, bytes memory signature) internal pure returns (address) { + require(signature.length == 65, "SignatureValidator#recoverSigner: invalid signature length"); + + bytes32 r; + bytes32 s; + uint8 v; + assembly { + r := mload(add(signature, 32)) + s := mload(add(signature, 64)) + v := and(mload(add(signature, 65)), 255) + } + + // EIP-2 still allows signature malleability for ecrecover(). Remove this possibility and make the signature + // unique. Appendix F in the Ethereum Yellow paper (https://ethereum.github.io/yellowpaper/paper.pdf), defines + // the valid range for s in (281): 0 < s < secp256k1n ÷ 2 + 1, and for v in (282): v ∈ {27, 28}. Most + // signatures from current libraries generate a unique signature with an s-value in the lower half order. + // + // If your library generates malleable signatures, such as s-values in the upper range, calculate a new s-value + // with 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 - s1 and flip v from 27 to 28 or + // vice versa. If your library also generates signatures with 0/1 for v instead 27/28, add 27 to v to accept + // these malleable signatures as well. + // + // Source OpenZeppelin + // https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/utils/cryptography/ECDSA.sol + + if (uint256(s) > 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0) { + revert("SignatureValidator#recoverSigner: invalid signature 's' value"); + } + + if (v != 27 && v != 28) { + revert("SignatureValidator#recoverSigner: invalid signature 'v' value"); + } + + // Recover ECDSA signer + address signer = ecrecover(messageHash, v, r, s); + + // Prevent signer from being 0x0 + require( + signer != address(0x0), + "SignatureValidator#recoverSigner: INVALID_SIGNER" + ); + + return signer; + } +} \ No newline at end of file diff --git a/test/EncumberBySig.t.sol b/test/EncumberBySig.t.sol index d6e4df8..9d7813d 100644 --- a/test/EncumberBySig.t.sol +++ b/test/EncumberBySig.t.sol @@ -5,6 +5,7 @@ import "forge-std/StdUtils.sol"; import "../src/vendor/ERC20.sol"; import "../src/vendor/IERC20Metadata.sol"; import "../src/EncumberableToken.sol"; +import "../src/test/EIP1271Signer.sol"; contract EncumberBySigTest is Test { ERC20 public underlyingToken; @@ -12,16 +13,18 @@ contract EncumberBySigTest is Test { uint256 alicePrivateKey = 0xa11ce; address alice; // see setup() + address aliceContract; // contract that can verify EIP1271 signatures address bob = address(11); address charlie = address(12); bytes32 internal constant ENCUMBER_TYPEHASH = keccak256("Encumber(address owner,address taker,uint256 amount,uint256 nonce,uint256 expiry)"); function setUp() public { + alice = vm.addr(alicePrivateKey); + underlyingToken = new ERC20("TEST TOKEN", "TTKN"); wrappedToken = new EncumberableToken(address(underlyingToken)); - - alice = vm.addr(alicePrivateKey); + aliceContract = address(new EIP1271Signer(alice)); } function aliceAuthorization(uint256 amount, uint256 nonce, uint256 expiry) internal view returns (uint8, bytes32, bytes32) { @@ -30,6 +33,12 @@ contract EncumberBySigTest is Test { return vm.sign(alicePrivateKey, digest); } + function aliceContractAuthorization(uint256 amount, uint256 nonce, uint256 expiry) internal view returns (uint8, bytes32, bytes32) { + bytes32 structHash = keccak256(abi.encode(ENCUMBER_TYPEHASH, aliceContract, bob, amount, nonce, expiry)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", wrappedToken.DOMAIN_SEPARATOR(), structHash)); + return vm.sign(alicePrivateKey, digest); + } + function testEncumberBySig() public { uint256 aliceBalance = 100e18; uint256 encumbranceAmount = 60e18; @@ -379,4 +388,324 @@ contract EncumberBySigTest is Test { // alice's nonce is not incremented assertEq(wrappedToken.nonces(alice), nonce); } + + /* ===== EIP1271 Tests ===== */ + + function testEncumberBySigEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(encumbranceAmount, nonce, expiry); + + // bob calls encumberBySig with the signature + vm.prank(bob); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount, expiry, v, r, s); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance - encumbranceAmount); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), encumbranceAmount); + assertEq(wrappedToken.encumbrances(aliceContract, bob), encumbranceAmount); + + // alice's contract's nonce is incremented + assertEq(wrappedToken.nonces(aliceContract), nonce + 1); + } + + function testEncumberBySigRevertsForBadSpenderEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(encumbranceAmount, nonce, expiry); + + // bob calls encumberBySig with the signature, but he manipulates the spender + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.encumberBySig(aliceContract, charlie, encumbranceAmount, expiry, v, r, s); + + // no encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testEncumberBySigRevertsForBadAmountEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(encumbranceAmount, nonce, expiry); + + // bob calls encumberBySig with the signature, but he manipulates the encumbranceAmount + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount + 1 wei, expiry, v, r, s); + + // no encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testEncumberBySigRevertsForBadExpiryEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(encumbranceAmount, nonce, expiry); + + // bob calls encumberBySig with the signature, but he manipulates the expiry + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount, expiry + 1, v, r, s); + + // no encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testEncumberBySigRevertsForBadNonceEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + // alice signs an authorization with an invalid nonce + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 badNonce = nonce + 1; + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(encumbranceAmount, badNonce, expiry); + + // bob calls encumberBySig with the signature with an invalid nonce + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount, expiry, v, r, s); + + // no encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testEncumberBySigRevertsOnRepeatedCallEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + uint256 transferAmount = 30e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + uint256 nonce = wrappedToken.nonces(alice); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(encumbranceAmount, nonce, expiry); + + // bob calls encumberBySig with the signature + vm.startPrank(bob); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount, expiry, v, r, s); + + // the encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance - encumbranceAmount); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), encumbranceAmount); + assertEq(wrappedToken.encumbrances(aliceContract, bob), encumbranceAmount); + + // alice's contract's nonce is incremented + assertEq(wrappedToken.nonces(aliceContract), nonce + 1); + + // bob uses some of the encumbrance to transfer to himself + wrappedToken.transferFrom(aliceContract, bob, transferAmount); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance - transferAmount); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance - encumbranceAmount); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), encumbranceAmount - transferAmount); + assertEq(wrappedToken.encumbrances(aliceContract, bob), encumbranceAmount - transferAmount); + + // bob tries to reuse the same signature twice + vm.expectRevert("Bad signatory"); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount, expiry, v, r, s); + + // no new encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance - transferAmount); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance - encumbranceAmount); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), encumbranceAmount - transferAmount); + assertEq(wrappedToken.encumbrances(aliceContract, bob), encumbranceAmount - transferAmount); + + // alice's contract's nonce is not incremented a second time + assertEq(wrappedToken.nonces(aliceContract), nonce + 1); + + vm.stopPrank(); + } + + function testEncumberBySigRevertsForExpiredSignatureEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(encumbranceAmount, nonce, expiry); + + // the expiry block arrives + vm.warp(expiry); + + // bob calls encumberBySig with the signature after the expiry + vm.prank(bob); + vm.expectRevert("Signature expired"); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount, expiry, v, r, s); + + // no encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testEncumberBySigRevertsInvalidVEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (, bytes32 r, bytes32 s) = aliceContractAuthorization(encumbranceAmount, nonce, expiry); + uint8 invalidV = 26; + + // bob calls encumberBySig with the signature with an invalid `v` value + vm.prank(bob); + vm.expectRevert("Call to verify EIP1271 signature failed"); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount, expiry, invalidV, r, s); + + // no encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testEncumberBySigRevertsInvalidSEIP1271() public { + uint256 aliceBalance = 100e18; + uint256 encumbranceAmount = 60e18; + + // alice's contract has 100 wrapped tokens + deal(address(wrappedToken), aliceContract, aliceBalance); + + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, ) = aliceContractAuthorization(encumbranceAmount, nonce, expiry); + + // 1 greater than the max value of s + bytes32 invalidS = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A1; + + // bob calls encumberBySig with the signature, but he manipulates the expiry + vm.prank(bob); + vm.expectRevert("Call to verify EIP1271 signature failed"); + wrappedToken.encumberBySig(aliceContract, bob, encumbranceAmount, expiry, v, r, invalidS); + + // no encumbrance is created + assertEq(wrappedToken.balanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.availableBalanceOf(aliceContract), aliceBalance); + assertEq(wrappedToken.encumberedBalanceOf(aliceContract), 0); + assertEq(wrappedToken.encumbrances(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } } diff --git a/test/Permit.t.sol b/test/Permit.t.sol index b5d4d24..e06f206 100644 --- a/test/Permit.t.sol +++ b/test/Permit.t.sol @@ -5,6 +5,7 @@ import "forge-std/StdUtils.sol"; import "../src/vendor/ERC20.sol"; import "../src/vendor/IERC20Metadata.sol"; import "../src/EncumberableToken.sol"; +import "../src/test/EIP1271Signer.sol"; contract PermitTest is Test { ERC20 public underlyingToken; @@ -12,16 +13,18 @@ contract PermitTest is Test { uint256 alicePrivateKey = 0xa11ce; address alice; // see setup() + address aliceContract; // contract that can verify EIP1271 signatures address bob = address(11); address charlie = address(12); bytes32 internal constant AUTHORIZATION_TYPEHASH = keccak256("Authorization(address owner,address spender,uint256 amount,uint256 nonce,uint256 expiry)"); function setUp() public { + alice = vm.addr(alicePrivateKey); + underlyingToken = new ERC20("TEST TOKEN", "TTKN"); wrappedToken = new EncumberableToken(address(underlyingToken)); - - alice = vm.addr(alicePrivateKey); + aliceContract = address(new EIP1271Signer(alice)); } function aliceAuthorization(uint256 amount, uint256 nonce, uint256 expiry) internal view returns (uint8, bytes32, bytes32) { @@ -30,6 +33,12 @@ contract PermitTest is Test { return vm.sign(alicePrivateKey, digest); } + function aliceContractAuthorization(uint256 amount, uint256 nonce, uint256 expiry) internal view returns (uint8, bytes32, bytes32) { + bytes32 structHash = keccak256(abi.encode(AUTHORIZATION_TYPEHASH, aliceContract, bob, amount, nonce, expiry)); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", wrappedToken.DOMAIN_SEPARATOR(), structHash)); + return vm.sign(alicePrivateKey, digest); + } + function testPermit() public { // bob's allowance from alice is 0 assertEq(wrappedToken.allowance(alice, bob), 0); @@ -272,4 +281,249 @@ contract PermitTest is Test { // alice's nonce is not incremented assertEq(wrappedToken.nonces(alice), nonce); } + + /* ===== EIP1271 Tests ===== */ + + function testPermitEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, nonce, expiry); + + // bob calls permit with the signature + vm.prank(bob); + wrappedToken.permit(aliceContract, bob, allowance, expiry, v, r, s); + + // bob's allowance from alice's contract equals allowance + assertEq(wrappedToken.allowance(aliceContract, bob), allowance); + + // alice's contract's nonce is incremented + assertEq(wrappedToken.nonces(aliceContract), nonce + 1); + } + + function testPermitRevertsForBadOwnerEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, nonce, expiry); + + // bob calls permit with the signature, but he manipulates the owner + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.permit(charlie, bob, allowance, expiry, v, r, s); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testPermitRevertsForBadSpenderEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, nonce, expiry); + + // bob calls permit with the signature, but he manipulates the spender + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.permit(aliceContract, charlie, allowance, expiry, v, r, s); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testPermitRevertsForBadAmountEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, nonce, expiry); + + // bob calls permit with the signature, but he manipulates the allowance + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.permit(aliceContract, bob, allowance + 1 wei, expiry, v, r, s); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testPermitRevertsForBadExpiryEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, nonce, expiry); + + // bob calls permit with the signature, but he manipulates the expiry + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.permit(aliceContract, bob, allowance, expiry + 1, v, r, s); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(alice), nonce); + } + + function testPermitRevertsForBadNonceEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice signs an authorization with an invalid nonce + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 badNonce = nonce + 1; + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, badNonce, expiry); + + // bob calls permit with the signature with an invalid nonce + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.permit(aliceContract, bob, allowance, expiry, v, r, s); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testPermitRevertsOnRepeatedCallEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, nonce, expiry); + + // bob calls permit with the signature + vm.prank(bob); + wrappedToken.permit(aliceContract, bob, allowance, expiry, v, r, s); + + // bob's allowance from alice's contract equals allowance + assertEq(wrappedToken.allowance(aliceContract, bob), allowance); + + // alice's contract's nonce is incremented + assertEq(wrappedToken.nonces(aliceContract), nonce + 1); + + // alice revokes bob's allowance + vm.prank(aliceContract); + wrappedToken.approve(bob, 0); + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // bob tries to reuse the same signature twice + vm.prank(bob); + vm.expectRevert("Bad signatory"); + wrappedToken.permit(aliceContract, bob, allowance, expiry, v, r, s); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce + 1); + } + + function testPermitRevertsForExpiredSignatureEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, nonce, expiry); + + // the expiry block arrives + vm.warp(expiry); + + // bob calls permit with the signature after the expiry + vm.prank(bob); + vm.expectRevert("Signature expired"); + wrappedToken.permit(aliceContract, bob, allowance, expiry, v, r, s); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testPermitRevertsInvalidVEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (, bytes32 r, bytes32 s) = aliceContractAuthorization(allowance, nonce, expiry); + uint8 invalidV = 26; + + // bob calls permit with the signature with invalid `v` value + vm.prank(bob); + vm.expectRevert("Call to verify EIP1271 signature failed"); + wrappedToken.permit(aliceContract, bob, allowance, expiry, invalidV, r, s); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } + + function testPermitRevertsInvalidSEIP1271() public { + // bob's allowance from alice's contract is 0 + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + uint256 allowance = 123e18; + uint256 nonce = wrappedToken.nonces(aliceContract); + uint256 expiry = block.timestamp + 1000; + + (uint8 v, bytes32 r, ) = aliceContractAuthorization(allowance, nonce, expiry); + + // 1 greater than the max value of s + bytes32 invalidS = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A1; + + // bob calls permit with the signature with invalid `s` value + vm.prank(bob); + vm.expectRevert("Call to verify EIP1271 signature failed"); + wrappedToken.permit(aliceContract, bob, allowance, expiry, v, r, invalidS); + + // bob's allowance from alice's contract is unchanged + assertEq(wrappedToken.allowance(aliceContract, bob), 0); + + // alice's contract's nonce is not incremented + assertEq(wrappedToken.nonces(aliceContract), nonce); + } } \ No newline at end of file