Skip to content

Commit 1910c23

Browse files
Merge pull request #308 from valory-xyz/feat/proposal-24-dewhitelist-sameaddr-and-unnominate
Add governance proposal 24: de-whitelist same-address multisig, nominee cleanup, GuardCM allowlist
2 parents 2f12ff7 + dde69b3 commit 1910c23

11 files changed

Lines changed: 1299 additions & 0 deletions

.gitleaksignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,3 +545,4 @@ ee3cc126d87e1cf8f96d9e481caf66c218f88f2e:scripts/deployment/l2/globals_celo_main
545545
07ff4f4e6ef54185cc220500b127034e1f9b4b83:scripts/deployment/l2/globals_celo_mainnet.json:generic-api-key:46
546546
c44128526ace9324dd81f67cedc266e55400d182:scripts/deployment/l2/globals_celo_mainnet.json:generic-api-key:46
547547
cbf9b49b0643c42d50af9dadbdb7356cc908cb43:test/SafeAndDepositWalletCreatorFork.sol:generic-api-key:227
548+
75ed94aa359e0377bbbfc83ec2fa43265a858261:test/Proposal24ForkL1.t.sol:generic-api-key:36

scripts/proposals/proposal_24_dewhitelist_sameaddr_and_unnominate/Proposal24DewhitelistAndUnnominate.s.sol

Lines changed: 296 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Proposal 24 — de-whitelist GnosisSafeSameAddressMultisig, clean up nominees, extend GuardCM allowlist
2+
3+
A single GovernorOLAS proposal (41 actions) bundling three protocol-maintenance changes:
4+
5+
1. **De-whitelist the same-address multisig adapters** (8 calls) — `changeMultisigPermission(adapter, false)` on
6+
the `ServiceRegistry` (Ethereum) and `ServiceRegistryL2` (Gnosis, Polygon, Arbitrum, Optimism, Base, Celo,
7+
Mode). Removes the same-address multisig adoption path from service deployment. Mainnet is a direct Timelock
8+
call; each L2 is bridged through its mediator (AMB / FxRoot / Arbitrum Inbox retryable / OP-stack messenger).
9+
Polygon carries **two** such adapters — `GnosisSafeSameAddressMultisig` and `PolySafeSameAddressMultisig`
10+
batched as two concatenated tuples in the single FxRoot message.
11+
2. **Un-nominate staking contracts** (32 calls) — `VoteWeighting.removeNominee(bytes32, uint256)` for the
12+
retired (account, chainId) pairs across Ethereum, Gnosis, Base, Polygon, Optimism, Celo and Arbitrum.
13+
3. **Extend the GuardCM allowlist** (1 call) — `setTargetSelectorChainIds(...)` adding 19 (target, selector,
14+
chainId) emergency-pause triples (Dispenser `setPauseState`, ServiceManager / RegistriesManager `pause`,
15+
per-L2 `TargetDispenserL2.pause`) plus the Mode `ServiceRegistryL2.drain` / `ServiceRegistryTokenUtility.drain`
16+
backfill that Phase 0 omitted. All statuses = true (additions).
17+
18+
## Files
19+
- `Proposal24DewhitelistAndUnnominate.s.sol` — the builder (`buildProposal()` returns
20+
`targets/values/calldatas/description` and prints the `proposalId`). Bridge encodings mirror the autonolas-
21+
governance proposal-11 builder; the GuardCM batch mirrors its Phase-0 `setTargetSelectorChainIds`.
22+
- `description.txt` — the proposal description, byte-for-byte equal to the builder's `DESCRIPTION` (the
23+
`proposalId` is computed from `keccak256(abi.encode(targets, values, calldatas, keccak256(description)))`).
24+
- `estimate_arb_submission_cost.js` — Arbitrum retryable estimator (Arbitrum SDK `estimateAll`, +1000% buffers,
25+
`value = deposit * 10`); mirrors `scripts/proposals/proposal_15_*`. Re-run right before submission to refresh
26+
the baked `ARB_*` constants against L1 basefee drift.
27+
28+
## Fork tests (`test/`)
29+
Full propose → vote → queue → execute lifecycle on a mainnet fork plus per-L2 bridge-delivery sims:
30+
- `Proposal24ForkL1.t.sol` — through the live GovernorOLAS; asserts the mainnet de-whitelist, all 32 nominee
31+
removals, and all 19 GuardCM triples; Arbitrum retryable is executor-funded (Timelock keeps no balance).
32+
- `Proposal24ForkL2OpStack.t.sol` — Optimism / Base / Celo / Mode mediator replay → adapter de-whitelisted.
33+
- `Proposal24ForkL2Other.t.sol` — Gnosis (AMB) / Polygon (FxRoot) / Arbitrum (aliased Timelock) → de-whitelisted.
34+
35+
```
36+
forge test --match-contract Proposal24Fork -vvv # uses ETH_RPC / OP_RPC / ... or public RPC defaults
37+
forge script scripts/proposals/proposal_24_dewhitelist_sameaddr_and_unnominate/Proposal24DewhitelistAndUnnominate.s.sol:Proposal24DewhitelistAndUnnominate
38+
```
39+
40+
## Operational notes
41+
- **Nominee-removal timing:** `Dispenser.removeNominee` reverts within the last week of the ongoing epoch, so
42+
this proposal must be **executed with > 7 days left in the epoch** (epoch length is 14 days).
43+
- **Arbitrum value:** the executor of `execute()` supplies the retryable ETH (`ARB_RETRYABLE_VALUE`); it flows
44+
executor → Governor → Timelock → Inbox, so the Timelock needs no balance. Re-run the estimator before submission.

