Skip to content

feat: updatable proposals#5

Draft
dan13ram wants to merge 38 commits into
mainfrom
feat/updatable-proposals
Draft

feat: updatable proposals#5
dan13ram wants to merge 38 commits into
mainfrom
feat/updatable-proposals

Conversation

@dan13ram

@dan13ram dan13ram commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for creating and updating proposals via offline signatures, enabling multi-signer governance workflows.
    • Introduced an updatable proposal period allowing authorized changes to active proposals.
    • Enhanced vote-by-signature with nonce tracking and a unified signature format.
    • Added new proposal states and replacement tracking for modified proposals.
  • Documentation

    • Added comprehensive migration guide for Governor V2 frontend integration.
    • Added governance architecture and proposal lifecycle reference documentation.
    • Added protocol upgrade runbook for chains and DAOs.
    • Added audit readiness checklist for governance upgrades.

Review Change Stack

@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c6480695-bf54-4208-979a-1c5144e54276

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/updatable-proposals

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@dan13ram dan13ram force-pushed the feat/updatable-proposals branch from 5abbe4c to a3bc597 Compare May 20, 2026 13:55
@dan13ram dan13ram force-pushed the feat/updatable-proposals branch from a2b9aaa to 03e428f Compare May 21, 2026 17:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (5)
test/GovGasBenchmark.t.sol (1)

338-374: ⚡ Quick win

Extract duplicated helper logic to shared test base (Lines 338-374).

_buildOrderedUpdateSignatures and _mintTokensToUsers are duplicated across benchmark/fuzz suites. Moving them into Gov.t.sol (or a shared helper mixin) reduces drift risk when signature payloads change again.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/GovGasBenchmark.t.sol` around lines 338 - 374, Move the duplicated
helper functions _buildOrderedUpdateSignatures and _mintTokensToUsers out of
GovGasBenchmark.t.sol into the shared test base (e.g., Gov.t.sol or a new helper
mixin) so all benchmark/fuzz suites use the same implementations; ensure the
moved _buildOrderedUpdateSignatures continues to reference
UPDATE_PROPOSAL_TYPEHASH, governor.DOMAIN_SEPARATOR(), and _encodeSignature and
that callers still pass proposer/nonce/deadline, and ensure _mintTokensToUsers
continues to use token.mint(), auction, token.transferFrom(...) and otherUsers;
after moving, remove the duplicates and update any imports/inheritance so tests
compile and reference the single shared helper.
test/GovFuzz.t.sol (1)

80-89: ⚡ Quick win

Improve fuzz effectiveness by removing high-rejection assumption (Lines 80-89).

vm.assume(expiredOffset <= block.timestamp) drops a large portion of fuzz cases after bounding to 365 days. Use a dynamic upper bound instead to keep the corpus dense.

Proposed change
-        // Bound to reasonable past range
-        expiredOffset = bound(expiredOffset, 1, 365 days);
+        // Bound to valid past range relative to current timestamp
+        uint256 maxExpiredOffset = block.timestamp < 365 days ? block.timestamp : 365 days;
+        expiredOffset = bound(expiredOffset, 1, maxExpiredOffset);
...
-        vm.assume(expiredOffset <= block.timestamp);
         uint256 deadline = block.timestamp - expiredOffset;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/GovFuzz.t.sol` around lines 80 - 89, The test currently uses
vm.assume(expiredOffset <= block.timestamp) which rejects many fuzz cases;
instead move the bounding of expiredOffset to after the vm.warp and replace
bound(expiredOffset, 1, 365 days) with a dynamic upper bound using the current
timestamp (e.g., bound(expiredOffset, 1, block.timestamp)), and remove the
vm.assume line; adjust the order so you call deployMock(), mintVoter1(),
createProposal(), vm.warp(... governor.proposalUpdatablePeriod() +
governor.votingDelay()), then compute expiredOffset via bound(expiredOffset, 1,
block.timestamp) and derive deadline = block.timestamp - expiredOffset.
test/GovUpgrade.t.sol (2)

