Skip to content

Commit 07b9496

Browse files
committed
fix: address tmnt 143
Please read [contributing guidelines](CONTRIBUTING.md) and remove this line. For audit-related pull requests, please use the [audit PR template](?expand=1&template=audit.md).
1 parent f82c5e4 commit 07b9496

File tree

5 files changed

+233
-16
lines changed

5 files changed

+233
-16
lines changed

l1-contracts/src/governance/GSEPayload.sol

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pragma solidity >=0.8.27;
44

55
import {IGSE} from "@aztec/governance/GSE.sol";
6+
import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol";
67
import {Errors} from "@aztec/governance/libraries/Errors.sol";
78
import {IPayload} from "./interfaces/IPayload.sol";
89
import {IProposerPayload} from "./interfaces/IProposerPayload.sol";
@@ -34,10 +35,12 @@ import {IProposerPayload} from "./interfaces/IProposerPayload.sol";
3435
contract GSEPayload is IProposerPayload {
3536
IPayload public immutable ORIGINAL;
3637
IGSE public immutable GSE;
38+
IRegistry public immutable REGISTRY;
3739

38-
constructor(IPayload _originalPayloadProposal, IGSE _gse) {
40+
constructor(IPayload _originalPayloadProposal, IGSE _gse, IRegistry _registry) {
3941
ORIGINAL = _originalPayloadProposal;
4042
GSE = _gse;
43+
REGISTRY = _registry;
4144
}
4245

4346
function getOriginalPayload() external view override(IProposerPayload) returns (IPayload) {
@@ -68,17 +71,54 @@ contract GSEPayload is IProposerPayload {
6871
}
6972

7073
/**
71-
* @notice We see the proposal as valid if after its execution,
72-
* the latest rollup in the GSE (including the "bonus instance")
73-
* have >2/3 of total stake.
74+
* @notice Validates that the proposal maintains governance system integrity by ensuring
75+
* sufficient stake remains on the active rollup after execution.
7476
*
75-
* @dev This function is ONLY meant to be called by the entity executing the proposal, i.e. Governance.
76-
* As you can see, a call to this function is embedded in the `getActions` above, and its return value
77-
* is effectively meaningless outside the context of this payload's execution by Governance.
77+
* The validation passes when EITHER:
78+
* 1. The latest rollup (plus bonus instance) has >2/3 of total stake, OR
79+
* 2. A Registry/GSE mismatch is detected (fail-open to prevent governance livelock)
80+
*
81+
* @dev The "bonus instance" is a special GSE mechanism where attesters automatically
82+
* follow the latest rollup without re-depositing. Their stake counts toward
83+
* the latest rollup's total for this validation.
84+
*
85+
* @dev LIVELOCK PREVENTION: When canonical != latest, we intentionally return true
86+
* to bypass validation. This mismatch typically indicates the GovernanceProposer
87+
* is still pointing to a stale GSE contract after a rollup upgrade.
88+
*
89+
* Why this creates a livelock:
90+
* - The stale GSE tracks an outdated rollup as "latest"
91+
* - The Registry correctly identifies the new rollup as canonical
92+
* - Economic incentives drive attesters to follow the canonical (where rewards are)
93+
* - The stale GSE's "latest" gradually bleeds stake as rational actors exit
94+
* - While theoretically possible to maintain >2/3 stake, it becomes increasingly
95+
* unlikely as only inattentive or non-reward-seeking attesters remain
96+
* - Proposals keep failing validation, creating a probabilistic livelock where
97+
* progress is technically possible but economically improbable
98+
*
99+
* By returning true, we provide an escape hatch that allows governance to
100+
* continue functioning despite the misconfiguration, enabling corrective
101+
* proposals to update the GovernanceProposer's GSE reference.
102+
*
103+
* @dev This function executes as the final action of the proposal (see getActions).
104+
* It either reverts with an error (proposal invalid) or returns true (proposal valid).
105+
* The boolean return value is effectively ceremonial - only the revert matters.
106+
*
107+
* @return Always returns true if the proposal is valid; reverts otherwise
78108
*/
79109
function amIValid() external view override(IProposerPayload) returns (bool) {
80-
uint256 totalSupply = GSE.totalSupply();
110+
address canonicalRollup = address(REGISTRY.getCanonicalRollup());
81111
address latestRollup = GSE.getLatestRollup();
112+
113+
// Bypass validation on mismatch to prevent economically-driven livelock
114+
// In theory, >2/3 stake could remain on the stale rollup, but economic
115+
// incentives make this highly unlikely
116+
if (canonicalRollup != latestRollup) {
117+
return true;
118+
}
119+
120+
// Standard validation: ensure >2/3 of stake remains with the latest rollup
121+
uint256 totalSupply = GSE.totalSupply();
82122
address bonusInstance = GSE.getBonusInstanceAddress();
83123
uint256 effectiveSupplyOfLatestRollup = GSE.supplyOf(latestRollup) + GSE.supplyOf(bonusInstance);
84124

l1-contracts/src/governance/proposer/GovernanceProposer.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ contract GovernanceProposer is IGovernanceProposer, EmpireBase {
8686
* @return true if the proposal was proposed successfully, reverts otherwise.
8787
*/
8888
function _handleRoundWinner(IPayload _payload) internal override(EmpireBase) returns (bool) {
89-
GSEPayload extendedPayload = new GSEPayload(_payload, GSE);
89+
GSEPayload extendedPayload = new GSEPayload(_payload, GSE, REGISTRY);
9090
uint256 proposalId = IGovernance(getGovernance()).propose(IPayload(address(extendedPayload)));
9191
proposalProposer[proposalId] = getInstance();
9292
return true;

l1-contracts/test/governance/governance-proposer/mocks/Fakerollup.sol

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ pragma solidity >=0.8.27;
33

44
import {Timestamp, Slot, Epoch, TimeLib} from "@aztec/core/libraries/TimeLib.sol";
55
import {TestConstants} from "../../../harnesses/TestConstants.sol";
6+
import {IHaveVersion} from "@aztec/governance/interfaces/IRegistry.sol";
67

7-
contract Fakerollup {
8+
contract Fakerollup is IHaveVersion {
89
using TimeLib for Slot;
910
using TimeLib for Timestamp;
1011

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
// SPDX-License-Identifier: UNLICENSED
2+
pragma solidity >=0.8.27;
3+
4+
import {GovernanceProposerBase} from "../Base.t.sol";
5+
6+
import {IPayload} from "@aztec/governance/interfaces/IPayload.sol";
7+
import {Slot, Timestamp} from "@aztec/core/libraries/TimeLib.sol";
8+
import {Fakerollup} from "../mocks/Fakerollup.sol";
9+
import {IRollup} from "@aztec/core/interfaces/IRollup.sol";
10+
import {Signature} from "@aztec/shared/libraries/SignatureLib.sol";
11+
import {MessageHashUtils} from "@oz/utils/cryptography/MessageHashUtils.sol";
12+
import {TestERC20} from "@aztec/mock/TestERC20.sol";
13+
import {Registry} from "@aztec/governance/Registry.sol";
14+
import {IRegistry} from "@aztec/governance/interfaces/IRegistry.sol";
15+
import {IInstance} from "@aztec/core/interfaces/IInstance.sol";
16+
import {GovernanceProposer} from "@aztec/governance/proposer/GovernanceProposer.sol";
17+
import {FakeGovernance} from "../Base.t.sol";
18+
import {TestBase} from "@test/base/Base.sol";
19+
import {GSEPayload} from "@aztec/governance/GSEPayload.sol";
20+
import {Governance} from "@aztec/governance/Governance.sol";
21+
import {TestConstants} from "../../../harnesses/TestConstants.sol";
22+
import {Proposal, ProposalState} from "@aztec/governance/interfaces/IGovernance.sol";
23+
import {ProposalLib} from "@aztec/governance/libraries/ProposalLib.sol";
24+
import {IGSE} from "@aztec/governance/GSE.sol";
25+
26+
contract MisconfiguredPayload is IPayload {
27+
IRegistry public immutable REGISTRY;
28+
IInstance public immutable ROLLUP;
29+
30+
constructor(IRegistry _registry, IInstance _rollup) {
31+
REGISTRY = _registry;
32+
ROLLUP = _rollup;
33+
}
34+
35+
function getActions() external view override(IPayload) returns (IPayload.Action[] memory) {
36+
IPayload.Action[] memory res = new IPayload.Action[](1);
37+
38+
res[0] =
39+
Action({target: address(REGISTRY), data: abi.encodeWithSelector(IRegistry.addRollup.selector, address(ROLLUP))});
40+
41+
return res;
42+
}
43+
44+
function getURI() external pure override(IPayload) returns (string memory) {
45+
return "MisconfiguredPayload";
46+
}
47+
}
48+
49+
contract FakeGSE {
50+
address public latestRollup;
51+
uint256 public totalSupply;
52+
address public bonusInstance;
53+
mapping(address instance => uint256 supply) public supplyOf;
54+
55+
function setLatestRollup(address _latestRollup) external {
56+
latestRollup = _latestRollup;
57+
}
58+
59+
function setSupplyOf(address _instance, uint256 _supply) external {
60+
totalSupply -= supplyOf[_instance];
61+
supplyOf[_instance] = _supply;
62+
totalSupply += _supply;
63+
}
64+
65+
function setBonusInstanceAddress(address _bonusInstance) external {
66+
bonusInstance = _bonusInstance;
67+
}
68+
69+
function getLatestRollup() external view returns (address) {
70+
return latestRollup;
71+
}
72+
73+
function getTotalSupply() external view returns (uint256) {
74+
return totalSupply;
75+
}
76+
77+
function getBonusInstanceAddress() external view returns (address) {
78+
return bonusInstance;
79+
}
80+
}
81+
82+
// https://linear.app/aztec-labs/issue/TMNT-143/spearbit-gov-finding-8-governance-deadlock
83+
contract TestTmnt143 is TestBase {
84+
using ProposalLib for Proposal;
85+
86+
TestERC20 public asset;
87+
Registry public registry;
88+
FakeGSE public gse;
89+
GovernanceProposer public governanceProposer;
90+
Fakerollup public rollup;
91+
Governance internal governance;
92+
Proposal internal proposal;
93+
94+
function setUp() external {
95+
asset = new TestERC20("test", "TEST", address(this));
96+
registry = new Registry(address(this), new TestERC20("test", "TEST", address(this)));
97+
gse = new FakeGSE();
98+
99+
governanceProposer = new GovernanceProposer(registry, IGSE(address(gse)), 6, 10);
100+
101+
rollup = new Fakerollup();
102+
registry.addRollup(rollup);
103+
104+
governance =
105+
new Governance(asset, address(governanceProposer), address(gse), TestConstants.getGovernanceConfiguration());
106+
107+
registry.transferOwnership(address(governance));
108+
109+
vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(rollup.getCurrentSlot() + Slot.wrap(10))));
110+
}
111+
112+
function test_livelock() external {
113+
// Make a proposal to move to a new GSE, but people leave early so less than 2/3 on the latest in the GSE.
114+
115+
vm.prank(address(governance));
116+
governance.openFloodgates();
117+
118+
vm.prank(asset.owner());
119+
asset.mint(address(this), 10_000e18);
120+
asset.approve(address(governance), 10_000e18);
121+
governance.deposit(address(this), 10_000e18);
122+
123+
// We setup the gse state where 70% percent is on the current instance (half directly, half from following)
124+
// The last 30% are then on older rollups.
125+
gse.setLatestRollup(address(rollup));
126+
gse.setSupplyOf(address(rollup), 3500e18);
127+
address bonusInstance = address(0xbeef);
128+
gse.setBonusInstanceAddress(bonusInstance);
129+
gse.setSupplyOf(bonusInstance, 3500e18);
130+
131+
address oldRandomRollup = address(0x1234);
132+
gse.setSupplyOf(oldRandomRollup, 3000e18);
133+
134+
Fakerollup newRollup = new Fakerollup();
135+
136+
MisconfiguredPayload payload = new MisconfiguredPayload(registry, IInstance(address(newRollup)));
137+
138+
for (uint256 i = 0; i < 10; i++) {
139+
address proposer = rollup.getCurrentProposer();
140+
vm.prank(proposer);
141+
governanceProposer.signal(payload);
142+
vm.warp(Timestamp.unwrap(rollup.getTimestampForSlot(rollup.getCurrentSlot() + Slot.wrap(1))));
143+
}
144+
145+
governanceProposer.submitRoundWinner(1);
146+
proposal = governance.getProposal(0);
147+
148+
// At this point, we expect the new rollup to be gaining traction, so people that follows (on bonus)
149+
// exit from the GSE, because they want to be on the one with reward. Those on the specific
150+
// are fine with staying, they like the tech/deployment.
151+
gse.setSupplyOf(bonusInstance, 0);
152+
153+
vm.warp(Timestamp.unwrap(proposal.pendingThrough()) + 1);
154+
155+
governance.vote(0, 10_000e18, true);
156+
157+
vm.warp(Timestamp.unwrap(proposal.activeThrough()) + 1);
158+
assertTrue(governance.getProposalState(0) == ProposalState.Queued);
159+
160+
vm.warp(Timestamp.unwrap(proposal.queuedThrough()) + 1);
161+
assertTrue(governance.getProposalState(0) == ProposalState.Executable);
162+
assertEq(governance.governanceProposer(), address(governanceProposer));
163+
164+
// Since the canonical != latest on the GSE. We expect there have been a misstep in the payload
165+
// e.g., forgot to add a new governance proposer not using the stale GSE.
166+
167+
governance.execute(0);
168+
169+
assertEq(address(registry.getCanonicalRollup()), address(newRollup));
170+
}
171+
}

yarn-project/end-to-end/src/e2e_p2p/upgrade_governance_proposer.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { jest } from '@jest/globals';
1212
import fs from 'fs';
1313
import os from 'os';
1414
import path from 'path';
15-
import { getAddress, getContract } from 'viem';
15+
import { encodeFunctionData, getAddress, getContract } from 'viem';
1616

1717
import { shouldCollectMetrics } from '../fixtures/fixtures.js';
1818
import { createNodes } from '../fixtures/setup_p2p_test.js';
@@ -181,11 +181,16 @@ describe('e2e_p2p_governance_proposer', () => {
181181
await waitL1Block();
182182

183183
t.logger.info(`Submitting winner of round ${govData.round}`);
184-
const txHash = await governanceProposer.write.submitRoundWinner([govData.round], {
185-
account: emperor,
186-
gas: 1_000_000n,
184+
185+
await l1TxUtils.sendAndMonitorTransaction({
186+
to: governanceProposer.address,
187+
data: encodeFunctionData({
188+
abi: GovernanceProposerAbi,
189+
functionName: 'submitRoundWinner',
190+
args: [govData.round],
191+
}),
187192
});
188-
await t.ctx.deployL1ContractsValues.l1Client.waitForTransactionReceipt({ hash: txHash });
193+
189194
t.logger.info(`Submitted winner of round ${govData.round}`);
190195

191196
const proposal = await governance.read.getProposal([0n]);
@@ -203,7 +208,7 @@ describe('e2e_p2p_governance_proposer', () => {
203208

204209
const proposalState = await governance.read.getProposal([0n]);
205210
t.logger.info(`Proposal state`, proposalState);
206-
400000000000000000000;
211+
207212
const timeToExecutable = timeToActive + proposal.config.votingDuration + proposal.config.executionDelay + 1n;
208213
t.logger.info(`Warping to ${timeToExecutable}`);
209214
await t.ctx.cheatCodes.eth.warp(Number(timeToExecutable));

0 commit comments

Comments
 (0)