scripts/proposals/proposal_24_dewhitelist_sameaddr_and_unnominate/annotate.js

Lines changed: 303 additions & 0 deletions
Large diffs are not rendered by default.

scripts/proposals/proposal_24_dewhitelist_sameaddr_and_unnominate/calldata.json

Lines changed: 248 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Olas protocol security hardening, staking nominee cleanup, and Community Multisig guard whitelist extension. This proposal: (1) de-whitelists the same-address multisig implementations (GnosisSafeSameAddressMultisig on Ethereum and the ServiceRegistryL2 of each supported network: Gnosis, Polygon, Arbitrum, Optimism, Base, Celo, Mode; and additionally the PolySafeSameAddressMultisig on Polygon) by calling changeMultisigPermission(address,false), removing the same-address multisig adoption path from service deployment; (2) removes a set of staking contract nominees from the VoteWeighting contract by calling removeNominee(bytes32,uint256) for the corresponding (account, chainId) pairs across Ethereum, Gnosis, Base, Polygon, Optimism, Celo and Arbitrum; and (3) extends the Community Multisig GuardCM allowlist via setTargetSelectorChainIds with 19 additional (target, selector, chainId) combinations enabling emergency pause actions across Ethereum and all supported L2 networks (Dispenser setPauseState, ServiceManager and RegistriesManager pause, and TargetDispenserL2 pause on each L2) together with the Mode ServiceRegistryL2 and ServiceRegistryTokenUtility drain backfill. In accordance with Autonolas DAO Constitution at ipfs://bafybeibrhz6hnxsxcbv7dkzerq4chssotexb276pidzwclbytzj7m4t47u
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*global process*/
2+
// One-off estimator for proposal 24's Arbitrum de-whitelist retryable (mirrors proposal_15's method:
3+
// SDK estimateAll with default base + big buffers, then value = deposit * 10). Prints concrete numbers to
4+
// bake into the staging builder. Run: npx hardhat run scripts/proposals/proposal_24_dewhitelist_sameaddr_and_unnominate/estimate_arb_submission_cost.js
5+
const { ethers } = require("hardhat");
6+
const { L1ToL2MessageGasEstimator } = require("@arbitrum/sdk/dist/lib/message/L1ToL2MessageGasEstimator");
7+
const { EthBridger, getL2Network } = require("@arbitrum/sdk");
8+
const { getBaseFee } = require("@arbitrum/sdk/dist/lib/utils/lib");
9+
10+
async function main() {
11+
const AddressZero = ethers.constants.AddressZero;
12+
const TIMELOCK = "0x3C1fF68f5aa342D296d4DEe4Bb1cACCA912D95fE";
13+
const ARB_MEDIATOR_L2 = "0x4d30F68F5AA342d296d4deE4bB1Cacca912dA70F"; // aliased L1 Timelock on L2
14+
const SRL2_ARBITRUM = "0xE3607b00E75f6405248323A9417ff6b39B244b50";
15+
const SAME_ARBITRUM = "0xBb7e1D6Cb6F243D6bdE81CE92a9f2aFF7Fbe7eac";
16+
17+
const ALCHEMY_API_KEY_MAINNET = process.env.ALCHEMY_API_KEY_MAINNET;
18+
const mainnetProvider = new ethers.providers.JsonRpcProvider(
19+
ALCHEMY_API_KEY_MAINNET ? ("https://eth-mainnet.g.alchemy.com/v2/" + ALCHEMY_API_KEY_MAINNET)
20+
: "https://ethereum-rpc.publicnode.com");
21+
const arbitrumProvider = new ethers.providers.JsonRpcProvider("https://arb1.arbitrum.io/rpc");
22+
23+
// inner L2 call: ServiceRegistryL2.changeMultisigPermission(SAME_ARBITRUM, false)
24+
const iReg = new ethers.utils.Interface(["function changeMultisigPermission(address,bool)"]);
25+
const calldata = iReg.encodeFunctionData("changeMultisigPermission", [SAME_ARBITRUM, false]);
26+
27+
const l2Network = await getL2Network(arbitrumProvider);
28+
const ethBridger = new EthBridger(l2Network);
29+
const inboxAddress = ethBridger.l2Network.ethBridge.inbox;
30+
const est = new L1ToL2MessageGasEstimator(arbitrumProvider);
31+
32+
const overrides = {
33+
gasLimit: { base: undefined, min: ethers.BigNumber.from(2000000), percentIncrease: ethers.BigNumber.from(30) },
34+
maxSubmissionFee: { base: undefined, percentIncrease: ethers.BigNumber.from(1000) },
35+
maxFeePerGas: { base: undefined, percentIncrease: ethers.BigNumber.from(1000) },
36+
};
37+
38+
const p = await est.estimateAll(
39+
{ from: TIMELOCK, to: SRL2_ARBITRUM, l2CallValue: 0,
40+
excessFeeRefundAddress: ARB_MEDIATOR_L2, callValueRefundAddress: AddressZero, data: calldata },
41+
await getBaseFee(mainnetProvider), mainnetProvider, overrides);
42+
43+
// Bake the SDK's BUFFERED maxFeePerGas (the maxFeePerGas override above is +1000%), NOT the raw current
44+
// L2 gas price: msg.value being overfunded does not change the ticket's gas bid, so the explicit
45+
// maxFeePerGas argument must itself carry headroom or the retryable can fail to auto-redeem if L2 gas rises.
46+
const gasPriceBid = await arbitrumProvider.getGasPrice(); // raw, for reference only
47+
const value = p.deposit.mul(10); // deposit already uses the buffered maxFeePerGas
48+
49+
console.log("inbox:", inboxAddress);
50+
console.log("ARB_MAX_SUBMISSION_COST =", p.maxSubmissionCost.toString());
51+
console.log("ARB_GAS_LIMIT =", p.gasLimit.toString());
52+
console.log("ARB_MAX_FEE_PER_GAS =", p.maxFeePerGas.toString(), "(buffered; raw gasPrice =", gasPriceBid.toString() + ")");
53+
console.log("deposit =", p.deposit.toString());
54+
console.log("ARB_RETRYABLE_VALUE = deposit*10 =", value.toString());
55+
}
56+
57+
main().then(() => process.exit(0)).catch((e) => { console.error(e); process.exit(1); });

