Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: embedded EIP712 for StETH #640

Merged
merged 1 commit into from Feb 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions contracts/0.4.24/StETHPermit.sol
Expand Up @@ -100,7 +100,7 @@ contract StETHPermit is IERC2612, StETH {
abi.encode(PERMIT_TYPEHASH, _owner, _spender, _value, _useNonce(_owner), _deadline)
);

bytes32 hash = IEIP712(getEIP712StETH()).hashTypedDataV4(structHash);
bytes32 hash = IEIP712(getEIP712StETH()).hashTypedDataV4(address(this), structHash);

address signer = ECDSA.recover(hash, _v, _r, _s);
require(signer == _owner, "ERC20Permit: invalid signature");
Expand All @@ -124,7 +124,7 @@ contract StETHPermit is IERC2612, StETH {
*/
// solhint-disable-next-line func-name-mixedcase
function DOMAIN_SEPARATOR() external view returns (bytes32) {
return IEIP712(getEIP712StETH()).domainSeparatorV4();
return IEIP712(getEIP712StETH()).domainSeparatorV4(address(this));
}

/**
Expand Down
83 changes: 72 additions & 11 deletions contracts/0.8.9/EIP712StETH.sol
@@ -1,27 +1,88 @@
// SPDX-FileCopyrightText: 2023 Lido <info@lido.fi>
// SPDX-License-Identifier: GPL-3.0
// SPDX-FileCopyrightText: 2023 OpenZeppelin, Lido <info@lido.fi>
// SPDX-License-Identifier: MIT

/* See contracts/COMPILERS.md */
pragma solidity 0.8.9;

import {EIP712} from "@openzeppelin/contracts-v4.4/utils/cryptography/draft-EIP712.sol";
import {ECDSA} from "@openzeppelin/contracts-v4.4/utils/cryptography/ECDSA.sol";

import {IEIP712} from "../common/interfaces/IEIP712.sol";

/**
* Helper contract exposes OpenZeppelin's EIP712 message utils implementation.
* NOTE: The code below is taken from "@openzeppelin/contracts-v4.4/utils/cryptography/draft-EIP712.sol"
* With a main difference to store the stETH contract address internally and use it for signing.
*/
contract EIP712StETH is IEIP712, EIP712 {

/**
* @dev https://eips.ethereum.org/EIPS/eip-712[EIP 712] is a standard for hashing and signing of typed structured data.
*
* The encoding specified in the EIP is very generic, and such a generic implementation in Solidity is not feasible,
* thus this contract does not implement the encoding itself. Protocols need to implement the type-specific encoding
* they need in their contracts using a combination of `abi.encode` and `keccak256`.
*
* This contract implements the EIP 712 domain separator ({_domainSeparatorV4}) that is used as part of the encoding
* scheme, and the final step of the encoding to obtain the message digest that is then signed via ECDSA
* ({_hashTypedDataV4}).
*
* The implementation of the domain separator was designed to be as efficient as possible while still properly updating
* the chain id to protect against replay attacks on an eventual fork of the chain.
*
* NOTE: This contract implements the version of the encoding known as "v4", as implemented by the JSON RPC method
* https://docs.metamask.io/guide/signing-data.html[`eth_signTypedDataV4` in MetaMask].
*
*/
contract EIP712StETH is IEIP712 {
/* solhint-disable var-name-mixedcase */
// Cache the domain separator as an immutable value, but also store the chain id that it corresponds to, in order to
// invalidate the cached domain separator if the chain id changes.
bytes32 private immutable _CACHED_DOMAIN_SEPARATOR;
uint256 private immutable _CACHED_CHAIN_ID;
address private immutable _CACHED_STETH;

bytes32 private immutable _HASHED_NAME;
bytes32 private immutable _HASHED_VERSION;
bytes32 private immutable _TYPE_HASH;

error ZeroStETHAddress();

/**
* @dev Constructs specialized EIP712 instance for StETH token, version "2".
*/
constructor() EIP712("Liquid staked Ether 2.0", "2") {}
constructor(address _stETH) {
if (_stETH == address(0)) { revert ZeroStETHAddress(); }

bytes32 hashedName = keccak256(bytes("Liquid staked Ether 2.0"));
bytes32 hashedVersion = keccak256(bytes("2"));
bytes32 typeHash = keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
);

_HASHED_NAME = hashedName;
_HASHED_VERSION = hashedVersion;
_CACHED_CHAIN_ID = block.chainid;
_CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(typeHash, hashedName, hashedVersion, _stETH);
_CACHED_STETH = _stETH;
_TYPE_HASH = typeHash;
}

/**
* @dev Returns the domain separator for the current chain.
*/
function domainSeparatorV4() external view override returns (bytes32) {
return _domainSeparatorV4();
function domainSeparatorV4(address _stETH) public view override returns (bytes32) {
if (_stETH == _CACHED_STETH && block.chainid == _CACHED_CHAIN_ID) {
return _CACHED_DOMAIN_SEPARATOR;
} else {
return _buildDomainSeparator(_TYPE_HASH, _HASHED_NAME, _HASHED_VERSION, _stETH);
arwer13 marked this conversation as resolved.
Show resolved Hide resolved
}
}

function _buildDomainSeparator(
bytes32 _typeHash,
bytes32 _nameHash,
bytes32 _versionHash,
address _stETH
) private view returns (bytes32) {
return keccak256(abi.encode(_typeHash, _nameHash, _versionHash, block.chainid, _stETH));
}

/**
Expand All @@ -31,15 +92,15 @@ contract EIP712StETH is IEIP712, EIP712 {
* This hash can be used together with {ECDSA-recover} to obtain the signer of a message. For example:
*
* ```solidity
* bytes32 digest = hashTypedDataV4(keccak256(abi.encode(
* bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(
* keccak256("Mail(address to,string contents)"),
* mailTo,
* keccak256(bytes(mailContents))
* )));
* address signer = ECDSA.recover(digest, signature);
* ```
*/
function hashTypedDataV4(bytes32 _structHash) external view override returns (bytes32) {
return _hashTypedDataV4(_structHash);
function hashTypedDataV4(address _stETH, bytes32 _structHash) external view override returns (bytes32) {
return ECDSA.toTypedDataHash(domainSeparatorV4(_stETH), _structHash);
}
}
4 changes: 2 additions & 2 deletions contracts/common/interfaces/IEIP712.sol
Expand Up @@ -15,7 +15,7 @@ interface IEIP712 {
/**
* @dev Returns the domain separator for the current chain.
*/
function domainSeparatorV4() external view returns (bytes32);
function domainSeparatorV4(address _stETH) external view returns (bytes32);

/**
* @dev Given an already https://eips.ethereum.org/EIPS/eip-712#definition-of-hashstruct[hashed struct], this
Expand All @@ -32,5 +32,5 @@ interface IEIP712 {
* address signer = ECDSA.recover(digest, signature);
* ```
*/
function hashTypedDataV4(bytes32 _structHash) external view returns (bytes32);
function hashTypedDataV4(address _stETH, bytes32 _structHash) external view returns (bytes32);
}
2 changes: 1 addition & 1 deletion lib/abi/EIP712StETH.json
@@ -1 +1 @@
[{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"domainSeparatorV4","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_structHash","type":"bytes32"}],"name":"hashTypedDataV4","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"}]
[{"inputs":[{"internalType":"address","name":"_stETH","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[],"name":"ZeroStETHAddress","type":"error"},{"inputs":[{"internalType":"address","name":"_stETH","type":"address"}],"name":"domainSeparatorV4","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_stETH","type":"address"},{"internalType":"bytes32","name":"_structHash","type":"bytes32"}],"name":"hashTypedDataV4","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"}]
68 changes: 37 additions & 31 deletions test/0.4.24/stethpermit.test.js
@@ -1,8 +1,7 @@
const crypto = require('crypto')
const { ACCOUNTS_AND_KEYS, MAX_UINT256, ZERO_ADDRESS } = require('./helpers/constants')
const { bn } = require('@aragon/contract-helpers-test')
const { assertBn, assertEvent } = require('@aragon/contract-helpers-test/src/asserts')
const { assertRevert } = require('../helpers/assertThrow')
const { assert } = require('../helpers/assert')
const { signPermit, signTransferAuthorization, makeDomainSeparator } = require('./helpers/permit_helpers')
const { hexStringFromBuffer } = require('./helpers/sign_utils')
const { ETH } = require('../helpers/utils')
Expand All @@ -16,13 +15,13 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
const snapshot = new EvmSnapshot(hre.ethers.provider)

before('deploy mock token', async () => {
eip712StETH = await EIP712StETH.new({ from: deployer })
stEthPermit = await StETHPermit.new({ from: deployer, value: ETH(1) })
eip712StETH = await EIP712StETH.new(stEthPermit.address, { from: deployer })
await stEthPermit.initializeEIP712StETH(eip712StETH.address)

chainId = await web3.eth.net.getId();

domainSeparator = makeDomainSeparator('Liquid staked Ether 2.0', '2', chainId, eip712StETH.address)
domainSeparator = makeDomainSeparator('Liquid staked Ether 2.0', '2', chainId, stEthPermit.address)
await snapshot.make()
})

Expand Down Expand Up @@ -50,8 +49,15 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
await stEthPermit.mintShares(permitParams.owner, initialBalance, { from: deployer })
})

it('EIP-712 signature helper reverts when zero stETH address passed', async () => {
await assert.revertsWithCustomError(
EIP712StETH.new(ZERO_ADDRESS, { from: deployer }),
`ZeroStETHAddress()`
)
})

it('EIP-712 signature helper contract matches the stored one', async () => {
assert.equal(await stEthPermit.getEIP712StETH(), eip712StETH.address)
assert.equals(await stEthPermit.getEIP712StETH(), eip712StETH.address)
})

it('grants allowance when a valid permit is given', async () => {
Expand All @@ -64,11 +70,11 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
let { v, r, s } = signPermit(owner, spender, value, nonce, deadline, domainSeparator, alice.key)

// check that the allowance is initially zero
assertBn(await stEthPermit.allowance(owner, spender), bn(0))
assert.equals(await stEthPermit.allowance(owner, spender), bn(0))
// check that the next nonce expected is zero
assertBn(await stEthPermit.nonces(owner), bn(0))
assert.equals(await stEthPermit.nonces(owner), bn(0))
// check domain separator
assert.equal(
assert.equals(
await stEthPermit.DOMAIN_SEPARATOR(),
domainSeparator
)
Expand All @@ -79,15 +85,15 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
)

// check that allowance is updated
assertBn(await stEthPermit.allowance(owner, spender), bn(value))
assert.equals(await stEthPermit.allowance(owner, spender), bn(value))

assertEvent(
assert.emits(
receipt,
'Approval',
{ expectedArgs: { owner: owner, spender: spender, value: bn(value) } }
{ owner: owner, spender: spender, value: bn(value) }
)

assertBn(await stEthPermit.nonces(owner), bn(1))
assert.equals(await stEthPermit.nonces(owner), bn(1))

// increment nonce
nonce = 1
Expand All @@ -99,15 +105,15 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
const receipt2 = await stEthPermit.permit(owner, spender, value, deadline, v, r, s, { from: charlie })

// check that allowance is updated
assertBn(await stEthPermit.allowance(owner, spender), bn(value))
assert.equals(await stEthPermit.allowance(owner, spender), bn(value))

assertEvent(
assert.emits(
receipt2,
'Approval',
{ expectedArgs: { owner: owner, spender: spender, value: bn(value) } }
{ owner: owner, spender: spender, value: bn(value) }
)

assertBn(await stEthPermit.nonces(owner), bn(2))
assert.equals(await stEthPermit.nonces(owner), bn(2))
})

it('reverts if the signature does not match given parameters', async () => {
Expand All @@ -116,7 +122,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
const { v, r, s } = signPermit(owner, spender, value, nonce, deadline, domainSeparator, alice.key)

// try to cheat by claiming the approved amount + 1
await assertRevert(
await assert.reverts(
stEthPermit.permit(
owner,
spender,
Expand All @@ -131,7 +137,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
)

// check that msg is incorrect even if claim the approved amount - 1
await assertRevert(
await assert.reverts(
stEthPermit.permit(
owner,
spender,
Expand All @@ -154,7 +160,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {

// try to cheat by submitting the permit that is signed by a
// wrong person
await assertRevert(
await assert.reverts(
stEthPermit.permit(owner, spender, value, deadline, v, r, s, {
from: charlie
}),
Expand All @@ -166,7 +172,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
await web3.eth.sendTransaction({ to: bob.address, from: accounts[0], value: ETH(10) })

// even Bob himself can't call permit with the invalid sig
await assertRevert(
await assert.reverts(
stEthPermit.permit(owner, spender, value, deadline, v, r, s, {
from: bob.address
}),
Expand All @@ -181,7 +187,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
const { v, r, s } = signPermit(owner, spender, value, nonce, deadline, domainSeparator, alice.key)

// try to submit the permit that is expired
await assertRevert(
await assert.reverts(
stEthPermit.permit(owner, spender, value, deadline, v, r, s, {
from: charlie
}),
Expand All @@ -194,11 +200,11 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
const { v, r, s } = signPermit(owner, spender, value, nonce, deadline1min, domainSeparator, alice.key)
const receipt = await stEthPermit.permit(owner, spender, value, deadline1min, v, r, s, { from: charlie })

assertBn(await stEthPermit.nonces(owner), bn(1))
assertEvent(
assert.equals(await stEthPermit.nonces(owner), bn(1))
assert.emits(
receipt,
'Approval',
{ expectedArgs: { owner: owner, spender: spender, value: bn(value) } }
{ owner: owner, spender: spender, value: bn(value) }
)
}
})
Expand All @@ -209,10 +215,10 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
// create a signed permit
const { v, r, s } = signPermit(owner, spender, value, nonce, deadline, domainSeparator, alice.key)
// check that the next nonce expected is 0, not 1
assertBn(await stEthPermit.nonces(owner), bn(0))
assert.equals(await stEthPermit.nonces(owner), bn(0))

// try to submit the permit
await assertRevert(
await assert.reverts(
stEthPermit.permit(owner, spender, value, deadline, v, r, s, {
from: charlie
}),
Expand All @@ -229,7 +235,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
await stEthPermit.permit(owner, spender, value, deadline, v, r, s, { from: charlie })

// try to submit the permit again
await assertRevert(
await assert.reverts(
stEthPermit.permit(owner, spender, value, deadline, v, r, s, {
from: charlie
}),
Expand All @@ -241,7 +247,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
await web3.eth.sendTransaction({ to: alice.address, from: accounts[0], value: ETH(10) })

// try to submit the permit again from Alice herself
await assertRevert(
await assert.reverts(
stEthPermit.permit(owner, spender, value, deadline, v, r, s, {
from: alice.address
}),
Expand All @@ -262,7 +268,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
const permit2 = signPermit(owner, spender, 1e6, nonce, deadline, domainSeparator, alice.key)

// try to submit the permit again
await assertRevert(
await assert.reverts(
stEthPermit.permit(owner, spender, 1e6, deadline, permit2.v, permit2.r, permit2.s, { from: charlie }),
'ERC20Permit: invalid signature'
)
Expand All @@ -276,7 +282,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
const { v, r, s } = signPermit(owner, spender, value, nonce, deadline, domainSeparator, alice.key)

// try to submit the permit with invalid approval parameters
await assertRevert(
await assert.reverts(
stEthPermit.permit(owner, spender, value, deadline, v, r, s, {
from: charlie
}),
Expand All @@ -292,7 +298,7 @@ contract('StETHPermit', ([deployer, ...accounts]) => {
const { v, r, s } = signTransferAuthorization(from, to, value, validAfter, validBefore, nonce, domainSeparator, alice.key)

// try to submit the transfer permit
await assertRevert(
await assert.reverts(
stEthPermit.permit(from, to, value, validBefore, v, r, s, {
from: charlie
}),
Expand Down