github-actions 2 лет назад
Родитель
Сommit
87456e3358

+ 5 - 0
.changeset/swift-bags-divide.md

@@ -0,0 +1,5 @@
+---
+'openzeppelin-solidity': patch
+---
+
+`Governor`: Add a mechanism to restrict the address of the proposer using a suffix in the description.

+ 88 - 2
contracts/governance/Governor.sol

@@ -263,7 +263,7 @@ abstract contract Governor is Context, ERC165, EIP712, IGovernor, IERC721Receive
     }
 
     /**
-     * @dev See {IGovernor-propose}.
+     * @dev See {IGovernor-propose}. This function has opt-in frontrunning protection, described in {_isValidDescriptionForProposer}.
      */
     function propose(
         address[] memory targets,
@@ -272,8 +272,9 @@ abstract contract Governor is Context, ERC165, EIP712, IGovernor, IERC721Receive
         string memory description
     ) public virtual override returns (uint256) {
         address proposer = _msgSender();
-        uint256 currentTimepoint = clock();
+        require(_isValidDescriptionForProposer(proposer, description), "Governor: proposer restricted");
 
+        uint256 currentTimepoint = clock();
         require(
             getVotes(proposer, currentTimepoint - 1) >= proposalThreshold(),
             "Governor: proposer votes below proposal threshold"
@@ -634,4 +635,89 @@ abstract contract Governor is Context, ERC165, EIP712, IGovernor, IERC721Receive
     ) public virtual override returns (bytes4) {
         return this.onERC1155BatchReceived.selector;
     }
+
+    /**
+     * @dev Check if the proposer is authorized to submit a proposal with the given description.
+     *
+     * If the proposal description ends with `#proposer=0x???`, where `0x???` is an address written as a hex string
+     * (case insensitive), then the submission of this proposal will only be authorized to said address.
+     *
+     * This is used for frontrunning protection. By adding this pattern at the end of their proposal, one can ensure
+     * that no other address can submit the same proposal. An attacker would have to either remove or change that part,
+     * which would result in a different proposal id.
+     *
+     * If the description does not match this pattern, it is unrestricted and anyone can submit it. This includes:
+     * - If the `0x???` part is not a valid hex string.
+     * - If the `0x???` part is a valid hex string, but does not contain exactly 40 hex digits.
+     * - If it ends with the expected suffix followed by newlines or other whitespace.
+     * - If it ends with some other similar suffix, e.g. `#other=abc`.
+     * - If it does not end with any such suffix.
+     */
+    function _isValidDescriptionForProposer(
+        address proposer,
+        string memory description
+    ) internal view virtual returns (bool) {
+        uint256 len = bytes(description).length;
+
+        // Length is too short to contain a valid proposer suffix
+        if (len < 52) {
+            return true;
+        }
+
+        // Extract what would be the `#proposer=0x` marker beginning the suffix
+        bytes12 marker;
+        assembly {
+            // - Start of the string contents in memory = description + 32
+            // - First character of the marker = len - 52
+            //   - Length of "#proposer=0x0000000000000000000000000000000000000000" = 52
+            // - We read the memory word starting at the first character of the marker:
+            //   - (description + 32) + (len - 52) = description + (len - 20)
+            // - Note: Solidity will ignore anything past the first 12 bytes
+            marker := mload(add(description, sub(len, 20)))
+        }
+
+        // If the marker is not found, there is no proposer suffix to check
+        if (marker != bytes12("#proposer=0x")) {
+            return true;
+        }
+
+        // Parse the 40 characters following the marker as uint160
+        uint160 recovered = 0;
+        for (uint256 i = len - 40; i < len; ++i) {
+            (bool isHex, uint8 value) = _tryHexToUint(bytes(description)[i]);
+            // If any of the characters is not a hex digit, ignore the suffix entirely
+            if (!isHex) {
+                return true;
+            }
+            recovered = (recovered << 4) | value;
+        }
+
+        return recovered == uint160(proposer);
+    }
+
+    /**
+     * @dev Try to parse a character from a string as a hex value. Returns `(true, value)` if the char is in
+     * `[0-9a-fA-F]` and `(false, 0)` otherwise. Value is guaranteed to be in the range `0 <= value < 16`
+     */
+    function _tryHexToUint(bytes1 char) private pure returns (bool, uint8) {
+        uint8 c = uint8(char);
+        unchecked {
+            // Case 0-9
+            if (47 < c && c < 58) {
+                return (true, c - 48);
+            }
+            // Case A-F
+            else if (64 < c && c < 71) {
+                return (true, c - 55);
+            }
+            // Case a-f
+            else if (96 < c && c < 103) {
+                return (true, c - 87);
+            }
+            // Else: not a hex char
+            else {
+                return (false, 0);
+            }
+        }
+    }
 }

