Skip to content

Commit 66f390f

Browse files
Transpile 33ff9b0
1 parent b9fcc08 commit 66f390f

File tree

4 files changed

+231
-2
lines changed

4 files changed

+231
-2
lines changed

.changeset/swift-bags-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'openzeppelin-solidity': patch
3+
---
4+
5+
`Governor`: Add a mechanism to restrict the address of the proposer using a suffix in the description.

contracts/governance/GovernorUpgradeable.sol

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ abstract contract GovernorUpgradeable is Initializable, ContextUpgradeable, ERC1
269269
}
270270

271271
/**
272-
* @dev See {IGovernor-propose}.
272+
* @dev See {IGovernor-propose}. This function has opt-in frontrunning protection, described in {_isValidDescriptionForProposer}.
273273
*/
274274
function propose(
275275
address[] memory targets,
@@ -278,8 +278,9 @@ abstract contract GovernorUpgradeable is Initializable, ContextUpgradeable, ERC1
278278
string memory description
279279
) public virtual override returns (uint256) {
280280
address proposer = _msgSender();
281-
uint256 currentTimepoint = clock();
281+
require(_isValidDescriptionForProposer(proposer, description), "Governor: proposer restricted");
282282

283+
uint256 currentTimepoint = clock();
283284
require(
284285
getVotes(proposer, currentTimepoint - 1) >= proposalThreshold(),
285286
"Governor: proposer votes below proposal threshold"
@@ -641,6 +642,91 @@ abstract contract GovernorUpgradeable is Initializable, ContextUpgradeable, ERC1
641642
return this.onERC1155BatchReceived.selector;
642643
}
643644

645+
/**
646+
* @dev Check if the proposer is authorized to submit a proposal with the given description.
647+
*
648+
* If the proposal description ends with `#proposer=0x???`, where `0x???` is an address written as a hex string
649+
* (case insensitive), then the submission of this proposal will only be authorized to said address.
650+
*
651+
* This is used for frontrunning protection. By adding this pattern at the end of their proposal, one can ensure
652+
* that no other address can submit the same proposal. An attacker would have to either remove or change that part,
653+
* which would result in a different proposal id.
654+
*
655+
* If the description does not match this pattern, it is unrestricted and anyone can submit it. This includes:
656+
* - If the `0x???` part is not a valid hex string.
657+
* - If the `0x???` part is a valid hex string, but does not contain exactly 40 hex digits.
658+
* - If it ends with the expected suffix followed by newlines or other whitespace.
659+
* - If it ends with some other similar suffix, e.g. `#other=abc`.
660+
* - If it does not end with any such suffix.
661+
*/
662+
function _isValidDescriptionForProposer(
663+
address proposer,
664+
string memory description
665+
) internal view virtual returns (bool) {
666+
uint256 len = bytes(description).length;
667+
668+
// Length is too short to contain a valid proposer suffix
669+
if (len < 52) {
670+
return true;
671+
}
672+
673+
// Extract what would be the `#proposer=0x` marker beginning the suffix
674+
bytes12 marker;
675+
assembly {
676+
// - Start of the string contents in memory = description + 32
677+
// - First character of the marker = len - 52
678+
// - Length of "#proposer=0x0000000000000000000000000000000000000000" = 52
679+
// - We read the memory word starting at the first character of the marker:
680+
// - (description + 32) + (len - 52) = description + (len - 20)
681+
// - Note: Solidity will ignore anything past the first 12 bytes
682+
marker := mload(add(description, sub(len, 20)))
683+
}
684+
685+
// If the marker is not found, there is no proposer suffix to check
686+
if (marker != bytes12("#proposer=0x")) {
687+
return true;
688+
}
689+
690+
// Parse the 40 characters following the marker as uint160
691+
uint160 recovered = 0;
692+
for (uint256 i = len - 40; i < len; ++i) {
693+
(bool isHex, uint8 value) = _tryHexToUint(bytes(description)[i]);
694+
// If any of the characters is not a hex digit, ignore the suffix entirely
695+
if (!isHex) {
696+
return true;
697+
}
698+
recovered = (recovered << 4) | value;
699+
}
700+
701+
return recovered == uint160(proposer);
702+
}
703+
704+
/**
705+
* @dev Try to parse a character from a string as a hex value. Returns `(true, value)` if the char is in
706+
* `[0-9a-fA-F]` and `(false, 0)` otherwise. Value is guaranteed to be in the range `0 <= value < 16`
707+
*/
708+
function _tryHexToUint(bytes1 char) private pure returns (bool, uint8) {
709+
uint8 c = uint8(char);
710+
unchecked {
711+
// Case 0-9
712+
if (47 < c && c < 58) {
713+
return (true, c - 48);
714+
}
715+
// Case A-F
716+
else if (64 < c && c < 71) {
717+
return (true, c - 55);
718+
}
719+
// Case a-f
720+
else if (96 < c && c < 103) {
721+
return (true, c - 87);
722+
}
723+
// Else: not a hex char
724+
else {
725+
return (false, 0);
726+
}
727+
}
728+
}
729+
644730
/**
645731
* @dev This empty reserved space is put in place to allow future versions to add new
646732
* variables without shifting down storage in the inheritance chain.

test/governance/Governor.t.sol

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// SPDX-License-Identifier: MIT
2+
3+
pragma solidity ^0.8.19;
4+
5+
import "forge-std/Test.sol";
6+
import "../../contracts/utils/Strings.sol";
7+
import "../../contracts/governance/Governor.sol";
8+
9+
contract GovernorInternalTest is Test, Governor {
10+
constructor() Governor("") {}
11+
12+
function testValidDescriptionForProposer(string memory description, address proposer, bool includeProposer) public {
13+
if (includeProposer) {
14+
description = string.concat(description, "#proposer=", Strings.toHexString(proposer));
15+
}
16+
assertTrue(_isValidDescriptionForProposer(proposer, description));
17+
}
18+
19+
function testInvalidDescriptionForProposer(
20+
string memory description,
21+
address commitProposer,
22+
address actualProposer
23+
) public {
24+
vm.assume(commitProposer != actualProposer);
25+
description = string.concat(description, "#proposer=", Strings.toHexString(commitProposer));
26+
assertFalse(_isValidDescriptionForProposer(actualProposer, description));
27+
}
28+
29+
// We don't need to truly implement implement the missing functions because we are just testing
30+
// internal helpers.
31+
32+
function clock() public pure override returns (uint48) {}
33+
34+
// solhint-disable-next-line func-name-mixedcase
35+
function CLOCK_MODE() public pure override returns (string memory) {}
36+
37+
// solhint-disable-next-line func-name-mixedcase
38+
function COUNTING_MODE() public pure virtual override returns (string memory) {}
39+
40+
function votingDelay() public pure virtual override returns (uint256) {}
41+
42+
function votingPeriod() public pure virtual override returns (uint256) {}
43+
44+
function quorum(uint256) public pure virtual override returns (uint256) {}
45+
46+
function hasVoted(uint256, address) public pure virtual override returns (bool) {}
47+
48+
function _quorumReached(uint256) internal pure virtual override returns (bool) {}
49+
50+
function _voteSucceeded(uint256) internal pure virtual override returns (bool) {}
51+
52+
function _getVotes(address, uint256, bytes memory) internal pure virtual override returns (uint256) {}
53+
54+
function _countVote(uint256, address, uint8, uint256, bytes memory) internal virtual override {}
55+
}

test/governance/Governor.test.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -544,6 +544,89 @@ contract('Governor', function (accounts) {
544544
});
545545
});
546546

547+
describe('frontrun protection using description suffix', function () {
548+
describe('without protection', function () {
549+
describe('without suffix', function () {
550+
it('proposer can propose', async function () {
551+
expectEvent(await this.helper.propose({ from: proposer }), 'ProposalCreated');
552+
});
553+
554+
it('someone else can propose', async function () {
555+
expectEvent(await this.helper.propose({ from: voter1 }), 'ProposalCreated');
556+
});
557+
});
558+
559+
describe('with different suffix', function () {
560+
beforeEach(async function () {
561+
this.proposal = this.helper.setProposal(
562+
[
563+
{
564+
target: this.receiver.address,
565+
data: this.receiver.contract.methods.mockFunction().encodeABI(),
566+
value,
567+
},
568+
],
569+
`<proposal description>#wrong-suffix=${proposer}`,
570+
);
571+
});
572+
573+
it('proposer can propose', async function () {
574+
expectEvent(await this.helper.propose({ from: proposer }), 'ProposalCreated');
575+
});
576+
577+
it('someone else can propose', async function () {
578+
expectEvent(await this.helper.propose({ from: voter1 }), 'ProposalCreated');
579+
});
580+
});
581+
582+
describe('with proposer suffix but bad address part', function () {
583+
beforeEach(async function () {
584+
this.proposal = this.helper.setProposal(
585+
[
586+
{
587+
target: this.receiver.address,
588+
data: this.receiver.contract.methods.mockFunction().encodeABI(),
589+
value,
590+
},
591+
],
592+
`<proposal description>#proposer=0x3C44CdDdB6a900fa2b585dd299e03d12FA429XYZ`, // XYZ are not a valid hex char
593+
);
594+
});
595+
596+
it('propose can propose', async function () {
597+
expectEvent(await this.helper.propose({ from: proposer }), 'ProposalCreated');
598+
});
599+
600+
it('someone else can propose', async function () {
601+
expectEvent(await this.helper.propose({ from: voter1 }), 'ProposalCreated');
602+
});
603+
});
604+
});
605+
606+
describe('with protection via proposer suffix', function () {
607+
beforeEach(async function () {
608+
this.proposal = this.helper.setProposal(
609+
[
610+
{
611+
target: this.receiver.address,
612+
data: this.receiver.contract.methods.mockFunction().encodeABI(),
613+
value,
614+
},
615+
],
616+
`<proposal description>#proposer=${proposer}`,
617+
);
618+
});
619+
620+
it('proposer can propose', async function () {
621+
expectEvent(await this.helper.propose({ from: proposer }), 'ProposalCreated');
622+
});
623+
624+
it('someone else cannot propose', async function () {
625+
await expectRevert(this.helper.propose({ from: voter1 }), 'Governor: proposer restricted');
626+
});
627+
});
628+
});
629+
547630
describe('onlyGovernance updates', function () {
548631
it('setVotingDelay is protected', async function () {
549632
await expectRevert(this.mock.setVotingDelay('0'), 'Governor: onlyGovernance');

0 commit comments

Comments
 (0)