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

Enable ERC-1271 signature checks in Governor castVoteBySig #4418

Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/dry-crews-sell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': minor
---

`Governor`: Add support for casting votes with ERC-1271 signatures
ernestognw marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 5 additions & 0 deletions .changeset/popular-deers-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'openzeppelin-solidity': major
---

`Governor`: Use `bytes memory` signature instead of `r`, `s` and `v` parameters in the `castVoteBySig` and `castVoteWithReasonAndParamsBySig` functions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not merge the two changesets into one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just thought both required their own entry. Although the main reason of this PR is to introduce 1271 compatibility, it requires this change that should be communicated on its own.

We can think of an individual changeset if you think it's needed

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My intuition is that all of the changes to castVoteBySig should be together in the same changeset, including also #4378 currently in .changeset/sixty-numbers-reply.md.

If not that, I do think we should at least merge the 2 in this PR.

Copy link
Member Author

@ernestognw ernestognw Jul 4, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer merging these two into a single one because otherwise, we'll be breaking the PR reference when processing the changesets into the changelog.

Not sure if all the changes should be in a single entry. But I'd be worried about the order in the Changelog once they get processed.

36 changes: 15 additions & 21 deletions contracts/governance/Governor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ pragma solidity ^0.8.19;

import {IERC721Receiver} from "../token/ERC721/IERC721Receiver.sol";
import {IERC1155Receiver} from "../token/ERC1155/IERC1155Receiver.sol";
import {ECDSA} from "../utils/cryptography/ECDSA.sol";
import {EIP712} from "../utils/cryptography/EIP712.sol";
import {SignatureChecker} from "../utils/cryptography/SignatureChecker.sol";
import {IERC165, ERC165} from "../utils/introspection/ERC165.sol";
import {SafeCast} from "../utils/math/SafeCast.sol";
import {DoubleEndedQueue} from "../utils/structs/DoubleEndedQueue.sol";
Expand Down Expand Up @@ -519,22 +519,19 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
uint256 proposalId,
uint8 support,
address voter,
uint8 v,
bytes32 r,
bytes32 s
bytes memory signature
) public virtual override returns (uint256) {
address signer = ECDSA.recover(
bool valid = SignatureChecker.isValidSignatureNow(
voter,
_hashTypedDataV4(keccak256(abi.encode(BALLOT_TYPEHASH, proposalId, support, voter, _useNonce(voter)))),
v,
r,
s
signature
);

if (voter != signer) {
revert GovernorInvalidSigner(signer, voter);
if (!valid) {
revert GovernorInvalidSignature(voter);
}

return _castVote(proposalId, signer, support, "");
return _castVote(proposalId, voter, support, "");
}

/**
Expand All @@ -546,11 +543,10 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
address voter,
string calldata reason,
bytes memory params,
uint8 v,
bytes32 r,
bytes32 s
bytes memory signature
) public virtual override returns (uint256) {
address signer = ECDSA.recover(
bool valid = SignatureChecker.isValidSignatureNow(
voter,
_hashTypedDataV4(
keccak256(
abi.encode(
Expand All @@ -564,16 +560,14 @@ abstract contract Governor is Context, ERC165, EIP712, Nonces, IGovernor, IERC72
)
)
),
v,
r,
s
signature
);

if (voter != signer) {
revert GovernorInvalidSigner(signer, voter);
if (!valid) {
revert GovernorInvalidSignature(voter);
}

return _castVote(proposalId, signer, support, reason, params);
return _castVote(proposalId, voter, support, reason, params);
}

/**
Expand Down
18 changes: 8 additions & 10 deletions contracts/governance/IGovernor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ abstract contract IGovernor is IERC165, IERC6372 {
error GovernorInvalidVoteType();

/**
* @dev The `voter` doesn't match with the recovered `signer`.
* @dev The provided signature is not valid for the expected `voter`.
* If the `voter` is a contract, the signature is not valid using {IERC1271-isValidSignature}.
*/
error GovernorInvalidSigner(address signer, address voter);
error GovernorInvalidSignature(address voter);

/**
* @dev Emitted when a proposal is created.
Expand Down Expand Up @@ -353,21 +354,20 @@ abstract contract IGovernor is IERC165, IERC6372 {
) public virtual returns (uint256 balance);

/**
* @dev Cast a vote using the user's cryptographic signature.
* @dev Cast a vote using the voter's signature, including ERC-1271 signature support.
*
* Emits a {VoteCast} event.
*/
function castVoteBySig(
uint256 proposalId,
uint8 support,
address voter,
uint8 v,
bytes32 r,
bytes32 s
bytes memory signature
) public virtual returns (uint256 balance);

/**
* @dev Cast a vote with a reason and additional encoded parameters using the user's cryptographic signature.
* @dev Cast a vote with a reason and additional encoded parameters using the voter's signature,
* including ERC-1271 signature support.
*
* Emits a {VoteCast} or {VoteCastWithParams} event depending on the length of params.
*/
Expand All @@ -377,8 +377,6 @@ abstract contract IGovernor is IERC165, IERC6372 {
address voter,
string calldata reason,
bytes memory params,
uint8 v,
bytes32 r,
bytes32 s
bytes memory signature
) public virtual returns (uint256 balance);
}
2 changes: 1 addition & 1 deletion contracts/utils/cryptography/SignatureChecker.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {IERC1271} from "../../interfaces/IERC1271.sol";
/**
* @dev Signature verification helper that can be used instead of `ECDSA.recover` to seamlessly support both ECDSA
* signatures from externally owned accounts (EOAs) as well as ERC1271 signatures from smart contract wallets like
* Argent and Gnosis Safe.
* Argent and Safe Wallet (previously Gnosis Safe).
*
* _Available since v4.1._
*/
Expand Down
173 changes: 104 additions & 69 deletions test/governance/Governor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ const { constants, expectEvent, expectRevert } = require('@openzeppelin/test-hel
const { expect } = require('chai');
const ethSigUtil = require('eth-sig-util');
const Wallet = require('ethereumjs-wallet').default;
const { fromRpcSig, toRpcSig } = require('ethereumjs-util');

const Enums = require('../helpers/enums');
const { getDomain, domainType } = require('../helpers/eip712');
Expand All @@ -18,6 +17,7 @@ const Governor = artifacts.require('$GovernorMock');
const CallReceiver = artifacts.require('CallReceiverMock');
const ERC721 = artifacts.require('$ERC721');
const ERC1155 = artifacts.require('$ERC1155');
const ERC1271WalletMock = artifacts.require('ERC1271WalletMock');

const TOKENS = [
{ Token: artifacts.require('$ERC20Votes'), mode: 'blocknumber' },
Expand Down Expand Up @@ -166,55 +166,6 @@ contract('Governor', function (accounts) {
expect(await web3.eth.getBalance(this.receiver.address)).to.be.bignumber.equal(value);
});

it('votes with signature', async function () {
const voterBySig = Wallet.generate();
const voterBySigAddress = web3.utils.toChecksumAddress(voterBySig.getAddressString());

const signature = (contract, message) =>
getDomain(contract)
.then(domain => ({
primaryType: 'Ballot',
types: {
EIP712Domain: domainType(domain),
Ballot: [
{ name: 'proposalId', type: 'uint256' },
{ name: 'support', type: 'uint8' },
{ name: 'voter', type: 'address' },
{ name: 'nonce', type: 'uint256' },
],
},
domain,
message,
}))
.then(data => ethSigUtil.signTypedMessage(voterBySig.getPrivateKey(), { data }))
.then(fromRpcSig);

await this.token.delegate(voterBySigAddress, { from: voter1 });

const nonce = await this.mock.nonces(voterBySigAddress);

// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
expectEvent(
await this.helper.vote({ support: Enums.VoteType.For, voter: voterBySigAddress, nonce, signature }),
'VoteCast',
{
voter: voterBySigAddress,
support: Enums.VoteType.For,
},
);
await this.helper.waitForDeadline();
await this.helper.execute();

// After
expect(await this.mock.hasVoted(this.proposal.id, owner)).to.be.equal(false);
expect(await this.mock.hasVoted(this.proposal.id, voter1)).to.be.equal(false);
expect(await this.mock.hasVoted(this.proposal.id, voter2)).to.be.equal(false);
expect(await this.mock.hasVoted(this.proposal.id, voterBySigAddress)).to.be.equal(true);
expect(await this.mock.nonces(voterBySigAddress)).to.be.bignumber.equal(nonce.addn(1));
});

it('send ethers', async function () {
const empty = web3.utils.toChecksumAddress(web3.utils.randomHex(20));

Expand Down Expand Up @@ -244,6 +195,100 @@ contract('Governor', function (accounts) {
expect(await web3.eth.getBalance(empty)).to.be.bignumber.equal(value);
});

describe('vote with signature', function () {
beforeEach(async function () {
this.sign = privateKey => (contract, message) =>
getDomain(contract)
.then(domain => ({
primaryType: 'Ballot',
types: {
EIP712Domain: domainType(domain),
Ballot: [
{ name: 'proposalId', type: 'uint256' },
{ name: 'support', type: 'uint8' },
{ name: 'voter', type: 'address' },
{ name: 'nonce', type: 'uint256' },
],
},
domain,
message,
}))
.then(data => ethSigUtil.signTypedMessage(privateKey, { data }));
});
ernestognw marked this conversation as resolved.
Show resolved Hide resolved

it('votes with an EOA signature', async function () {
const voterBySig = Wallet.generate();
const voterBySigAddress = web3.utils.toChecksumAddress(voterBySig.getAddressString());

await this.token.delegate(voterBySigAddress, { from: voter1 });

const nonce = await this.mock.nonces(voterBySigAddress);

// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
expectEvent(
await this.helper.vote({
support: Enums.VoteType.For,
voter: voterBySigAddress,
nonce,
signature: this.sign(voterBySig.getPrivateKey()),
}),
'VoteCast',
{
voter: voterBySigAddress,
support: Enums.VoteType.For,
},
);
await this.helper.waitForDeadline();
await this.helper.execute();

// After
expect(await this.mock.hasVoted(this.proposal.id, voterBySigAddress)).to.be.equal(true);
expect(await this.mock.nonces(voterBySigAddress)).to.be.bignumber.equal(nonce.addn(1));
});

it('votes with a valid EIP-1271 signature', async function () {
const ERC1271WalletOwner = Wallet.generate();
ERC1271WalletOwner.address = web3.utils.toChecksumAddress(ERC1271WalletOwner.getAddressString());

const wallet = await ERC1271WalletMock.new(ERC1271WalletOwner.address);

await this.token.delegate(wallet.address, { from: voter1 });

const nonce = await this.mock.nonces(wallet.address);

// Run proposal
await this.helper.propose();
await this.helper.waitForSnapshot();
expectEvent(
await this.helper.vote({
support: Enums.VoteType.For,
voter: wallet.address,
nonce,
signature: this.sign(ERC1271WalletOwner.getPrivateKey()),
}),
'VoteCast',
{
voter: wallet.address,
support: Enums.VoteType.For,
},
);
await this.helper.waitForDeadline();
await this.helper.execute();

// After
expect(await this.mock.hasVoted(this.proposal.id, wallet.address)).to.be.equal(true);
expect(await this.mock.nonces(wallet.address)).to.be.bignumber.equal(nonce.addn(1));
});

afterEach(async function () {
expect(await this.mock.hasVoted(this.proposal.id, owner)).to.be.equal(false);
expect(await this.mock.hasVoted(this.proposal.id, voter1)).to.be.equal(false);
expect(await this.mock.hasVoted(this.proposal.id, voter2)).to.be.equal(false);
});
frangio marked this conversation as resolved.
Show resolved Hide resolved
});

describe('should revert', function () {
describe('on propose', function () {
it('if proposal already exists', async function () {
Expand Down Expand Up @@ -328,9 +373,9 @@ contract('Governor', function (accounts) {
}));

this.signature = (contract, message) =>
this.data(contract, message)
.then(data => ethSigUtil.signTypedMessage(this.voterBySig.getPrivateKey(), { data }))
.then(fromRpcSig);
this.data(contract, message).then(data =>
ethSigUtil.signTypedMessage(this.voterBySig.getPrivateKey(), { data }),
);

await this.token.delegate(this.voterBySig.address, { from: voter1 });

Expand All @@ -348,19 +393,13 @@ contract('Governor', function (accounts) {
nonce,
signature: async (...params) => {
const sig = await this.signature(...params);
sig.s[12] ^= 0xff;
return sig;
const tamperedSig = web3.utils.hexToBytes(sig);
tamperedSig[42] ^= 0xff;
return web3.utils.bytesToHex(tamperedSig);
},
};

const { r, s, v } = await this.helper.sign(voteParams);
const message = this.helper.forgeMessage(voteParams);
const data = await this.data(this.mock, message);

await expectRevertCustomError(this.helper.vote(voteParams), 'GovernorInvalidSigner', [
ethSigUtil.recoverTypedSignature({ sig: toRpcSig(v, r, s), data }),
voteParams.voter,
]);
await expectRevertCustomError(this.helper.vote(voteParams), 'GovernorInvalidSignature', [voteParams.voter]);
});

it('if vote nonce is incorrect', async function () {
Expand All @@ -373,15 +412,11 @@ contract('Governor', function (accounts) {
signature: this.signature,
};

const { r, s, v } = await this.helper.sign(voteParams);
const message = this.helper.forgeMessage(voteParams);
const data = await this.data(this.mock, { ...message, nonce });

await expectRevertCustomError(
this.helper.vote(voteParams),
// The signature check implies the nonce can't be tampered without changing the signer
'GovernorInvalidSigner',
[ethSigUtil.recoverTypedSignature({ sig: toRpcSig(v, r, s), data }), voteParams.voter],
'GovernorInvalidSignature',
[voteParams.voter],
);
});
});
Expand Down
Loading