+ 45 - 2
docs/modules/api/pages/governance.adoc

@@ -88,6 +88,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-clock--: xref:governance.adoc#IGovernor-clock--
 :xref-IGovernor-CLOCK_MODE--: xref:governance.adoc#IGovernor-CLOCK_MODE--
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
@@ -170,6 +171,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-clock--: xref:governance.adoc#IGovernor-clock--
 :xref-IGovernor-CLOCK_MODE--: xref:governance.adoc#IGovernor-CLOCK_MODE--
 :xref-IGovernor-votingDelay--: xref:governance.adoc#IGovernor-votingDelay--
@@ -231,6 +233,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
 :xref-IGovernor-votingDelay--: xref:governance.adoc#IGovernor-votingDelay--
 :xref-IGovernor-votingPeriod--: xref:governance.adoc#IGovernor-votingPeriod--
@@ -292,6 +295,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
 :xref-IGovernor-votingDelay--: xref:governance.adoc#IGovernor-votingDelay--
 :xref-IGovernor-votingPeriod--: xref:governance.adoc#IGovernor-votingPeriod--
@@ -346,6 +350,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
 :xref-IGovernor-votingDelay--: xref:governance.adoc#IGovernor-votingDelay--
 :xref-IGovernor-votingPeriod--: xref:governance.adoc#IGovernor-votingPeriod--
@@ -410,6 +415,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-clock--: xref:governance.adoc#IGovernor-clock--
 :xref-IGovernor-CLOCK_MODE--: xref:governance.adoc#IGovernor-CLOCK_MODE--
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
@@ -480,6 +486,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-clock--: xref:governance.adoc#IGovernor-clock--
 :xref-IGovernor-CLOCK_MODE--: xref:governance.adoc#IGovernor-CLOCK_MODE--
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
@@ -547,6 +554,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-clock--: xref:governance.adoc#IGovernor-clock--
 :xref-IGovernor-CLOCK_MODE--: xref:governance.adoc#IGovernor-CLOCK_MODE--
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
@@ -607,6 +615,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-clock--: xref:governance.adoc#IGovernor-clock--
 :xref-IGovernor-CLOCK_MODE--: xref:governance.adoc#IGovernor-CLOCK_MODE--
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
@@ -675,6 +684,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernorTimelock-timelock--: xref:governance.adoc#IGovernorTimelock-timelock--
 :xref-IGovernorTimelock-proposalEta-uint256-: xref:governance.adoc#IGovernorTimelock-proposalEta-uint256-
 :xref-IGovernorTimelock-queue-address---uint256---bytes---bytes32-: xref:governance.adoc#IGovernorTimelock-queue-address---uint256---bytes---bytes32-
@@ -742,6 +752,7 @@
 :xref-Governor-onERC721Received-address-address-uint256-bytes-: xref:governance.adoc#Governor-onERC721Received-address-address-uint256-bytes-
 :xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-: xref:governance.adoc#Governor-onERC1155Received-address-address-uint256-uint256-bytes-
 :xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-: xref:governance.adoc#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-
+:xref-Governor-_isValidDescriptionForProposer-address-string-: xref:governance.adoc#Governor-_isValidDescriptionForProposer-address-string-
 :xref-IGovernor-clock--: xref:governance.adoc#IGovernor-clock--
 :xref-IGovernor-CLOCK_MODE--: xref:governance.adoc#IGovernor-CLOCK_MODE--
 :xref-IGovernor-COUNTING_MODE--: xref:governance.adoc#IGovernor-COUNTING_MODE--