271-273: ⚡ Quick win

Consider specifying the expected revert reason.

Using vm.expectRevert() without a specific error could mask bugs if the upgrade fails for an unexpected reason. Specifying the exact error would improve test reliability and help identify regressions faster.

♻️ Proposed enhancement

Looking at the Manager contract pattern in this codebase, the specific error should likely be related to the Manager's upgrade registration check. Consider adding the expected error message:

        // Attempt upgrade without registration should fail
        vm.prank(address(treasury));
-       vm.expectRevert();
+       vm.expectRevert(); // TODO: Add specific error from Manager contract
        governor.upgradeTo(address(newGovernorImpl));

Or if there's a specific error from the upgrade validation logic, use it:

-       vm.expectRevert();
+       vm.expectRevert(abi.encodeWithSignature("INVALID_UPGRADE(address,address)", address(governorImpl), address(newGovernorImpl)));
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/GovUpgrade.t.sol` around lines 271 - 273, Replace the generic
vm.expectRevert() with a targeted expectRevert that asserts the exact revert
reason thrown by the Manager/upgrade validation so the test fails only for the
intended condition; locate the Manager contract's upgrade registration or
validation revert string (the error emitted by the function that blocks
unauthorized upgrades) and use vm.expectRevert(abi.encodePacked("<exact revert
string>")) (or the appropriate bytes/error selector) immediately before
vm.prank(address(treasury)); governor.upgradeTo(address(newGovernorImpl)) so the
test checks for that specific revert from governor.upgradeTo.

15-17: 💤 Low value

Consider removing the redundant setUp function.

The override only calls super.setUp() without any additional logic. When there's no child-specific setup, the function can be removed entirely and the parent's setUp will be called automatically.

♻️ Proposed cleanup
-    function setUp() public override {
-        super.setUp();
-    }
-
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/GovUpgrade.t.sol` around lines 15 - 17, The setUp() override in
GovUpgrade.t.sol is redundant because it only calls super.setUp(); remove the
entire function declaration (the setUp() override that calls super.setUp()) so
the parent setUp() will be used automatically; if you later need child-specific
initialization, reintroduce an override that calls super.setUp() and adds the
extra logic.
src/governance/governor/IGovernor.sol (1)

314-320: ⚡ Quick win

Expose the replacement successor in IGovernor.

state() can now return Replaced, but the interface does not expose the replacement id. The concrete contract already has a public proposalIdReplacedBy getter via storage, so callers compiled against IGovernor cannot follow the replacement chain from the interface alone.

💡 Suggested addition
+    /// `@notice` The replacement proposal id for a replaced proposal
+    /// `@param` proposalId The original proposal id
+    function proposalIdReplacedBy(bytes32 proposalId) external view returns (bytes32);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/governance/governor/IGovernor.sol` around lines 314 - 320, The IGovernor
interface must expose the replacement successor so callers can follow a Replaced
state chain; add a public getter declaration matching the concrete contract's
storage accessor (e.g., proposalIdReplacedBy) to IGovernor (signature like
proposalIdReplacedBy(bytes32) external view returns (bytes32)) so callers
compiled against IGovernor can retrieve the replacement proposal id when state()
returns Replaced; update the interface near the other proposal getters
(alongside getProposalSigners and proposalUpdatePeriodEnd).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/frontend-migration-guide.md`:
- Around line 331-338: The while-loop in getLatestProposalId incorrectly uses
the ethers v5 constant ethers.constants.HashZero; update the zero-hash check to
use the ethers v6 API by replacing ethers.constants.HashZero with
ethers.ZeroHash wherever getLatestProposalId calls governor.proposalIdReplacedBy
(and any similar replacement-chain checks) so the loop compares against the
correct zero hash constant.