scripts/proposals/proposal_24_dewhitelist_sameaddr_and_unnominate/proposal_24_dewhitelist_sameaddr_and_unnominate.html

Lines changed: 33 additions & 0 deletions
Large diffs are not rendered by default.

test/Proposal24ForkL1.t.sol

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.30;
3+
4+
import {Test, console2} from "forge-std/Test.sol";
5+
import {Proposal24Builder} from "../scripts/proposals/proposal_24_dewhitelist_sameaddr_and_unnominate/Proposal24DewhitelistAndUnnominate.s.sol";
6+
7+
// Minimal OZ-Governor surface (GovernorOLAS).
8+
interface IGovernor {
9+
function propose(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description) external returns (uint256);
10+
function castVote(uint256 proposalId, uint8 support) external returns (uint256);
11+
function queue(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) external returns (uint256);
12+
function execute(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) external payable returns (uint256);
13+
function state(uint256 proposalId) external view returns (uint8);
14+
function votingDelay() external view returns (uint256);
15+
function votingPeriod() external view returns (uint256);
16+
function proposalEta(uint256 proposalId) external view returns (uint256);
17+
function hashProposal(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, bytes32 descriptionHash) external pure returns (uint256);
18+
}
19+
20+
interface ITimelock { function hasRole(bytes32 role, address account) external view returns (bool); }
21+
interface IServiceRegistry { function mapMultisigs(address multisig) external view returns (bool); }
22+
interface IVoteWeighting { function mapNomineeIds(bytes32 nomineeHash) external view returns (uint256); }
23+
interface IGuardCM { function getTargetSelectorChainId(address target, bytes4 selector, uint256 chainId) external view returns (bool); }
24+
25+
/// @notice Full governance lifecycle (propose -> vote -> queue -> execute) for proposal 24 on a MAINNET fork,
26+
/// through the CURRENTLY-LIVE GovernorOLAS (NEW_GOV; proposal 11 already migrated the Timelock roles).
27+
/// Proves the L1-observable effects all land: the mainnet GnosisSafeSameAddressMultisig is de-whitelisted
28+
/// and every one of the 32 nominees is removed from VoteWeighting. The 7 L2 de-whitelists are bridge
29+
/// ENQUEUES here (their L1 calls must not revert); their L2 effect is verified in the L2 fork tests.
30+
/// The Arbitrum entry's maxSubmissionCost is recomputed to the live Inbox fee and the value is supplied
31+
/// by the EXECUTOR (flows executor -> Governor -> Timelock -> Inbox; the Timelock needs no balance).
32+
/// Run: forge test --match-contract Proposal24ForkL1Test -vvv (uses ETH_RPC or a public RPC)
33+
contract Proposal24ForkL1Test is Test, Proposal24Builder {
34+
address internal constant NEW_GOV = 0x060D0CBdDFb0498d610E2EF55C01516B5B1251E6; // live GovernorOLAS
35+
address internal constant WVEOLAS = 0x4039B809E0C0Ad04F6Fc880193366b251dDf4B40;
36+
address internal constant TOKENOMICS = 0xc096362fa6f4A4B1a9ea68b1043416f3381ce300; // gitleaks:allow - public TokenomicsProxy address, not a secret
37+
bytes32 internal constant PROPOSER_ROLE = keccak256("PROPOSER_ROLE");
38+
uint8 internal constant SUCCEEDED = 4;
39+
uint8 internal constant EXECUTED = 7;
40+
41+
/// @dev `Dispenser.removeNominee` reverts Overflow if called within the last week of the ongoing epoch
42+
/// (block.timestamp >= epochEnd - 1 week). That is an operational SCHEDULING constraint on when the
43+
/// proposal may execute, not a proposal defect. To exercise the removal logic regardless of where the
44+
/// fork block sits in the epoch, mock the epoch end so the allowed window is open. In production the
45+
/// proposal must simply be executed with > 7 days left in the epoch.
46+
function _openEpochTimingWindow() internal {
47+
vm.mockCall(TOKENOMICS, abi.encodeWithSelector(bytes4(keccak256("getEpochEndTime(uint256)"))), abi.encode(block.timestamp));
48+
}
49+
50+
/// @dev The proposal is built with concrete, SDK-estimated Arbitrum retryable params already baked in
51+
/// (proposal_15 method: +1000% buffers, value = deposit*10), so the fork test uses them verbatim —
52+
/// no live recompute, and the staging proposalId is the one that will be submitted. totalValue is the
53+
/// sum of entry values (only the Arbitrum entry carries one).
54+
function _forkProposal()
55+
internal pure
56+
returns (address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description, uint256 totalValue)
57+
{
58+
(targets, values, calldatas, description) = buildProposal();
59+
for (uint256 i; i < values.length; ++i) totalValue += values[i];
60+
}
61+
62+
function _mockVotes() internal {
63+
// Keep votes under the uint96 weight cap but well above threshold (5000 OLAS) + quorum.
64+
vm.mockCall(WVEOLAS, abi.encodeWithSelector(bytes4(keccak256("getPastVotes(address,uint256)"))), abi.encode(uint256(1e28)));
65+
vm.mockCall(WVEOLAS, abi.encodeWithSelector(bytes4(keccak256("getVotes(address,uint256)"))), abi.encode(uint256(1e28)));
66+
vm.mockCall(WVEOLAS, abi.encodeWithSelector(bytes4(keccak256("getPastTotalSupply(uint256)"))), abi.encode(uint256(1e24)));
67+
}
68+
69+
/// @dev Assert every L1-observable effect of the proposal.
70+
function _assertL1Effects(address[] memory targets, bytes[] memory calldatas) internal view {
71+
// (1) mainnet adapter de-whitelisted
72+
assertFalse(IServiceRegistry(SR_MAINNET).mapMultisigs(SAME_MAINNET), "mainnet adapter still whitelisted");
73+
// (2) every removeNominee (target == VoteWeighting) actually removed the nominee
74+
uint256 removed;
75+
for (uint256 i; i < targets.length; ++i) {
76+
if (targets[i] != VOTE_WEIGHTING) continue;
77+
bytes memory cd = calldatas[i];
78+
bytes32 acct; uint256 cid;
79+
assembly {
80+
acct := mload(add(cd, 0x24)) // 0x20 (len) + 0x04 (selector) -> account (bytes32)
81+
cid := mload(add(cd, 0x44)) // next word -> chainId
82+
}
83+
bytes32 h = keccak256(abi.encode(acct, cid));
84+
assertEq(IVoteWeighting(VOTE_WEIGHTING).mapNomineeIds(h), 0, "nominee not removed");
85+
removed++;
86+
}
87+
assertEq(removed, 32, "expected 32 nominee removals");
88+
89+
// (3) all 19 GuardCM Phase 1 triples are now whitelisted
90+
(address[] memory gt, bytes4[] memory gs, uint256[] memory gc,) = phase1Triples();
91+
for (uint256 i; i < gt.length; ++i) {
92+
assertTrue(IGuardCM(GUARD_CM).getTargetSelectorChainId(gt[i], gs[i], gc[i]), "GuardCM triple not set");
93+
}
94+
}
95+
96+
function test_preconditions() public {
97+
vm.createSelectFork(vm.envOr("ETH_RPC", string("https://ethereum-rpc.publicnode.com")));
98+
assertTrue(ITimelock(TIMELOCK).hasRole(PROPOSER_ROLE, NEW_GOV), "NEW_GOV not the live proposer");
99+
assertTrue(IServiceRegistry(SR_MAINNET).mapMultisigs(SAME_MAINNET), "mainnet adapter not whitelisted pre-exec");
100+
// sample: Gnosis Expert 1 and the two cross-chain L1 supply alpha are live nominees
101+
bytes32 h1 = keccak256(abi.encode(bytes32(uint256(uint160(0xdB9E2713c3dA3C403F2eA6E570eB978b00304e9E))), uint256(100)));
102+
assertGt(IVoteWeighting(VOTE_WEIGHTING).mapNomineeIds(h1), 0, "Expert 1 not a live nominee");
103+
// GuardCM Phase 1 additions are not yet set (mainnet ServiceManagerProxy.pause)
104+
assertFalse(IGuardCM(GUARD_CM).getTargetSelectorChainId(0x94a1892D91c05D0C61c3f49F42205D2285b914c9, 0x8456cb59, 1), "phase1 triple already set?");
105+
}
106+
107+
function test_L1_fullGovernanceLifecycle() public {
108+
vm.createSelectFork(vm.envOr("ETH_RPC", string("https://ethereum-rpc.publicnode.com")));
109+
_mockVotes();
110+
111+
(address[] memory targets, uint256[] memory values, bytes[] memory calldatas, string memory description, uint256 totalValue) = _forkProposal();
112+
bytes32 dh = keccak256(bytes(description));
113+
IGovernor gov = IGovernor(NEW_GOV);
114+
115+
address proposer = makeAddr("proposer");
116+
address voter = makeAddr("voter");
117+
address executor = makeAddr("executor");
118+
119+
// propose
120+
vm.prank(proposer);
121+
uint256 id = gov.propose(targets, values, calldatas, description);
122+
console2.log("proposed id:", id);
123+
124+
// active -> vote For
125+
vm.roll(block.number + gov.votingDelay() + 1);
126+
vm.prank(voter);
127+
gov.castVote(id, 1);
128+
129+
// end voting -> Succeeded
130+
vm.roll(block.number + gov.votingPeriod() + 1);
131+
assertEq(gov.state(id), SUCCEEDED, "not Succeeded");
132+
133+
// queue, then warp past the timelock eta. Executor funds the Arbitrum retryable; Timelock holds nothing.
134+
gov.queue(targets, values, calldatas, dh);
135+
uint256 eta = gov.proposalEta(id);
136+
if (eta >= block.timestamp) vm.warp(eta + 1);
137+
_openEpochTimingWindow(); // mock AFTER warp so it binds to the execution timestamp
138+
vm.deal(TIMELOCK, 0);
139+
vm.deal(executor, totalValue);
140+
vm.prank(executor);
141+
gov.execute{value: totalValue}(targets, values, calldatas, dh);
142+
assertEq(gov.state(id), EXECUTED, "not Executed");
143+
144+
_assertL1Effects(targets, calldatas);
145+
assertEq(TIMELOCK.balance, 0, "Timelock should not retain funds");
146+
console2.log("L1 effects asserted: mainnet de-whitelisted + 32 nominees removed; 7 L2 messages enqueued");
147+
}
148+
149+
/// @dev Fast path: execute the full proposal directly as the Timelock (no governor), same L1 assertions.
150+
function test_L1_fullProposal_executesAsTimelock() public {
151+
vm.createSelectFork(vm.envOr("ETH_RPC", string("https://ethereum-rpc.publicnode.com")));
152+
(address[] memory targets, uint256[] memory values, bytes[] memory calldatas,, uint256 totalValue) = _forkProposal();
153+
154+
_openEpochTimingWindow();
155+
vm.deal(TIMELOCK, totalValue);
156+
vm.startPrank(TIMELOCK);
157+
for (uint256 i; i < targets.length; ++i) {
158+
(bool ok, bytes memory ret) = targets[i].call{value: values[i]}(calldatas[i]);
159+
if (!ok) {
160+
console2.log("reverted at index", i, "target", targets[i]);
161+
if (ret.length > 0) { assembly { revert(add(ret, 0x20), mload(ret)) } }
162+
revert("call failed");
163+
}
164+
}
165+
vm.stopPrank();
166+
167+
_assertL1Effects(targets, calldatas);
168+
}
169+
}

0 commit comments

Comments
 (0)