@@ -1265,6 +1276,7 @@ Note: `support` values should be seen as buckets. Their interpretation depends o
 :onERC721Received: pass:normal[xref:#Governor-onERC721Received-address-address-uint256-bytes-[`++onERC721Received++`]]
 :onERC1155Received: pass:normal[xref:#Governor-onERC1155Received-address-address-uint256-uint256-bytes-[`++onERC1155Received++`]]
 :onERC1155BatchReceived: pass:normal[xref:#Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-[`++onERC1155BatchReceived++`]]
+:_isValidDescriptionForProposer: pass:normal[xref:#Governor-_isValidDescriptionForProposer-address-string-[`++_isValidDescriptionForProposer++`]]
 
 [.contract]
 [[Governor]]
@@ -1331,6 +1343,7 @@ _Available since v4.3._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -1532,7 +1545,7 @@ meaning of the additional params, in the context of that implementation
 [[Governor-propose-address---uint256---bytes---string-]]
 ==== `[.contract-item-name]#++propose++#++(address[] targets, uint256[] values, bytes[] calldatas, string description) → uint256++` [.item-kind]#public#
 
-See {IGovernor-propose}.
+See {IGovernor-propose}. This function has opt-in frontrunning protection, described in {_isValidDescriptionForProposer}.
 
 [.contract-item]
 [[Governor-execute-address---uint256---bytes---bytes32-]]
@@ -1667,6 +1680,26 @@ See {IERC1155Receiver-onERC1155Received}.
 
 See {IERC1155Receiver-onERC1155BatchReceived}.
 
+[.contract-item]
+[[Governor-_isValidDescriptionForProposer-address-string-]]
+==== `[.contract-item-name]#++_isValidDescriptionForProposer++#++(address proposer, string description) → bool++` [.item-kind]#internal#
+
+Check if the proposer is authorized to submit a proposal with the given description.
+
+If the proposal description ends with `#proposer=0x???`, where `0x???` is an address written as a hex string
+(case insensitive), then the submission of this proposal will only be authorized to said address.
+
+This is used for frontrunning protection. By adding this pattern at the end of their proposal, one can ensure
+that no other address can submit the same proposal. An attacker would have to either remove or change that part,
+which would result in a different proposal id.
+
+If the description does not match this pattern, it is unrestricted and anyone can submit it. This includes:
+- If the `0x???` part is not a valid hex string.
+- If the `0x???` part is a valid hex string, but does not contain exactly 40 hex digits.
+- If it ends with the expected suffix followed by newlines or other whitespace.
+- If it ends with some other similar suffix, e.g. `#other=abc`.
+- If it does not end with any such suffix.
+
 === Modules
 
 :VoteType: pass:normal[xref:#GovernorCountingSimple-VoteType[`++VoteType++`]]
@@ -1736,6 +1769,7 @@ _Available since v4.3._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -1910,6 +1944,7 @@ _Available since v4.3._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -2082,6 +2117,7 @@ _Available since v4.3._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -2286,6 +2322,7 @@ _Available since v4.3._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -2466,6 +2503,7 @@ _Available since v4.3._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -2701,6 +2739,7 @@ _Available since v4.3._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -2946,6 +2985,7 @@ _Available since v4.4._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -3181,6 +3221,7 @@ _Available since v4.5._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -3408,6 +3449,7 @@ _Available since v4.3._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -3667,6 +3709,7 @@ _Deprecated since v4.4._
 * {xref-Governor-onERC721Received-address-address-uint256-bytes-}[`++onERC721Received(, , , )++`]
 * {xref-Governor-onERC1155Received-address-address-uint256-uint256-bytes-}[`++onERC1155Received(, , , , )++`]
 * {xref-Governor-onERC1155BatchReceived-address-address-uint256---uint256---bytes-}[`++onERC1155BatchReceived(, , , , )++`]
+* {xref-Governor-_isValidDescriptionForProposer-address-string-}[`++_isValidDescriptionForProposer(proposer, description)++`]
 
 [.contract-subindex-inherited]
 .IERC1155Receiver
@@ -3747,7 +3790,7 @@ _Deprecated since v4.4._
 [[GovernorProposalThreshold-propose-address---uint256---bytes---string-]]
 ==== `[.contract-item-name]#++propose++#++(address[] targets, uint256[] values, bytes[] calldatas, string description) → uint256++` [.item-kind]#public#
 
-See {IGovernor-propose}.
+See {IGovernor-propose}. This function has opt-in frontrunning protection, described in {_isValidDescriptionForProposer}.
 
 == Utils
 

+ 55 - 0
test/governance/Governor.t.sol

@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: MIT
+
+pragma solidity ^0.8.19;
+
+import "forge-std/Test.sol";
+import "../../contracts/utils/Strings.sol";
+import "../../contracts/governance/Governor.sol";
+
+contract GovernorInternalTest is Test, Governor {
+    constructor() Governor("") {}
+
+    function testValidDescriptionForProposer(string memory description, address proposer, bool includeProposer) public {
+        if (includeProposer) {
+            description = string.concat(description, "#proposer=", Strings.toHexString(proposer));
+        }
+        assertTrue(_isValidDescriptionForProposer(proposer, description));
+    }
+
+    function testInvalidDescriptionForProposer(
+        string memory description,
+        address commitProposer,
+        address actualProposer
+    ) public {
+        vm.assume(commitProposer != actualProposer);
+        description = string.concat(description, "#proposer=", Strings.toHexString(commitProposer));
+        assertFalse(_isValidDescriptionForProposer(actualProposer, description));
+    }
+
+    // We don't need to truly implement implement the missing functions because we are just testing
+    // internal helpers.
+
+    function clock() public pure override returns (uint48) {}
+
+    // solhint-disable-next-line func-name-mixedcase
+    function CLOCK_MODE() public pure override returns (string memory) {}
+
+    // solhint-disable-next-line func-name-mixedcase
+    function COUNTING_MODE() public pure virtual override returns (string memory) {}
+
+    function votingDelay() public pure virtual override returns (uint256) {}
+
+    function votingPeriod() public pure virtual override returns (uint256) {}
+
+    function quorum(uint256) public pure virtual override returns (uint256) {}
+
+    function hasVoted(uint256, address) public pure virtual override returns (bool) {}
+
+    function _quorumReached(uint256) internal pure virtual override returns (bool) {}
+
+    function _voteSucceeded(uint256) internal pure virtual override returns (bool) {}
+
+    function _getVotes(address, uint256, bytes memory) internal pure virtual override returns (uint256) {}
+
+    function _countVote(uint256, address, uint8, uint256, bytes memory) internal virtual override {}
+}

+ 83 - 0
test/governance/Governor.test.js

@@ -544,6 +544,89 @@ contract('Governor', function (accounts) {
         });
       });
 
+      describe('frontrun protection using description suffix', function () {
+        describe('without protection', function () {
+          describe('without suffix', function () {
+            it('proposer can propose', async function () {
+              expectEvent(await this.helper.propose({ from: proposer }), 'ProposalCreated');
+            });
+
+            it('someone else can propose', async function () {
+              expectEvent(await this.helper.propose({ from: voter1 }), 'ProposalCreated');
+            });
+          });
+
+          describe('with different suffix', function () {
+            beforeEach(async function () {
+              this.proposal = this.helper.setProposal(
+                [
+                  {
+                    target: this.receiver.address,
+                    data: this.receiver.contract.methods.mockFunction().encodeABI(),
+                    value,
+                  },
+                ],
+                `<proposal description>#wrong-suffix=${proposer}`,
+              );
+            });
+
+            it('proposer can propose', async function () {
+              expectEvent(await this.helper.propose({ from: proposer }), 'ProposalCreated');
+            });
+
+            it('someone else can propose', async function () {
+              expectEvent(await this.helper.propose({ from: voter1 }), 'ProposalCreated');
+            });
+          });
+
+          describe('with proposer suffix but bad address part', function () {
+            beforeEach(async function () {
+              this.proposal = this.helper.setProposal(
+                [
+                  {
+                    target: this.receiver.address,
+                    data: this.receiver.contract.methods.mockFunction().encodeABI(),
+                    value,
+                  },
+                ],
+                `<proposal description>#proposer=0x3C44CdDdB6a900fa2b585dd299e03d12FA429XYZ`, // XYZ are not a valid hex char
+              );
+            });
+
+            it('propose can propose', async function () {
+              expectEvent(await this.helper.propose({ from: proposer }), 'ProposalCreated');
+            });
+
+            it('someone else can propose', async function () {
+              expectEvent(await this.helper.propose({ from: voter1 }), 'ProposalCreated');
+            });
+          });
+        });
+
+        describe('with protection via proposer suffix', function () {
+          beforeEach(async function () {
+            this.proposal = this.helper.setProposal(
+              [
+                {
+                  target: this.receiver.address,
+                  data: this.receiver.contract.methods.mockFunction().encodeABI(),
+                  value,
+                },
+              ],
+              `<proposal description>#proposer=${proposer}`,
+            );
+          });
+
+          it('proposer can propose', async function () {
+            expectEvent(await this.helper.propose({ from: proposer }), 'ProposalCreated');
+          });
+
+          it('someone else cannot propose', async function () {
+            await expectRevert(this.helper.propose({ from: voter1 }), 'Governor: proposer restricted');
+          });
+        });
+      });
+
       describe('onlyGovernance updates', function () {
         it('setVotingDelay is protected', async function () {
           await expectRevert(this.mock.setVotingDelay('0'), 'Governor: onlyGovernance');