In `@src/governance/governor/Governor.sol`:
- Around line 952-964: The update path currently allows arbitrarily many
signers; when handling an update in updateProposalBySigs (after receiving
newSigners from _validateUpdateSignaturesAndGetVotes but before calling
_replaceProposalWithSigners) enforce the same cap used by proposeBySigs by
checking newSigners.length <= MAX_PROPOSAL_SIGNERS and revert (e.g., with a new
or existing error) if exceeded; alternatively add the check inside
_validateUpdateSignaturesAndGetVotes so it never returns an oversized signer
array—this prevents unbounded gas/storage growth that would affect cancel() and
getProposalSigners().
- Around line 882-886: The loop in the helper copies old signer addresses into
proposalSigners[newProposalId] (using newSigners and _oldSigners), which
incorrectly carries prior approvals onto an unsigned replacement; remove the
copy so proposalSigners[newProposalId] remains empty (no push of _oldSigners
into newSigners) and ensure updateProposal()/updateProposalBySigs() flow
requires fresh signatures for the replacement (or explicitly document/implement
requiring updateProposalBySigs for persistent backing) so
getProposalSigners(newProposalId) and cancel() reflect only actual signers of
the new proposal.

In `@test/Gov.t.sol`:
- Around line 2728-2740: The test mints tokens to address(auction) so delegating
via token.delegate(address(wallet)) gives the ERC-1271 wallet zero votes; change
the minting so the wallet actually receives tokens before delegating (e.g. call
token.mint() while vm.prank(address(wallet)) or use the contract's
mintTo(wallet) helper if available) and make the same adjustment in the second
occurrence (the block around lines 2792-2800) so the wallet holds tokens prior
to token.delegate(address(wallet)) and the mixed-signer assertions exercise
contract voting weight.
- Around line 1489-1490: The tests never exercise non-zero proposalThreshold
because total supply after mintVoter1() is too small so
governor.updateProposalThresholdBps(1) still rounds proposalThreshold() to 0;
fix by increasing circulating supply before calling
governor.updateProposalThresholdBps (either mint additional tokens in
mintVoter1() or call an extra mint helper so totalSupply >= ceil(requiredBps *
denominator / 10000)), or use a much larger BPS value so that
proposalThreshold() > 0 for the current supply; update the test cases around
mintVoter1(), governor.updateProposalThresholdBps(...), and assertions on
proposalThreshold() to ensure the non-zero branch is actually reached.

---

Nitpick comments:
In `@src/governance/governor/IGovernor.sol`:
- Around line 314-320: The IGovernor interface must expose the replacement
successor so callers can follow a Replaced state chain; add a public getter
declaration matching the concrete contract's storage accessor (e.g.,
proposalIdReplacedBy) to IGovernor (signature like proposalIdReplacedBy(bytes32)
external view returns (bytes32)) so callers compiled against IGovernor can
retrieve the replacement proposal id when state() returns Replaced; update the
interface near the other proposal getters (alongside getProposalSigners and
proposalUpdatePeriodEnd).

In `@test/GovFuzz.t.sol`:
- Around line 80-89: The test currently uses vm.assume(expiredOffset <=
block.timestamp) which rejects many fuzz cases; instead move the bounding of
expiredOffset to after the vm.warp and replace bound(expiredOffset, 1, 365 days)
with a dynamic upper bound using the current timestamp (e.g.,
bound(expiredOffset, 1, block.timestamp)), and remove the vm.assume line; adjust
the order so you call deployMock(), mintVoter1(), createProposal(), vm.warp(...
governor.proposalUpdatablePeriod() + governor.votingDelay()), then compute
expiredOffset via bound(expiredOffset, 1, block.timestamp) and derive deadline =
block.timestamp - expiredOffset.

