| name | weeth-mesh-governance-3cp |
| description | Use when building a 3CP that changes weETH cross-chain GOVERNANCE/OWNERSHIP across the mesh — timelock controller swaps, OFT owner/delegate/proxyAdmin → timelock, controller-Safe raises (2-of-5→4-of-7), deprecating chains to Ethereum-only exit lanes, native-minting rationalization, or unifying every chain under one canonical Safe + timelock. Complements onboard-oft-chain (which adds new chains). Encodes the hard-won rules from 3CP |
weETH mesh governance / unification 3CP
Goal pattern the org converges on: one canonical Safe 0x7a00657a solely controls the
canonical timelock 0x851Dd540 on every chain; every OFT's owner + LZ delegate + proxyAdmin →
that timelock; native minting only on ETH/OP/BASE; deprecated chains are Ethereum-only exit
lanes. Two chains can't fully conform (see zkSync/stable below).
Canonical addresses (mainnet mesh)
| thing | address | notes |
|---|
| Controller Safe | 0x7a00657a45420044bc526B90Ad667aFfaee0A868 | deterministic via Safe ProxyFactory 0xC22834581EbC8527d974F8a1c97E1bEA4EF910BC + policy initialOwners + saltNonce 0; same addr on all EVM chains |
| L2 timelock | 0x851Dd540f4D2Ec78120De0a0cc87B21EdE5Df5C6 | 3-day delay (259200); CREATE3 via 0xba5Ed099633D3B313e4D5F7bdc1305d3c28ba5Ed |
| Safe singleton | 0xfb1bffC9d739B8D520DaF37dF666da4C687191EA | GnosisSafeL2 v1.3.0 (masterCopy of the canonical Safe) |
| L1 OFT adapter | 0xcd2eb13D6831d4602D80E5db9230A57596CDCA63 | owner = OPERATING_TIMELOCK |
| L1 OPERATING_TIMELOCK | 0xcD425f44758a08BaAB3C4908f3e3dE5776e45d7a | 2-day (172800); proposer = main Safe 0x2aCA71020De61bb532008049e1Bd41E451aE8AdC |
| L1 10-day timelock (native minting) | 0x9f26d4C958fD811A1F59B01B86Be7dFFc9d20761 | 864000; owns L1 SyncPool; proposer = 0xcdd57D11476c22d265722F68390b036f3DA48c21; NOT enumerable via cast |
| L1 SyncPool | 0xD789870beA40D056A4d26055d0bEFcC8755DA146 | owner+proxyAdmin already on the 10-day timelock |
| Guardian Safe | 0x055a8B2B65d0aB4E0C17a0168d032464B7E97bdF | to be deployed same-addr everywhere + wired on the canonical Safe |
Roles (OZ TimelockController, same hash everywhere): PROPOSER 0xb09aa5ae…cc1, EXECUTOR
0xd8aa0f31…e63, CANCELLER 0xfd643c72…783. MINTER_ROLE (OFT) = keccak("MINTER_ROLE").
Iron rules (derive from live state — never a static list)
- Routing = read the owner on-chain. OFT/contract owner == a Safe ⇒ DIRECT Safe call. Owner == timelock ⇒
scheduleBatch → delay → executeBatch, signed by that timelock's current PROPOSER Safe. A controller-swap (move timelock control) is signed by the old/current proposer Safe, granting the new one and revoking itself grant-first / revoke-last (atomic, no lockout).
- Per-chain everything varies — Safe version (most 1.3.0; unichain, morph, plasma are 1.4.1), nonce, owner, whether a contract is already migrated. op vs base differ (base OFT owned by canonical Safe directly, not timelock).
getThreshold()/VERSION() reverting ⇒ not a Gnosis Safe (e.g. stable's owner 0xd2D4171A is a bespoke 10KB contract).
- Nonces: use the Safe's next FREE nonce, not just
nonce(). On-chain nonce() counts only EXECUTED txns; queued-but-unexecuted txns occupy nonces above it and will collide. Before assigning, ALWAYS check the Safe transaction service for pending txns: GET https://safe-transaction-<net>.safe.global/api/v1/safes/<addr>/multisig-transactions/?executed=false&trusted=true&ordering=-nonce and start at max(nonce(), highestQueued + 1). Same Safe address has independent nonces per chain, and any open PR/queued tx consumes a nonce (seen live: eth Safe 0x2aCA71 n807 taken by an open PR; op OLD Safe 0x764682 had a pending tx at 33 → controls bumped to 34; base canonical 0x7a00657a had pending at 43 → base leaves bumped to 44/45). A wrong nonce silently invalidates every hash.
- MultiSendCallOnly is per (chain, Safe-version) — resolve EXACTLY like Safe{Wallet}:
safe-deployments networkAddresses[chainId] FIRST entry, keyed by the Safe's ON-CHAIN VERSION(). NEVER probe by code order — both the eip155 (0xA1dabEF3…) and canonical (0x40A2aCCb…) 1.3.0 MultiSends are deployed on most chains, so probing returns the wrong one and the safeTxHash silently mismatches the UI (this bug shipped in 6 PRs — fixed in hash-3cp-585.mjs, which now carries a verified per-chainId map). Verified 1.3.0 first-entries: 0xA1dabEF33b3B82c7814B6D82A79e50F4AC44102B ONLY on op/base/avax/ink; 0x40A2aCCbd92BCA938b02010E17A5b8929b49130D on all other EVM chains (bnb, linea, unichain, hyperEVM, plasma, scroll, swell, bera, mode, blast, morph, ethereum, sonic, monad); zkSync 0xf220D3b4DFb23C4ade8C88E526C1353AbAcbC38F (NOT 0x0dFcccB9; confirmed by on-chain code + domainSeparator() match). 1.4.1 = 0x9641d764fc13c8B624c04430C7356C1C7C8102e2 everywhere (zkSync 0x0408EF01…). The canonical Safe 0x7a00657a is v1.3.0 on every chain, so its raises use the 1.3.0 column even where the OLD per-chain Safe is 1.4.1 — read VERSION(), don't assume from the chain. NEVER plain MultiSend 0xA238CBeb….
- Single-tx leaf → operation 0,
to=target. Multi-tx → operation 1, to=MultiSendCallOnly. The safeTxHash depends on both.
Hashing (uniform cast EIP-712 — works on every chain incl. exotic)
- DOMAIN_TYPEHASH =
keccak("EIP712Domain(uint256 chainId,address verifyingContract)"); domainSeparator = keccak(abi.encode(DOMAIN_TH, chainId, safe)). Cross-check against on-chain domainSeparator() — a true match validates Safe addr + chainId + version.
- SAFE_TX_TYPEHASH =
keccak("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)").
- safeTxHash =
keccak(0x1901 ‖ domain ‖ keccak(abi.encode(SAFE_TX_TYPEHASH, to, 0, keccak(data), op, 0,0,0, 0,0, nonce))).
- MultiSend payload per inner tx:
00 ‖ to(20) ‖ value(32) ‖ len(32) ‖ data, wrapped as multiSend(bytes).
Fork-verify by REPLAYING the exact proposal bytes (mandatory before signing)
Anvil fork → anvil_impersonateAccount the real signer Safe/timelock → cast send <to> <leaf.json calldata> --from <safe> --unlocked. For schedule/execute: send schedule, evm_increaseTime past the delay, evm_mine, send execute. Assert the end STATE (threshold==4; canon has P/E/C & old doesn't; OFT owner/delegate==timelock; peers severed; outbound==10e18 inbound==0), not just that the tx didn't revert.
Asymmetric exit-lane (deprecating a chain with holdings)
One-way out to Ethereum only. Deprecated side: keep only the Eth peer; setPeer(otherEid,0) for all others; outbound[30101]=10e18/3600, inbound[30101]=0 (set explicitly). Ethereum side (adapter, via OPERATING_TIMELOCK): keep the peer; inbound[depEid]=10e18/3600, outbound[depEid]=0. Set the 10/hr on BOTH ends of the exit direction, 0 on BOTH ends of the return. Empty chain (≈0 supply, e.g. sonic): full unwire — sever Eth too, all limits 0, sever it on the Ethereum side as well. A 0 rate limit disables the pathway (it does not mean unlimited).
MANDATORY before hard-severing (setPeer=0 or inbound-rate=0) or zeroing any path — run node tools/inflight-check.mjs first. A hard sever strands any message already in flight: the source has burned/locked the tokens, but lzReceive reverts on the destination (NoPeer / rate-limit 0) → the transfer is stuck, recoverable only by governance temporarily restoring the peer to deliver, then re-severing. The tool compares each path's source outboundNonce vs destination lazyInboundNonce (both directions) and exits non-zero if any gap > 0. Require 0 in-flight on every severed path before signing/executing the sever. If a path shows in-flight: drain it first — zero OUTBOUND only (keep the peer + inbound so in-flight can still be delivered), wait the drain window (minutes; use hours for safety), then apply the full sever. Deprecated↔non-Eth L2 paths are usually already 0 (near-zero direct L2↔L2 volume; the Eth exit lane stays open), so a one-shot sever is typically safe — but never assume, always run the check. NOTE: because LZ requires the RECEIVER to have the sender as a peer, severing EITHER side kills both directions (no half-open path); and effective rate = min(both sides), so a transient two-sided mismatch is always MORE restrictive (a throttled/retryable transfer, never over-send). Keep both sides of one exit lane in the same PR.
Native minting deprecation (keep ETH/OP/BASE only)
L2 (sign the chain's controls Safe): OFT.revokeRole(MINTER_ROLE, L2_SYNC_POOL) (the real disable) + syncPool.setMinSyncAmount(tokenIn,0). L1 (sign via the 10-day timelock, proposer 0xcdd57D11): L1SyncPool.setPeer(L2_EID,0). Check live first — MINTER is often already revoked on some chains; emit only real changes. Keep BOTH sides in one PR (L2 MINTER revoke as its own leaf + the L1 SyncPool sever) so NM never sits half-on, and execute the L1 SyncPool sever before/with the L2 revoke so no mint message is left in flight (source routed, dest can't mint → stuck). The L2 revoke must run BEFORE that chain's OFT ownership handoff (while the old Safe still administers the OFT) — give it the lower nonce.
Guardian Safe (compromise recovery)
A separate Guardian Safe 0x055a8B2B65d0aB4E0C17a0168d032464B7E97bdF (4-of-6, Safe v1.4.1)
holds the CANCELLER role on every timelock — it can veto/cancel scheduled txns if the canonical
Safe signers are compromised, but cannot propose or execute. Deploy to the same address on every
EVM chain by replaying the exact v1.4.1 createProxyWithNonce calldata (factory
0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67, singleton 0x41675c09…, saltNonce 0) — CREATE2 is
sender-independent (tools/deploy-guardian-safe.sh). Wire it per chain via the timelock:
scheduleBatch([grantRole(CANCELLER, guardian)]) → delay → executeBatch, signed by that timelock's
current proposer (canonical Safe on L2 post-swap; main Safe 0x2aCA71 on L1 0xcD425f;
0xcdd57D11 on the L1 10-day 0x9f26d4C9). Grant CANCELLER only — never PROPOSER/EXECUTOR.
zkSync + stable are structurally special
- zkSync (Era): different CREATE2/CREATE3 derivation →
0x851Dd540/0x7a00657a/Guardian 0x055a8B2B unreachable (matching an EVM address would be a keccak preimage search — infeasible; not a tooling gap). The EVM MultiSend 0xA1dabEF3 exists there but is a non-functional same-address artifact; the zkSync-native MultiSendCallOnly Safe{Wallet} uses is 0xf220D3b4DFb23C4ade8C88E526C1353AbAcbC38F (v1.3.0, per safe-deployments; verified by on-chain code + domainSeparator() match — NOT 0x0dFcccB9). Existing Safe 0x8b9836176900A8EE62Dbe98066976D6CE829C53e is already 4-of-7 with the canonical owner set → reuse it. Era-native timelock deployed at 0xde8A2C33655ACA88f258988ED74D1511876343D1 (3-day, P/E/C = 0x8b983617, self-admin, OZ 5.0.1) via scripts/DeployZkSyncTimelock.s.sol (foundry-zksync). Guardian = a FRESH Era Safe (same 4-of-6 owner set, different address) via tools/deploy-zksync-guardian.sh (zk Safe v1.3.0: factory 0xDAec3364…, singleton 0x1727c2c5…, fallback 0x2f870a80…). Verify source with forge build --zksync FIRST (no zkout/ ⇒ "zk compiler version specified for EVM bytecode"), then --verifier zksync.
- stable (988): ALREADY UNIFIED — do NOT deploy a CREATE3 factory or
0x851Dd540. Its OFT owner 0xd2D4171A1e81dc266e14B9694250C11C516C860b is itself a 3-day TimelockController (self-admin, OZ): PROPOSER+EXECUTOR+CANCELLER = canonical Safe 0x7a00657a (live 4-of-7), OFT owner + ProxyAdmin both = that timelock, LZ delegate = the Safe. Same carve-out as zkSync — a chain-specific timelock address, not the canonical one. Reaching the canonical 0x851Dd540 is blocked by EIP-155: it needs CreateX 0xba5Ed099 = CREATE(0xeD456e05Ca…, nonce 0), reproducible ONLY via the pre-EIP-155 keyless tx — and BOTH stable RPCs (Alchemy + native rpc.stable.xyz) reject legacy txs (-32000: only replay-protected … allowed over RPC). No CREATE2 path (Safe Singleton Factory lands elsewhere); can't add EIP-155 (no key, changes the address). Arachnid 0x4e59b448 + Safe Singleton 0x914d7Fec ARE present ⇒ genesis predeploys. So the canonical address needs an OPERATOR action (predeploy CreateX, inject the raw tx, or enable --rpc.allow-unprotected-txs); tooling is built & ready (tools/deploy-stable-canonical.sh, scripts/DeployStableTimelock.s.sol, tools/gen-stable-canonical-migration.mjs, keyless deployer pre-funded 0.3). Default to the carve-out (keep 0xd2D4171A, zkSync-style) — only gap is granting Guardian 0x055a8B2B CANCELLER on 0xd2D4171A (tools/gen-guardian-canceller-leaves.mjs, nonces 6/7).
- L1:
0x851Dd540 is L2-only (not deployed on L1, can't be); L1 stays on 0xcD425f/0x9f26d4C9.
Tooling / pitfalls (this repo)
- Build pipeline (weETH-cross-chain repo, outputs to
../3CP-secure/queued/<PR#>/): tools/gen-3cp-585.mjs (calldata+JSON via cast), tools/hash-3cp-585.mjs (hashes + domain cross-check), tools/md-3cp-585.mjs (per-leaf md + PR body), tools/verify-canonical-safe-deprecated.mjs (same-addr/singleton/owners), tools/deploy-canonical-safe-deprecated.sh (Ledger deploy of the canonical Safe on new chains).
tools/inflight-check.mjs — run before ANY sever / rate-limit-zero (see the exit-lane section). Reports source outboundNonce vs destination lazyInboundNonce per path+direction; exit 1 if in-flight. Also tools/gen-guardian-canceller-leaves.mjs (Guardian CANCELLER leaves for chains already unified — robinhood/stable, env-gated), tools/deploy-stable-canonical.sh + scripts/DeployStableTimelock.s.sol (stable canonical-address path, blocked by EIP-155), tools/deploy-zksync-guardian.sh + scripts/DeployZkSyncTimelock.s.sol (Era-native), tools/safe-eip712-sign.mjs + tools/safe-eip712-exec.mjs (offline EIP-712 hardware-wallet signing for chains without a Safe UI).
- The hash/RPC chain map must include EVERY chain or you silently get "no-multisend"/domain-fail for the missing ones.
- Unescaped backticks inside a JS template-literal PR body silently break md generation — never run generators with output suppressed without then checking the file count.
L2Constants.sol has copy-paste artifacts (op and bera share addresses) — verify on-chain, don't trust the file.
- Explorer verification: Etherscan v2 multichain API (
api.etherscan.io/v2/api?chainid=…, one key) covers most; Blockscout v2 (/api/v2/smart-contracts/<addr>) for mode/ink/swell/morph; zkSync has its own. Singleton 0xfb1bffC9 is verified on most majors; known gaps seen: monad singleton, avax/swell timelock, zkSync singleton+OFT. Each leaf md must carry a direct clickable explorer link to its Safe + target so chains with no Safe{Wallet} UI (hyperEVM/swell/morph/monad/plasma/robinhood) are still independently checkable.
- app.safe.global supports a subset; for the rest, sign on-chain (
approveHash from ≥threshold owners, then execTransaction).
Batching (maximize, then flag what can't)
Per converging chain you get 3 signatures, irreducible: (1) controller-Safe raise (different Safe, nonce 0), (2) the old/per-chain Safe's controls multisend — fold in EVERYTHING signable by it: 1557 peer rewiring + 1477 transfers + OFT owner/delegate→timelock handoff + the 1559 scheduleBatch + (NM revoke where applicable), (3) executeBatch after the 3-day delay. Different Safes and timelock delays make "1 tx per chain" impossible across the full set — collapse within each Safe, and say so explicitly.