In `@test/GovGasBenchmark.t.sol`:
- Around line 338-374: Move the duplicated helper functions
_buildOrderedUpdateSignatures and _mintTokensToUsers out of
GovGasBenchmark.t.sol into the shared test base (e.g., Gov.t.sol or a new helper
mixin) so all benchmark/fuzz suites use the same implementations; ensure the
moved _buildOrderedUpdateSignatures continues to reference
UPDATE_PROPOSAL_TYPEHASH, governor.DOMAIN_SEPARATOR(), and _encodeSignature and
that callers still pass proposer/nonce/deadline, and ensure _mintTokensToUsers
continues to use token.mint(), auction, token.transferFrom(...) and otherUsers;
after moving, remove the duplicates and update any imports/inheritance so tests
compile and reference the single shared helper.

In `@test/GovUpgrade.t.sol`:
- Around line 271-273: Replace the generic vm.expectRevert() with a targeted
expectRevert that asserts the exact revert reason thrown by the Manager/upgrade
validation so the test fails only for the intended condition; locate the Manager
contract's upgrade registration or validation revert string (the error emitted
by the function that blocks unauthorized upgrades) and use
vm.expectRevert(abi.encodePacked("<exact revert string>")) (or the appropriate
bytes/error selector) immediately before vm.prank(address(treasury));
governor.upgradeTo(address(newGovernorImpl)) so the test checks for that
specific revert from governor.upgradeTo.
- Around line 15-17: The setUp() override in GovUpgrade.t.sol is redundant
because it only calls super.setUp(); remove the entire function declaration (the
setUp() override that calls super.setUp()) so the parent setUp() will be used
automatically; if you later need child-specific initialization, reintroduce an
override that calls super.setUp() and adds the extra logic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fdd6265e-2632-4e51-a195-961ac22e114d

📥 Commits

Reviewing files that changed from the base of the PR and between cfb42d8 and 03e428f.

📒 Files selected for processing (20)
  • docs/README.md
  • docs/deployment-workflows.md
  • docs/frontend-migration-guide.md
  • docs/governor-architecture.md
  • docs/governor-audit-readiness.md
  • docs/governor-proposal-lifecycle.md
  • docs/mainnet-v2-upgrade-runbook.md
  • docs/upgrade-runbook.md
  • package.json
  • src/governance/governor/Governor.sol
  • src/governance/governor/IGovernor.sol
  • src/governance/governor/storage/GovernorStorageV3.sol
  • src/governance/governor/types/GovernorTypesV1.sol
  • test/Gov.t.sol
  • test/GovFuzz.t.sol
  • test/GovGasBenchmark.t.sol
  • test/GovUpgrade.t.sol
  • test/MetadataRenderer.t.sol
  • test/Token.t.sol
  • test/utils/mocks/MockERC1271Wallet.sol
💤 Files with no reviewable changes (1)
  • docs/mainnet-v2-upgrade-runbook.md

Comment on lines +331 to +338
async function getLatestProposalId(proposalId) {
let currentId = proposalId;
let replacedBy = await governor.proposalIdReplacedBy(currentId);

// Follow replacement chain to get latest version
while (replacedBy !== ethers.constants.HashZero) {
currentId = replacedBy;
replacedBy = await governor.proposalIdReplacedBy(currentId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In ethers.js v6, what is the equivalent of ethers.constants.HashZero from ethers.js v5?

💡 Result:

In ethers.js v6, the equivalent of ethers.constants.HashZero from v5 is ethers.ZeroHash [1][2]. In v5, constants were grouped under the ethers.constants object [3]. In v6, these constants have been moved to the top-level ethers object [1][4]. Similarly, ethers.constants.AddressZero has been replaced by ethers.ZeroAddress [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="docs/frontend-migration-guide.md"
nl -ba "$FILE" | sed -n '320,350p'

Repository: BuilderOSS/nouns-protocol

Length of output: 112


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="docs/frontend-migration-guide.md"
cat -n "$FILE" | sed -n '320,350p'

Repository: BuilderOSS/nouns-protocol

Length of output: 1010


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="docs/frontend-migration-guide.md"

echo "== HashZero occurrences =="
rg -n "ethers\.constants\.HashZero" "$FILE" || true

echo "== ZeroHash occurrences =="
rg -n "ethers\.ZeroHash" "$FILE" || true

echo "== Context around HashZero =="
rg -n "ethers\.constants\.HashZero" "$FILE" -C 5 || true

Repository: BuilderOSS/nouns-protocol

Length of output: 624


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="docs/frontend-migration-guide.md"

rg -n "ethers\.?js v6|ethers v6|\bv6\b|ZeroAddress|constants\.HashZero|HashZero|ethers\.ZeroHash" "$FILE" || true

# Also grab the section header(s) around line 331
sed -n '280,380p' "$FILE" | nl -ba -w2 -s': ' | head -n 120

Repository: BuilderOSS/nouns-protocol

Length of output: 215


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="docs/frontend-migration-guide.md"

echo "== Around ethers.js v6 section (90-180) =="
sed -n '90,180p' "$FILE"

echo
echo "== Around snippet (300-370) =="
sed -n '300,370p' "$FILE"

Repository: BuilderOSS/nouns-protocol

Length of output: 3969


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="docs/frontend-migration-guide.md"

echo "== ethers.js section headings =="
rg -n "^#### Using ethers\.js v" "$FILE" || true

echo
echo "== ethers.constants.* occurrences =="
rg -n "ethers\.constants\." "$FILE" || true

Repository: BuilderOSS/nouns-protocol

Length of output: 225


Update replacement-chain zero-hash check to ethers v6 (ethers.ZeroHash).

docs/frontend-migration-guide.md (line 336) uses ethers.constants.HashZero (ethers v5 API); in ethers v6 this should be ethers.ZeroHash to prevent copy/paste failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/frontend-migration-guide.md` around lines 331 - 338, The while-loop in
getLatestProposalId incorrectly uses the ethers v5 constant
ethers.constants.HashZero; update the zero-hash check to use the ethers v6 API
by replacing ethers.constants.HashZero with ethers.ZeroHash wherever
getLatestProposalId calls governor.proposalIdReplacedBy (and any similar
replacement-chain checks) so the loop compares against the correct zero hash
constant.

Comment thread src/governance/governor/Governor.sol Outdated
Comment on lines +882 to +886
address[] storage newSigners = proposalSigners[newProposalId];
uint256 oldSignersLen = _oldSigners.length;
for (uint256 i; i < oldSignersLen; ++i) {
newSigners.push(_oldSigners[i]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not carry signer approvals onto an unsigned replacement.

When updateProposal() reaches this helper, the loop on Lines 882-886 copies the old signer list onto newProposalId even though those addresses never signed the replacement payload. That makes getProposalSigners(newProposalId) inaccurate and gives stale signers cancel authority through cancel().

💡 Suggested fix
-        address[] storage newSigners = proposalSigners[newProposalId];
-        uint256 oldSignersLen = _oldSigners.length;
-        for (uint256 i; i < oldSignersLen; ++i) {
-            newSigners.push(_oldSigners[i]);
-        }

If signer backing should persist on the replacement, require fresh approvals via updateProposalBySigs() instead of reusing the old signer list.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
address[] storage newSigners = proposalSigners[newProposalId];
uint256 oldSignersLen = _oldSigners.length;
for (uint256 i; i < oldSignersLen; ++i) {
newSigners.push(_oldSigners[i]);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/governance/governor/Governor.sol` around lines 882 - 886, The loop in the
helper copies old signer addresses into proposalSigners[newProposalId] (using
newSigners and _oldSigners), which incorrectly carries prior approvals onto an
unsigned replacement; remove the copy so proposalSigners[newProposalId] remains
empty (no push of _oldSigners into newSigners) and ensure
updateProposal()/updateProposalBySigs() flow requires fresh signatures for the
replacement (or explicitly document/implement requiring updateProposalBySigs for
persistent backing) so getProposalSigners(newProposalId) and cancel() reflect
only actual signers of the new proposal.

Comment thread src/governance/governor/Governor.sol Outdated
Comment on lines +952 to +964
// If original proposal had signers, update must also have signers
if (proposalSigners[_proposalId].length > 0 && _proposerSignatures.length == 0) revert MUST_PROVIDE_SIGNATURES();

bytes32 descriptionHash = keccak256(bytes(_description));
bytes32 updatedProposalId = hashProposal(_targets, _values, _calldatas, descriptionHash, _proposer);

// Validate new signatures and collect votes (signers can be different from original)
(uint256 totalVotes, address[] memory newSigners) =
_validateUpdateSignaturesAndGetVotes(_proposalId, updatedProposalId, _proposer, _proposerSignatures);

if (totalVotes <= proposalThreshold()) revert VOTES_BELOW_PROPOSAL_THRESHOLD();

bytes32 newProposalId = _replaceProposalWithSigners(_proposalId, oldProposal, _targets, _values, _calldatas, descriptionHash, newSigners);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-enforce MAX_PROPOSAL_SIGNERS on the update path.

proposeBySigs() caps signer count, but updateProposalBySigs() never does. A proposal can therefore start with a small signer set and then be updated with an arbitrarily large array, which makes cancel() and getProposalSigners() unbounded in gas/storage.

💡 Suggested fix
         // If original proposal had signers, update must also have signers
         if (proposalSigners[_proposalId].length > 0 && _proposerSignatures.length == 0) revert MUST_PROVIDE_SIGNATURES();
+        if (_proposerSignatures.length > MAX_PROPOSAL_SIGNERS) revert TOO_MANY_SIGNERS();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// If original proposal had signers, update must also have signers
if (proposalSigners[_proposalId].length > 0 && _proposerSignatures.length == 0) revert MUST_PROVIDE_SIGNATURES();
bytes32 descriptionHash = keccak256(bytes(_description));
bytes32 updatedProposalId = hashProposal(_targets, _values, _calldatas, descriptionHash, _proposer);
// Validate new signatures and collect votes (signers can be different from original)
(uint256 totalVotes, address[] memory newSigners) =
_validateUpdateSignaturesAndGetVotes(_proposalId, updatedProposalId, _proposer, _proposerSignatures);
if (totalVotes <= proposalThreshold()) revert VOTES_BELOW_PROPOSAL_THRESHOLD();
bytes32 newProposalId = _replaceProposalWithSigners(_proposalId, oldProposal, _targets, _values, _calldatas, descriptionHash, newSigners);
// If original proposal had signers, update must also have signers
if (proposalSigners[_proposalId].length > 0 && _proposerSignatures.length == 0) revert MUST_PROVIDE_SIGNATURES();
if (_proposerSignatures.length > MAX_PROPOSAL_SIGNERS) revert TOO_MANY_SIGNERS();
bytes32 descriptionHash = keccak256(bytes(_description));
bytes32 updatedProposalId = hashProposal(_targets, _values, _calldatas, descriptionHash, _proposer);
// Validate new signatures and collect votes (signers can be different from original)
(uint256 totalVotes, address[] memory newSigners) =
_validateUpdateSignaturesAndGetVotes(_proposalId, updatedProposalId, _proposer, _proposerSignatures);
if (totalVotes <= proposalThreshold()) revert VOTES_BELOW_PROPOSAL_THRESHOLD();
bytes32 newProposalId = _replaceProposalWithSigners(_proposalId, oldProposal, _targets, _values, _calldatas, descriptionHash, newSigners);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/governance/governor/Governor.sol` around lines 952 - 964, The update path
currently allows arbitrarily many signers; when handling an update in
updateProposalBySigs (after receiving newSigners from
_validateUpdateSignaturesAndGetVotes but before calling
_replaceProposalWithSigners) enforce the same cap used by proposeBySigs by
checking newSigners.length <= MAX_PROPOSAL_SIGNERS and revert (e.g., with a new
or existing error) if exceeded; alternatively add the check inside
_validateUpdateSignaturesAndGetVotes so it never returns an oversized signer
array—this prevents unbounded gas/storage growth that would affect cancel() and
getProposalSigners().

Comment thread test/Gov.t.sol
Comment on lines +1489 to +1490
vm.prank(address(treasury));
governor.updateProposalThresholdBps(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

These threshold tests never leave the zero-threshold path.

After mintVoter1() the supply here is only 1, so updateProposalThresholdBps(1) and updateProposalThresholdBps(500) both make proposalThreshold() round down to 0. That means the cancel-boundary and creation-threshold assertions pass without exercising the non-zero threshold logic they claim to cover.

🛠️ Example adjustment
-        governor.updateProposalThresholdBps(1);
+        governor.updateProposalThresholdBps(10_000);
...
-        governor.updateProposalThresholdBps(500); // 5% threshold
+        governor.updateProposalThresholdBps(10_000); // 1 token supply => threshold of 1

Also applies to: 2418-2421

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/Gov.t.sol` around lines 1489 - 1490, The tests never exercise non-zero
proposalThreshold because total supply after mintVoter1() is too small so
governor.updateProposalThresholdBps(1) still rounds proposalThreshold() to 0;
fix by increasing circulating supply before calling
governor.updateProposalThresholdBps (either mint additional tokens in
mintVoter1() or call an extra mint helper so totalSupply >= ceil(requiredBps *
denominator / 10000)), or use a much larger BPS value so that
proposalThreshold() > 0 for the current supply; update the test cases around
mintVoter1(), governor.updateProposalThresholdBps(...), and assertions on
proposalThreshold() to ensure the non-zero branch is actually reached.

Comment thread test/Gov.t.sol
Comment on lines +2728 to +2740
// Mint to both wallet and voter1
vm.prank(address(auction));
token.mint(); // to wallet

mintVoter1(); // to voter1 EOA

vm.prank(address(wallet));
token.delegate(address(wallet));

vm.warp(block.timestamp + 1);

vm.prank(address(treasury));
governor.updateProposalThresholdBps(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

The ERC-1271 wallet never gets voting power in this mixed-signer test.

token.mint() under address(auction) mints to the auction, not to wallet, so the subsequent token.delegate(address(wallet)) delegates zero votes. With proposalThresholdBps(1) also rounding to 0, this test can still pass even if contract signers contribute no weight at all.

🛠️ Example adjustment
-        vm.prank(address(auction));
-        token.mint(); // to wallet
+        vm.startPrank(address(auction));
+        uint256 walletTokenId = token.mint();
+        token.transferFrom(address(auction), address(wallet), walletTokenId);
+        vm.stopPrank();
...
-        governor.updateProposalThresholdBps(1);
+        governor.updateProposalThresholdBps(10_000);

Also applies to: 2792-2800

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/Gov.t.sol` around lines 2728 - 2740, The test mints tokens to
address(auction) so delegating via token.delegate(address(wallet)) gives the
ERC-1271 wallet zero votes; change the minting so the wallet actually receives
tokens before delegating (e.g. call token.mint() while vm.prank(address(wallet))
or use the contract's mintTo(wallet) helper if available) and make the same
adjustment in the second occurrence (the block around lines 2792-2800) so the
wallet holds tokens prior to token.delegate(address(wallet)) and the
mixed-signer assertions exercise contract voting weight.

@dan13ram dan13ram force-pushed the feat/updatable-proposals branch from 64e8260 to 9127436 Compare May 26, 2026 15:37
@dan13ram dan13ram force-pushed the feat/updatable-proposals branch from 8027c1f to 3983757 Compare May 26, 2026 16:39
@dan13ram dan13ram force-pushed the feat/updatable-proposals branch from bacd89e to ba72aa6 Compare May 27, 2026 13:12
@dan13ram dan13ram force-pushed the feat/updatable-proposals branch from 6cc7aa8 to f18bedf Compare June 1, 2026 16:41
@dan13ram dan13ram force-pushed the feat/updatable-proposals branch from 722e730 to 410ce09 Compare June 2, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant