From 2b93f55778ba5f24e41ae2b68874f12823c7ab54 Mon Sep 17 00:00:00 2001 From: aelmanaa Date: Fri, 10 Jul 2026 02:53:08 +0100 Subject: [PATCH 1/3] feat: Safe multisig execution mode, plus on-chain chain/pool removal MODE=safe emits each operation's Call[] as a reviewable Safe Transaction Builder batch and optionally executes it via execTransaction with raw owner signatures; emitted batches compose into one atomic Safe transaction (ExecuteBatch). Includes an EIP-712 safeTxHash recompute cross-checked against the Safe, a canonical v1.4.1 CREATE2 deploy helper, byte-equality and ceremony fork tests (incl. Safe v1.5.0), and docs/governance-modes.md. Default EOA behavior is unchanged. Also adds on-chain lane teardown. RemoveChain.s.sol fully unsupports a remote chain, version-dispatched across 1.5.0/1.5.1/1.6.1/2.0.0: 1.5.0 uses the single-argument allowed:false shape, 1.5.1+ the modern toRemove[] shape, with an isSupportedChain pre-check and a version-safe read before dispatch. RemoveRemotePool gains a 1.5.0 redirect, since 1.5.0 holds one remote pool per chain and has no standalone pool removal. The Safe byte-equality catalog covers both removal shapes, so Safe executes them byte-identically to the EOA path. docs/pool-versions.md documents the two teardown operations, the live-lane drain sequence, the reciprocal half-open window, and the last-pool footgun. --- .gitignore | 5 + Makefile | 2 +- README.md | 21 +- .../example.transfer-ownership.11155111.json | 1 + docs/config-architecture.md | 7 +- docs/governance-modes.md | 371 +++++++++++ docs/pool-versions.md | 70 +++ .../configure/remote-chains/RemoveChain.s.sol | 182 ++++++ .../remote-pools/RemoveRemotePool.s.sol | 7 + script/governance/DeploySafe.s.sol | 101 +++ script/governance/ExecuteBatch.s.sol | 64 ++ script/setup/AcceptAdminRole.s.sol | 9 +- script/setup/ClaimAndAcceptAdmin.s.sol | 113 ++++ src/base/EoaExecutor.s.sol | 49 +- src/base/ISafe.sol | 89 +++ src/base/SafeBatchEmitter.sol | 81 +++ src/base/SafeBatchLoader.sol | 97 +++ src/base/SafeMode.sol | 184 ++++++ src/base/SafeTxHash.sol | 68 ++ test/actions/PoolVersionDispatch.t.sol | 334 +++++++++- test/governance/ExecuteBatch.t.sol | 319 ++++++++++ test/governance/Safe150Compat.t.sol | 142 +++++ test/governance/SafeMode.t.sol | 595 ++++++++++++++++++ test/governance/SafeTxHash.t.sol | 167 +++++ 24 files changed, 3052 insertions(+), 26 deletions(-) create mode 100644 batches/example.transfer-ownership.11155111.json create mode 100644 docs/governance-modes.md create mode 100644 script/configure/remote-chains/RemoveChain.s.sol create mode 100644 script/governance/DeploySafe.s.sol create mode 100644 script/governance/ExecuteBatch.s.sol create mode 100644 script/setup/ClaimAndAcceptAdmin.s.sol create mode 100644 src/base/ISafe.sol create mode 100644 src/base/SafeBatchEmitter.sol create mode 100644 src/base/SafeBatchLoader.sol create mode 100644 src/base/SafeMode.sol create mode 100644 src/base/SafeTxHash.sol create mode 100644 test/governance/ExecuteBatch.t.sol create mode 100644 test/governance/Safe150Compat.t.sol create mode 100644 test/governance/SafeMode.t.sol create mode 100644 test/governance/SafeTxHash.t.sol diff --git a/.gitignore b/.gitignore index d49c6a9..b640224 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,11 @@ docs/* !docs/deployed-addresses.md !docs/pool-versions.md !docs/enabling-existing-token.md +!docs/governance-modes.md + +# Safe Transaction Builder batches (run artifacts, kept local); keep the example +batches/* +!batches/example.transfer-ownership.11155111.json # Dotenv file .env diff --git a/Makefile b/Makefile index c2963ed..d8b41ec 100644 --- a/Makefile +++ b/Makefile @@ -88,7 +88,7 @@ endif done @echo "review the lane policy diff (lanes{} = owner policy), then: make doctor CHAIN=$(LOCAL)" -remove-lane: tools ## Remove a lanes{} policy entry LOCAL -> REMOTE from the declaration (LOCAL= REMOTE= required; BOTH=1 removes the reciprocal; on-chain removal via ApplyChainUpdates is a separate step) +remove-lane: tools ## Remove a lanes{} policy entry LOCAL -> REMOTE from the declaration (LOCAL= REMOTE= required; BOTH=1 removes the reciprocal; on-chain removal via RemoveChain, or RemoveRemotePool for a single pool, is a separate step) $(if $(LOCAL),,$(error LOCAL is required: make remove-lane LOCAL= REMOTE= [BOTH=1])) $(if $(REMOTE),,$(error REMOTE is required: make remove-lane LOCAL= REMOTE= [BOTH=1])) FOUNDRY_PROFILE=sync forge script $(SYNC_SCRIPT) --sig "removeLane(string,string)" "$(LOCAL)" "$(REMOTE)" diff --git a/README.md b/README.md index 5419408..c1360f4 100644 --- a/README.md +++ b/README.md @@ -534,7 +534,7 @@ make add-chain CHAIN=ethereum-testnet-sepolia-base-1 SELECTOR=103449712358744650 make doctor CHAIN=ethereum-testnet-sepolia-base-1 # 3. layered verification — re-run until green ``` -`CHAIN` is the chain's **canonical CCIP selectorName** as shown by `make discover` (the API/registry name — e.g. `ethereum-testnet-sepolia-base-1`, not a bespoke `base-sepolia`); it becomes the file name `config/chains/.json` and is validated against the API. `SELECTOR` is the **explicit identity key**, also from `make discover` — every fetch cross-checks both: a valid-but-wrong selector fails loudly as `SELECTOR MISMATCH`, and a non-canonical name as `SELECTOR NAME MISMATCH`, instead of silently writing another chain's contracts. New chains are **discovered automatically** from `config/chains/` — `HelperConfig` scans the directory, so no Solidity edit is needed anywhere. For a newly added chain the `chainNameIdentifier` (and hence the `rpcEnv` and the `_TOKEN`/`_TOKEN_POOL` override prefix) is **derived from the selectorName** as UPPER_SNAKE — so it may differ in style from the six bundled chains' curated short forms (e.g. `AVALANCHE_TESTNET_FUJI`, not `AVALANCHE_FUJI`); `add-chain` **prints the exact `chainNameIdentifier` and `rpcEnv` names it generated** so you never have to guess (or open the JSON) which env var to export. `add-chain` prints your next steps: add the chain's RPC env var to `.env`, then deploy your token and pool there ([Step 1](#step-1-deploy-token-on-both-chains) / [Step 2](#step-2-deploy-token-pools-on-both-chains)). Then declare the lane policy with `make add-lane LOCAL= REMOTE= CAPACITY= RATE= BOTH=1`, apply it on-chain via [Step 5](#step-5-apply-chain-updates-configure-cross-chain-routes), and re-run `make doctor` — its lanes rung reconciles the declared policy against the pool. To retire a lane, `make remove-lane LOCAL= REMOTE= [BOTH=1]` removes the declaration; that is a separate step from the on-chain removal (the pool's `applyChainUpdates` takes the selector in `remoteChainSelectorsToRemove`), and between the two `make doctor` WARNs that the on-chain lane is not declared. +`CHAIN` is the chain's **canonical CCIP selectorName** as shown by `make discover` (the API/registry name — e.g. `ethereum-testnet-sepolia-base-1`, not a bespoke `base-sepolia`); it becomes the file name `config/chains/.json` and is validated against the API. `SELECTOR` is the **explicit identity key**, also from `make discover` — every fetch cross-checks both: a valid-but-wrong selector fails loudly as `SELECTOR MISMATCH`, and a non-canonical name as `SELECTOR NAME MISMATCH`, instead of silently writing another chain's contracts. New chains are **discovered automatically** from `config/chains/` — `HelperConfig` scans the directory, so no Solidity edit is needed anywhere. For a newly added chain the `chainNameIdentifier` (and hence the `rpcEnv` and the `_TOKEN`/`_TOKEN_POOL` override prefix) is **derived from the selectorName** as UPPER_SNAKE — so it may differ in style from the six bundled chains' curated short forms (e.g. `AVALANCHE_TESTNET_FUJI`, not `AVALANCHE_FUJI`); `add-chain` **prints the exact `chainNameIdentifier` and `rpcEnv` names it generated** so you never have to guess (or open the JSON) which env var to export. `add-chain` prints your next steps: add the chain's RPC env var to `.env`, then deploy your token and pool there ([Step 1](#step-1-deploy-token-on-both-chains) / [Step 2](#step-2-deploy-token-pools-on-both-chains)). Then declare the lane policy with `make add-lane LOCAL= REMOTE= CAPACITY= RATE= BOTH=1`, apply it on-chain via [Step 5](#step-5-apply-chain-updates-configure-cross-chain-routes), and re-run `make doctor` — its lanes rung reconciles the declared policy against the pool. To retire a lane, `make remove-lane LOCAL= REMOTE= [BOTH=1]` removes the declaration; that is a separate step from the on-chain removal, done with [`RemoveChain`](#remove-a-remote-chain) (whole-chain teardown, every version) or [`RemoveRemotePool`](#remove-a-remote-pool) (a single pool, 1.5.1+), and between the two `make doctor` WARNs that the on-chain lane is not declared. Full details: [Configuration](#configuration) overview, the per-field [`docs/config-schema.md`](docs/config-schema.md), and the command + architecture reference [`docs/config-architecture.md`](docs/config-architecture.md). @@ -1048,6 +1048,8 @@ DEST_CHAIN=MANTLE_SEPOLIA \ ##### Remove a Remote Pool +Drops a single remote pool from a chain that stays supported. This is a 1.5.1+ operation; on a 1.5.0 pool it refuses and points at "Remove a Remote Chain" below, since 1.5.0 holds one remote pool per chain (there is no standalone pool removal). + > **Warning:** All inflight transactions from the removed pool will be rejected after removal. Ensure there are no inflight transactions before proceeding. ```bash @@ -1062,6 +1064,23 @@ DEST_CHAIN=MANTLE_SEPOLIA \ --broadcast ``` +##### Remove a Remote Chain + +Tears down the whole lane: fully unsupports a remote chain on the source pool (removes the selector and deletes its remote-chain config), so neither direction accepts messages afterward. Use this to retire a lane, not to swap a pool. Works on every pool version (1.5.0 through 2.0.0) — the script dispatches on the pool's on-chain version. + +> **Warning:** All inflight transactions on this lane will be rejected after removal. Ensure there are no inflight messages to or from this chain before proceeding. See [`docs/pool-versions.md`](docs/pool-versions.md#removing-a-lane-or-a-pool) for the live-lane drain sequence and the config that survives removal. + +```bash +DEST_CHAIN=MANTLE_SEPOLIA \ + forge script \ + script/configure/remote-chains/RemoveChain.s.sol \ + --rpc-url \ + $ETHEREUM_SEPOLIA_RPC_URL \ + --account \ + $KEYSTORE_NAME \ + --broadcast +``` + ### Deploy Advanced Pool Hooks Use this script for enhanced security features like allowlists, CCV management, policy engine integration, and threshold-based validation. diff --git a/batches/example.transfer-ownership.11155111.json b/batches/example.transfer-ownership.11155111.json new file mode 100644 index 0000000..5cdce0c --- /dev/null +++ b/batches/example.transfer-ownership.11155111.json @@ -0,0 +1 @@ +{"version":"1.0","chainId":"11155111","createdAt":0,"meta":{"name":"transfer-ownership","description":"CCT action-layer batch (docs-cct-foundry)","txBuilderVersion":"1.17.1","createdFromSafeAddress":"0x01ABb19a5c0e22F34a52cA590ddaC116533679BA","createdFromOwnerAddress":""},"transactions":[{"to":"0xA8452Ec99ce0C64f20701dB7dD3abDb607c00496","value":"0","data":"0xf2fde38b00000000000000000000000001abb19a5c0e22f34a52ca590ddac116533679ba","contractMethod":null,"contractInputsValues":null}]} \ No newline at end of file diff --git a/docs/config-architecture.md b/docs/config-architecture.md index a2a99d0..923b1ad 100644 --- a/docs/config-architecture.md +++ b/docs/config-architecture.md @@ -18,7 +18,7 @@ that need it, never exported. Targets that touch the API need only `curl` + `jq` | `discover` | List the CCIP API testnet catalog joined against local configs | `FILTER=` (optional) | `bash script/config/sync-discover.sh` | | `add-chain` | Generate `config/chains/.json` from the live API, then sync | `CHAIN=` **+** `SELECTOR=` (both required) | `SyncCcipConfig.s.sol --sig "init(string,uint256)" ` → canonicalize | | `add-lane` | Append a `lanes{}` policy entry LOCAL → REMOTE (writes **only** the `lanes` subtree; no API fetch) | `LOCAL=` `REMOTE=` `CAPACITY=` `RATE=` (all required); `INBOUND_CAPACITY=` + `INBOUND_RATE=` (paired, optional) add the `inbound{}` block; `BOTH=1` adds the reciprocal | `SyncCcipConfig.s.sol --sig "addLane(string,string,uint256,uint256)" ` (6-arg overload with the inbound pair; twice with `BOTH=1`) → canonicalize both files | -| `remove-lane` | Remove a `lanes{}` policy entry LOCAL → REMOTE (undo of `add-lane`; writes **only** the `lanes` subtree, declaration only - an applied lane must also be removed on-chain via `ApplyChainUpdates`) | `LOCAL=` `REMOTE=` (both required); `BOTH=1` removes the reciprocal | `SyncCcipConfig.s.sol --sig "removeLane(string,string)" ` (twice with `BOTH=1`) → canonicalize both files | +| `remove-lane` | Remove a `lanes{}` policy entry LOCAL → REMOTE (undo of `add-lane`; writes **only** the `lanes` subtree, declaration only - an applied lane must also be removed on-chain via `RemoveChain`, or `RemoveRemotePool` for a single pool) | `LOCAL=` `REMOTE=` (both required); `BOTH=1` removes the reciprocal | `SyncCcipConfig.s.sol --sig "removeLane(string,string)" ` (twice with `BOTH=1`) → canonicalize both files | | `adopt-token` | Adopt an externally deployed token (and optionally its pool) into the address registry after on-chain validation (needs the chain's `rpcEnv` RPC; see [`enabling-existing-token.md`](enabling-existing-token.md)) | `CHAIN=` **+** `TOKEN=` (both required); `TOKEN_POOL=` optional | `AdoptToken.s.sol --sig "run(string,address,address)" ` | | `sync` | Refresh one chain's API-served fields (`ccip{}` + identity/metadata) from the API | `CHAIN=` (required) | `SyncCcipConfig.s.sol --sig "run(string)" ` → canonicalize | | `sync-preview` | Fetch + log a chain's `ccip{}` from the API **without writing** | `CHAIN=` (required) | `SyncCcipConfig.s.sol --sig "preview(string)" ` | @@ -172,8 +172,9 @@ A lane is **directional policy**: `lanes.` in chain A's file declares "A ``" with an outbound rate limit. A working transfer path needs the lane declared on **both** files (each side's `applyChainUpdates` reads its own file), so the committed configs must form a **reciprocal mesh**. `make add-lane ... BOTH=1` writes both sides in one command, and `make remove-lane` is its undo -(declaration only - a lane still applied on the pool must be removed separately via -`ApplyChainUpdates`, and until then the doctor's lanes rung WARNs about the undeclared on-chain +(declaration only - a lane still applied on the pool must be removed separately on-chain via +`RemoveChain` (whole-chain teardown, every version) or `RemoveRemotePool` (one pool, 1.5.1+), and +until then the doctor's lanes rung WARNs about the undeclared on-chain lane); the doctor's **mesh rung** proves the property across the whole directory on every `make doctor CHAIN=`: diff --git a/docs/governance-modes.md b/docs/governance-modes.md new file mode 100644 index 0000000..0677e92 --- /dev/null +++ b/docs/governance-modes.md @@ -0,0 +1,371 @@ +# Governance modes + +Every write operation in this repo is defined once, as a `CctActions` builder that returns a +`Call[]` (target, value, calldata). The `MODE` environment variable selects which executor runs +that `Call[]`. The calldata is identical in every mode, so reviewing one mode reviews them all. + +| Mode | Executor | What happens | +| ------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| unset / `eoa` | `EoaExecutor` (broadcast) | Each call is broadcast from the script signer. | +| `safe` | `SafeMode` | The batch is written as Safe Transaction Builder JSON under `batches/` for review and signing; optionally executed directly with `SAFE_EXEC=direct`. | + +A user who never sets `MODE` sees no change and needs no new environment variables: same commands, +same flags, same broadcast behavior, same output. The Safe-mode variables below are read only when +`MODE=safe`. + +A [Safe](https://docs.safe.global/) is an on-chain multisig: `threshold`-of-owners signatures +authorize each Safe transaction, and the Safe serializes its transactions with its own nonce, +distinct from any account nonce. + +```mermaid +flowchart LR + classDef core fill:#E8EDFB,stroke:#375BD2,color:#1A2B6B + classDef exec fill:#375BD2,stroke:#375BD2,color:#FFFFFF + + A["Script wrapper
(parse env / JSON)"]:::core --> B["CctActions builder
Call[] (target, value, data)"]:::core + B --> C{"MODE"}:::core + C -- "unset / eoa" --> D["EoaExecutor
broadcast each call"]:::exec + C -- "safe" --> E["SafeMode
emit Transaction Builder JSON"]:::exec + E -- "review, then SAFE_EXEC=direct" --> F["execTransaction
(raw ECDSA owner signatures)"]:::exec + E -- "review, then safe-cli / Safe UI" --> G["Safe Transaction Service
propose, co-sign, execute"]:::exec +``` + +## Deploying a Safe + +`script/governance/DeploySafe.s.sol` deploys from the canonical Safe v1.4.1 stack: + +```bash +SAFE_OWNERS="0x...,0x...,0x..." SAFE_THRESHOLD=2 \ +forge script script/governance/DeploySafe.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --broadcast +``` + +`SafeProxyFactory.createProxyWithNonce(SafeL2, setup(...), saltNonce)` is CREATE2, and the factory, +singleton, and fallback handler live at the same address on every supported chain. The same owners, +threshold, and salt nonce therefore reproduce the same Safe address on every chain, which is what a +mirrored multi-chain fleet needs. The script predicts the address first, asserts the deployment +lands on it, and is idempotent when the Safe already exists. + +`SAFE_SALT_NONCE` (optional, default 0) is the CREATE2 salt nonce; reuse the same value on every +chain to mirror the address. The script requires the canonical v1.4.1 factory and singleton to have +code on the target chain and reverts otherwise (0G Galileo, for example, does not carry the +canonical stack). + +The repo pins v1.4.1 rather than v1.5.0 because v1.5.0's canonical rollout is incomplete: as of +2026-07-10 the v1.5.0 `SafeL2` singleton is not deployed on Avalanche Fuji, among others, so +v1.4.1 is the only stack that mirrors one fleet address on every supported chain. +`test/governance/Safe150Compat.t.sol` proves the mode is forward-compatible: the `safeTxHash` +math, the `execTransaction` ABI, and MultiSend batching all hold against a Safe deployed from the +canonical v1.5.0 stack, so adopting v1.5.0 later is a constants change in `SafeCanonical`, not a +redesign. + +## Safe mode environment variables + +Read only when `MODE=safe`: + +| Variable | Required | Meaning | +| ------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SAFE_ADDRESS` | yes | The Safe that owns or administers the target contracts. Preflight authority checks compare against this address instead of the script broadcaster. | +| `BATCH_NAME` | no (`cct-batch`) | Basename of the emitted file: `batches/..json`. | +| `SAFE_EXEC` | no | Unset: emit the batch only. `direct`: execute `execTransaction` in the same run. | +| `SAFE_SIGNER_KEYS` | for `SAFE_EXEC=direct` | Comma-separated owner private keys, at least `threshold` of them. Never logged. | + +Preflight checks that assert on-chain authority (pool owner, pending administrator, admin role) +compare against the account that will execute the calls: the Safe in `safe` mode, the script +broadcaster otherwise (`EoaExecutor.executingAccount()`). `SAFE_ADDRESS` is therefore read at +preflight time, before any batch is emitted, and an emit-only run fails without it. Example: +`AcceptAdminRole` with `MODE=safe` requires the Safe, not the broadcaster, to be the registry's +pending administrator, so it runs standalone in Safe mode once the Safe is pending. + +## The two Safe paths + +Both paths execute the same reviewed batch and produce a byte-identical on-chain effect. They +differ only in how signatures are collected. + +**Direct `execTransaction` (universal).** `SAFE_EXEC=direct` computes the `safeTxHash` (see +[`safeTxHash` mechanics](#safetxhash-mechanics)), signs it with the provided owner keys, packs the +signatures sorted ascending by signer address, and submits `execTransaction` from the script +signer. The submitting account pays gas but does not need to be a Safe owner; authorization comes +entirely from the packed owner signatures. This path works on any EVM chain because it needs +nothing but the Safe contract itself. It is the default for automated tests and the only option on +chains the Safe Transaction Service does not serve. + +**Safe Transaction Service (network-dependent).** The emitted `batches/..json` +imports directly into the Safe{Wallet} UI's Transaction Builder, or can be proposed from the +command line with the official Python [`safe-cli`](https://github.com/safe-global/safe-cli): +propose, co-sign to threshold, execute. The proposal appears in the Safe{Wallet} queue, which is +where production co-signing usually happens. This path requires the Transaction Service to serve +the network (matrix below). One caveat from live validation: safe-cli 1.9.0's `execute` underprices +EIP-1559 gas on Sepolia (`max fee per gas less than block base fee`). Execution of a fully signed +proposal is permissionless, so the fallback stays on the Transaction Service path: fetch the +proposal's collected confirmations from the service, pack them sorted ascending by owner address, +and submit `execTransaction` from any funded account. The service's `isExecuted` flag lags the +receipt by up to a minute; poll before asserting. + +## Review before submitting + +The security gate is the same on both paths, and it happens before any signature is produced: + +1. Decode every transaction in the emitted batch file and check `to`, `value`, the function + selector, and the decoded arguments against the operation you intend. The batch carries exactly + the calldata EOA mode would broadcast, so the comparison is direct + (`test/governance/SafeMode.t.sol` pins this byte for byte). +2. Verify the `safeTxHash` independently before co-signing. On the `SAFE_EXEC=direct` path, + `SafeMode` recomputes the EIP-712 hash locally and requires it to equal the Safe's on-chain + `getTransactionHash` before signing. On the Transaction Service path no local recompute runs, so + each co-signer performs the equivalent check on their own device (see + [Independent signature verification](#independent-signature-verification)) and withholds the + signature until the hash matches. + +## `safeTxHash` mechanics + +The hash the owners sign is EIP-712 over the Safe's domain and the SafeTx struct +(`src/base/SafeTxHash.sol`): + +- Domain separator: `keccak256("EIP712Domain(uint256 chainId,address verifyingContract)")` with the + chain id and the Safe address (Safe >= 1.3.0). +- `SAFE_TX_TYPEHASH = keccak256("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)")` + = `0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8`. +- The type string ends in `uint256 nonce`, the EIP-712 struct field name. Encoding `_nonce`, the + Solidity parameter name Safe's own `getTransactionHash` uses, yields a wrong typehash and a hash + that never matches signer devices. `test/governance/SafeTxHash.t.sol` pins this as a regression + test, and fuzzes the local recompute against a real Safe's `getTransactionHash` on a fork. +- Signing uses raw ECDSA signatures (v = 27/28) over that hash, concatenated sorted ascending by + signer address. The Safe rejects unsorted packs (`GS026`). + +A single-call batch is submitted as a plain CALL (`operation = 0`) to its target. A batch of more +than one call becomes a single DELEGATECALL (`operation = 1`) into the canonical +`MultiSendCallOnly`, which replays each inner CALL from the Safe. A reviewer therefore accepts +`operation = 1` only when `to` is exactly the canonical `MultiSendCallOnly` address. + +## Independent signature verification + +Each signer recomputes the `safeTxHash` on a separate device before signing. The recompute takes +only the announced primary inputs: chain id, Safe address and version, `to`, `value`, `data`, +`operation`, and `nonce`. All of them are visible in the batch file, the Safe{Wallet} queue, or +the `SafeMode` run log, and the nonce is readable on-chain (`cast call $SAFE "nonce()(uint256)"`). +The signer compares the recomputed hash with the hash shown by the signing device (hardware +wallets display the domain and message hashes; `SafeMode` and the Safe UI display the +`safeTxHash`) and signs only when they match. A hash computed from independently sourced inputs on +a device the proposer never touched is what makes a co-signature a verification rather than an +acknowledgment. + +[`clearsig`](https://github.com/Cyfrin/clearsig) computes all three hashes from the primary +inputs, and accepts arbitrary chain ids, so it also covers chains other tools do not list. Single +call (`operation` 0, `to` is the target contract itself): + +```bash +clearsig safe-hash \ + --chain-id 11155111 \ + --safe-address $SAFE \ + --safe-version 1.4.1 \ + --to $POOL --value 0 --data \ + --operation 0 \ + --nonce --json +# {"domainHash": "0x...", "messageHash": "0x...", "safeTxHash": "0x..."} +``` + +Batched (more than one call, so `operation` 1 and `data` is the `multiSend(bytes)` calldata): + +```bash +clearsig safe-hash \ + --chain-id 11155111 \ + --safe-address $SAFE \ + --safe-version 1.4.1 \ + --to 0x9641d764fc13c8B624c04430C7356C1C7C8102e2 --value 0 --data \ + --operation 1 \ + --nonce --json +``` + +For `operation` 1 the `to` must equal the canonical `MultiSendCallOnly` +`0x9641d764fc13c8B624c04430C7356C1C7C8102e2`. A DELEGATECALL to any other target executes +arbitrary code as the Safe, and `clearsig` prints no delegatecall warning, so the signer checks +that address explicitly, not just the hash. + +Two operational notes: + +- Pass `--safe-version` explicitly. The flag defaults silently to 1.4.1, and the version selects + the domain and SafeTx encoding used for the hash; read the on-chain value with + `cast call $SAFE "VERSION()(string)"`. +- Other implementations exist: Cyfrin [`safe-hash`](https://github.com/cyfrin/safe-hash-rs) and + pcaversaccio [`safe_hashes.sh`](https://github.com/pcaversaccio/safe-tx-hashes-util) (needs + bash >= 4, the macOS default 3.2 fails, and it hardcodes its chain list: Avalanche Fuji 43113, + for example, is absent). Using a different implementation than the proposer used adds an + independent check: the same wrong input then has to produce the same wrong hash in two unrelated + codebases. + +## Transaction Service support matrix + +`GET https://api.safe.global/tx-service//api/v1/about/` returns 200 when the network is +served and 404 when not (full list: `GET https://safe-client.safe.global/v1/chains`). Measured +2026-07-10: + +| Network | Shortname | Transaction Service | +| ---------------- | --------------- | ----------------------------------- | +| Ethereum Sepolia | `sep` | 200 (served) | +| Base Sepolia | `basesep` | 200 (served) | +| Mantle Sepolia | `mnt-sep` | 200 (served) | +| Avalanche Fuji | `fuji` | 404 (not served; mainnet `avax` is) | +| 0G Galileo | `0g-galileo` | 404 (not served; mainnet `0g` is) | +| Ink Sepolia | `ink-sepolia` | 404 (not served; mainnet `ink` is) | +| Plume testnet | `plume-testnet` | 404 (not served) | + +The matrix describes the Safe Transaction Service only. Base Sepolia and Avalanche Fuji are listed +for reference; the repo's `HelperConfig` does not currently target them. + +A 404 gates only the Transaction Service path. The direct `execTransaction` path works on all of +these regardless, so an unserved network is never a blocker for Safe mode. + +## Batching multiple operations into one Safe transaction + +Safe mode composes: several operations can execute as a single Safe transaction, with one review +artifact, one `safeTxHash`, one signing round, one Safe nonce, and atomic execution (a failing +call reverts the whole batch). EOA mode broadcasts sequentially and is unchanged. + +Composition works because the three layers are strictly separated: + +1. **Builders** (`src/actions/CctActions.sol`) return `Call[]` structs (target, value, calldata). + Every write operation is defined there exactly once, and `CctActions.concat` flattens two + `Call[]`s into one. +2. **Artifacts** are canonical Safe Transaction Builder JSON (`batches/..json`). + `SafeBatchLoader` parses one back into the identical `Call[]`, so an emitted file is not just a + review document, it is a loadable input. +3. **Executors** are selected by `MODE`: the same `Call[]` broadcasts call-by-call in EOA mode or + becomes one Safe transaction in Safe mode. + +That gives two ways to batch your own operations: compose emitted batch files with `ExecuteBatch` +(no Solidity required), or compose `Call[]`s directly in a custom script. + +### Composing emitted batch files with `ExecuteBatch` + +The recipe has two explicit steps. First, run each write script with `MODE=safe` (emit only) and a +distinct `BATCH_NAME`; each run performs its own preflight and writes its batch file. Then run +`script/governance/ExecuteBatch.s.sol` with the files to compose, in execution order: + +```bash +SAFE_ADDRESS=$SAFE BATCH_NAME=full-setup \ +BATCH_FILES=batches/claim-accept.11155111.json,batches/set-pool.11155111.json,batches/lane.11155111.json \ +forge script script/governance/ExecuteBatch.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL +``` + +`ExecuteBatch` loads every file back into the action layer's `Call[]` (`SafeBatchLoader`, the exact +inverse of the emitter), validates that each was emitted for this chain and this Safe (a mismatch +reverts naming the offending file), concatenates them in the given order, and emits one merged +batch file. That merged file is the single review artifact, and all three submission channels +consume it unchanged: review then re-run with `SAFE_EXEC=direct` for one atomic `execTransaction`; +or propose the merged file via `safe-cli`; or import it in the Safe{Wallet} Transaction Builder. + +`ExecuteBatch` is Safe-only and does not read `MODE`: an EOA has no execution context that could +run a batch atomically. Its environment variables: + +| Variable | Required | Meaning | +| ------------------ | ---------------------- | ----------------------------------------------------------------------------- | +| `SAFE_ADDRESS` | yes | The Safe executing the batch; every input file must have been emitted for it. | +| `BATCH_NAME` | yes (no default) | Name of the merged artifact, so a composition never overwrites another batch. | +| `BATCH_FILES` | yes | Comma-separated batch JSON paths, in execution order. | +| `SAFE_EXEC` | no | Unset: emit the merged batch only. `direct`: execute it in the same run. | +| `SAFE_SIGNER_KEYS` | for `SAFE_EXEC=direct` | Comma-separated owner private keys, at least `threshold` of them. | + +### Worked example: full token setup as one Safe transaction + +Goal: register the Safe as the token's CCIP admin, point the token at its pool in the +TokenAdminRegistry, and configure the lane to Mantle Sepolia, all in one Safe transaction on +Ethereum Sepolia. Prerequisites: the token's `getCCIPAdmin()` (or `owner()`) already returns the +Safe, since the Safe is the account executing the registration pair, and the Safe owns the pool, +since `applyChainUpdates` is owner-gated at execution time. + +Step 1: emit each operation's batch. `MODE=safe` without `SAFE_EXEC` broadcasts nothing, so no +`--broadcast` flag; each run still needs the RPC for its preflight reads. + +```bash +MODE=safe SAFE_ADDRESS=$SAFE CCIP_ADMIN_ADDRESS=$SAFE BATCH_NAME=claim-accept \ +TOKEN=$TOKEN \ +forge script script/setup/ClaimAndAcceptAdmin.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL +# writes batches/claim-accept.11155111.json (2 calls: registerAdmin* + acceptAdminRole) + +MODE=safe SAFE_ADDRESS=$SAFE BATCH_NAME=set-pool \ +TOKEN=$TOKEN TOKEN_POOL=$POOL \ +forge script script/setup/SetPool.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL +# writes batches/set-pool.11155111.json (1 call: setPool) + +MODE=safe SAFE_ADDRESS=$SAFE BATCH_NAME=lane \ +TOKEN_POOL=$POOL DEST_CHAIN=MANTLE_SEPOLIA \ +DEST_TOKEN_POOL=$REMOTE_POOL DEST_TOKEN=$REMOTE_TOKEN \ +forge script script/setup/ApplyChainUpdates.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL +# writes batches/lane.11155111.json (1 call: applyChainUpdates) +``` + +Step 2: compose, in execution order (the registration pair must precede `setPool`, which must +precede the lane config): + +```bash +SAFE_ADDRESS=$SAFE BATCH_NAME=full-setup \ +BATCH_FILES=batches/claim-accept.11155111.json,batches/set-pool.11155111.json,batches/lane.11155111.json \ +forge script script/governance/ExecuteBatch.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL +# writes batches/full-setup.11155111.json (4 calls, one Safe transaction) +``` + +Step 3: review the merged file (decode `to`, `value`, selector, arguments for all four calls), +then execute it through any of the three channels. Directly: + +```bash +SAFE_ADDRESS=$SAFE BATCH_NAME=full-setup \ +BATCH_FILES=batches/claim-accept.11155111.json,batches/set-pool.11155111.json,batches/lane.11155111.json \ +SAFE_EXEC=direct SAFE_SIGNER_KEYS=$OWNER_KEY_1,$OWNER_KEY_2 \ +forge script script/governance/ExecuteBatch.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --broadcast +``` + +The four calls execute as a single DELEGATECALL into `MultiSendCallOnly`: one `safeTxHash`, one +nonce, and if any call reverts (for example, the Safe is not the token's current admin), the whole +setup reverts and no partial state lands. + +### Composing in Solidity for custom scripts + +When the pre-built scripts do not cover the combination you need, compose at the builder layer. +Every `CctActions` builder returns a `Call[]`, `CctActions.concat` merges them, and +`executeCalls(...)` (inherited from `EoaExecutor`) runs the result in whatever executor `MODE` +selects, so a custom script gets both modes for free: + +```solidity +import {CctActions} from "../src/actions/CctActions.sol"; +import {EoaExecutor} from "../src/base/EoaExecutor.s.sol"; + +contract MyBatchedSetup is EoaExecutor { + function run() external { + CctActions.Call[] memory calls = CctActions.concat( + CctActions.setPool(tokenAdminRegistry, token, pool), + CctActions.setDynamicConfig(pool, router, rateLimitAdmin, feeAdmin) + ); + // MODE unset/eoa: broadcast each call. MODE=safe: emit one batch file + // (and execute it when SAFE_EXEC=direct). + executeCalls(calls); + } +} +``` + +Preflight checks in a custom script that assert on-chain authority (owner, pending administrator, +admin role) must compare against `executingAccount()`, not `broadcaster()`: in Safe mode the +account acting on-chain is the Safe, while the broadcaster only emits or submits. + +### Composition rules + +Three rules, all consequences of per-operation preflight reading pre-batch chain state: + +- **Claim + accept in one batch:** use `script/setup/ClaimAndAcceptAdmin.s.sol` (the atomic + registration pair). The separate `AcceptAdminRole` script preflight-requires the pending + administrator to already equal the executing account (the Safe in Safe mode) when the script + runs, so it cannot be composed with the claim that creates that pending state. +- **Rate limits for a lane the same batch creates** ride inside `ApplyChainUpdates`' per-lane + config. `UpdateRateLimiters` seeds untouched directions from live lane state, so batch it only + against lanes that already exist. +- **`CCIP_ADMIN_ADDRESS=$SAFE_ADDRESS`** for registration batches: the Safe executes the batch, + so the Safe must be the token's current CCIP admin, not the emitting EOA. + +Batch files are inputs to a single signing round: regenerate them per run (`batches/` is +gitignored). A batch emitted yesterday encodes yesterday's preflight. + +Gas: one merged Safe transaction pays the 21k intrinsic cost and the Safe's signature-check +overhead (~15k) once instead of N times, minus a small per-call MultiSend loop cost. Derived from +live receipts, a five-operation setup costs about 685k as five separate Safe transactions versus +about 545k batched, roughly 20% less; `test/governance/ExecuteBatch.t.sol` pins merged < +sequential in CI (a three-operation in-EVM comparison). The larger saving is operational: one +co-sign round instead of N nonce-serialized signing rounds. diff --git a/docs/pool-versions.md b/docs/pool-versions.md index 733e7a1..cc42d86 100644 --- a/docs/pool-versions.md +++ b/docs/pool-versions.md @@ -65,6 +65,76 @@ The catalog and ranges are validated live, not only against source: every catalo repo scripts (adoption, lane updates including the 1.5.0 encoding, rate-limit updates, read-backs), including end-to-end cross-chain token transfers per version in both directions over the same lane. +## Removing a lane or a pool + +Teardown has two distinct operations, and they are not the same: + +- **Remove a whole chain (tear the lane down).** `script/configure/remote-chains/RemoveChain.s.sol` + fully unsupports a remote chain: it removes the selector and deletes the remote-chain config + (pools, remote token, rate limits), so `isSupportedChain` returns false and both directions revert + `ChainNotAllowed` afterward. This works on **every** cataloged version; only the encoding differs, + and the script dispatches on the source pool's version exactly like `ApplyChainUpdates`. A 1.5.0 + pool takes the single-argument shape with one `allowed:false` entry (both rate-limit configs + disabled and zeroed, which 1.5.0's validation requires: an enabled config reverts + `RateLimitMustBeDisabled`, a disabled-but-nonzero one reverts `DisabledNonZeroRateLimit`); 1.5.1 and + later take the modern `applyChainUpdates(uint64[] toRemove, ChainUpdate[] toAdd)` with the selector + in `toRemove` and an empty `toAdd`. On 2.0.0 the delete also clears the chain's fast-finality + inbound/outbound rate-limit buckets (separate mappings from the standard limits). Removing a chain + the pool does not support reverts `NonExistentChain`; the script pre-checks `isSupportedChain` and + refuses with a friendly reason first. + +- **Remove one remote pool from a still-supported chain.** + `script/configure/remote-pools/RemoveRemotePool.s.sol` drops a single remote pool via + `removeRemotePool`, leaving the chain supported. This is a **1.5.1+** operation: 1.5.0 holds exactly + one remote pool per chain (reachable only via `setRemotePool`, a replace), so there is no standalone + pool removal on 1.5.0 and the script refuses with a message pointing at whole-chain teardown. + + The last-pool nuance (1.5.1+): `removeRemotePool` never guards against emptying the set, and it does + not touch the chain selector. Removing the last remaining pool therefore leaves the chain + **supported with zero pools**. Outbound `lockOrBurn` still passes the pool's own validation (it does + not consult the remote-pool set, though an end-to-end send still needs the Router onRamp and the + local lane), but inbound `releaseOrMint` reverts `InvalidSourcePoolAddress` because no remote pool + matches. To fully drop such a lane, follow the pool removal with a chain removal (`RemoveChain`), or + use `RemoveChain` directly. + +### Tearing down a live lane safely + +The scripts execute the removal the moment you run them; they warn about in-flight messages but do not +gate on drain. On a lane carrying real traffic, run the removal as a sequence, not a one-shot: + +1. **Throttle new outbound, keep inbound open.** On the source pool set a strict-but-enabled outbound + limit (for example `outbound {isEnabled:true, capacity:2, rate:1}`, leaving `inbound` open) via + `UpdateRateLimiters`. Do not use `isEnabled:false`: that disables limiting rather than throttling + it. This stops new sends without rejecting messages already in flight. +2. **Wait out finality and executor drain.** Wait past source finality, then confirm every in-flight + `messageId` on the lane reaches `SUCCESS` (`ccip-cli show`, the CCIP Explorer, or the destination + OffRamp's `getExecutionState == 2`) plus a safety margin. +3. **Remove both directions, sending side first.** `RemoveChain` is single-sided by construction, so a + full teardown runs it on **both** chains. Remove the sending side first and let the other chain + drain its inbound before removing the reverse direction. +4. **Then run `RemoveChain`.** Only now is the removal safe. + +**The reciprocal half-open window.** If you remove chain B on A while B still supports A, a B→A message +already sent will lock or burn on B (B still recognizes A) but revert `releaseOrMint` on A, because A +no longer recognizes B as a source. It sits at `FAILURE` with no auto-recovery. Always drain **both** +directions before removing either side. + +**Rollback, and what removal does and does not touch.** Re-adding a removed lane is just +`ApplyChainUpdates` / `make add-lane` plus an apply. Chain removal deletes only what lives in the +pool's own remote-chain config: the remote pools, the remote token, and the standard rate-limiter +bucket state, plus on 2.0.0 the fast-finality inbound/outbound rate-limit buckets. Everything there +comes back only by re-adding and re-applying the lane (rate-limiter accumulator state does not +survive). + +What removal does **not** touch is the more important footgun, because it is keyed by chain selector +on separate storage that `applyChainUpdates` never reaches: the **CCV config and thresholds** (they +live on the `AdvancedPoolHooks` contract, not the pool) and the **token-transfer-fee config** +(`s_tokenTransferFeeConfig`, cleared only by `applyTokenTransferFeeConfigUpdates`). Both **survive** +chain removal. So re-adding a previously-removed lane silently reactivates whatever CCV and fee config +was set for that selector before. If the intent is a clean teardown, clear those separately (the +hooks' CCV update and `applyTokenTransferFeeConfigUpdates` disable list); if the intent is a rollback, +they are already in place and must not be double-applied. + ## Unknown versions: writes refuse, reads degrade diff --git a/script/configure/remote-chains/RemoveChain.s.sol b/script/configure/remote-chains/RemoveChain.s.sol new file mode 100644 index 0000000..d6fe819 --- /dev/null +++ b/script/configure/remote-chains/RemoveChain.s.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {console} from "forge-std/Script.sol"; +import {HelperConfig} from "../../HelperConfig.s.sol"; +import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; +import {PoolVersion} from "../../utils/PoolVersion.s.sol"; +import {PoolVersions} from "../../../src/PoolVersions.sol"; +import {CctActions, ITokenPoolV150} from "../../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../../src/base/EoaExecutor.s.sol"; + +/// @notice Fully unsupports a remote chain on the source TokenPool: removes the chain selector and +/// deletes its remote-chain config (pools, remote token, rate limits). After this call +/// `isSupportedChain` returns false and neither outbound `lockOrBurn` nor inbound +/// `releaseOrMint` accept the lane until it is added again with `applyChainUpdates`. +/// +/// @dev This is the whole-lane teardown, distinct from `RemoveRemotePool` (which drops ONE remote +/// pool from a still-supported chain, 1.5.1+ only). Chain removal works on EVERY cataloged pool +/// version; only the on-chain encoding differs, and the script dispatches on the source pool's +/// `typeAndVersion` (see docs/pool-versions.md): +/// - 1.5.0 uses its single-argument `applyChainUpdates(ChainUpdate[])` with one `allowed:false` +/// entry (disabled, zeroed rate-limit configs, as the 1.5.0 validation requires). +/// - 1.5.1 and later use the modern `applyChainUpdates(uint64[] toRemove, ChainUpdate[] toAdd)` +/// with the selector in `toRemove` and an empty `toAdd`. +/// +/// WARNING: All inflight transactions on this lane will be rejected after removal. Ensure there +/// are no inflight messages to or from this chain before removing it to avoid loss of funds. +/// +/// Environment Variables (required): +/// DEST_CHAIN - The remote chain to unsupport (e.g. MANTLE_SEPOLIA) +/// +/// Usage example: +/// DEST_CHAIN=MANTLE_SEPOLIA \ +/// forge script script/configure/remote-chains/RemoveChain.s.sol --rpc-url $ETHEREUM_SEPOLIA_RPC_URL --account --broadcast +contract RemoveChain is EoaExecutor { + HelperConfig public helperConfig; + + function run() external { + // ── Required env vars ────────────────────────────────────────────── + string memory destChainName = vm.envString("DEST_CHAIN"); + + // ── Resolve chain IDs and selectors ─────────────────────────────── + helperConfig = new HelperConfig(); + uint256 sourceChainId = block.chainid; + uint256 destChainId = helperConfig.parseChainName(destChainName); + uint64 remoteChainSelector = helperConfig.getNetworkConfig(destChainId).chainSelector; + + // ── Resolve pool address ─────────────────────────────────────────── + address tokenPoolAddress = helperConfig.getDeployedTokenPool(sourceChainId); + require( + tokenPoolAddress != address(0), + string.concat( + "Token pool not deployed. Set the ", + helperConfig.getNetworkConfig(sourceChainId).chainNameIdentifier, + "_TOKEN_POOL environment variable. Alternatively, use the inline alias TOKEN_POOL=0x..." + ) + ); + + _removeChain(tokenPoolAddress, remoteChainSelector, destChainName, destChainId); + } + + /// @dev Everything after input resolution. Split from run() so the version dispatch is testable + /// with an injected pool address (env-based pool resolution is process-global and cannot be + /// exercised race-free under parallel test suites), mirroring RemoveRemotePool. + function _removeChain( + address tokenPoolAddress, + uint64 remoteChainSelector, + string memory destChainName, + uint256 destChainId + ) internal { + uint256 sourceChainId = block.chainid; + string memory sourceChainName = helperConfig.getChainName(sourceChainId); + + TokenPool tokenPool = TokenPool(tokenPoolAddress); + + // Pre-check: unsupporting a chain the pool does not support reverts NonExistentChain on-chain + // (every version). Surface the friendly reason here rather than a raw contract revert. + require( + tokenPool.isSupportedChain(remoteChainSelector), + string.concat("Remote chain not supported on this pool (nothing to remove): ", destChainName) + ); + + // ── Header ───────────────────────────────────────────────────────── + console.log(""); + console.log("========================================"); + console.log(unicode"➖ Remove Remote Chain"); + console.log("========================================"); + console.log(string.concat("Chain: ", sourceChainName)); + console.log(string.concat("Remote Chain: ", helperConfig.getChainName(destChainId))); + console.log(string.concat("Token Pool: ", vm.toString(tokenPoolAddress))); + console.log(string.concat("Action: ", "Unsupport remote chain (full lane teardown)")); + console.log("========================================"); + console.log(""); + console.log(string.concat("Selector to Remove: ", vm.toString(remoteChainSelector))); + console.log(""); + console.log(unicode"⚠️ WARNING: All inflight transactions on this lane will be rejected after removal."); + console.log(" Ensure there are no inflight messages to or from this chain before proceeding."); + console.log(""); + + // ── Resolve the version BEFORE any version-shaped read ───────────── + // A 1.5.0 pool has only the SINGULAR getRemotePool; reading the plural getRemotePools before + // dispatch would raw-revert it and never reach the 1.5.0 encoding below. Resolve first, then + // read through the version-safe helper (singular on 1.5.0, plural from 1.5.1) — the same + // fence-before-read ordering RemoveRemotePool uses. + (PoolVersions.Version poolVersion, string memory poolTypeAndVersion) = PoolVersion.resolve(tokenPoolAddress); + console.log(string.concat("Pool contract: ", poolTypeAndVersion)); + console.log(""); + + // ── Show current remote pools for the lane being torn down ───────── + bytes[] memory currentPools = PoolVersion.remotePools(tokenPoolAddress, poolVersion, remoteChainSelector); + console.log(string.concat("Current Remote Pools on this lane: ", vm.toString(currentPools.length))); + for (uint256 i = 0; i < currentPools.length; i++) { + if (currentPools[i].length == 32) { + console.log( + string.concat(" [", vm.toString(i), "] ", vm.toString(abi.decode(currentPools[i], (address)))) + ); + } else { + console.log(string.concat(" [", vm.toString(i), "] (raw) ", vm.toString(currentPools[i]))); + } + } + console.log(""); + + console.log(string.concat("[Step 1] Removing remote chain on ", sourceChainName)); + + executeCalls(_buildChainRemovalCalls(poolVersion, tokenPoolAddress, remoteChainSelector)); + + console.log(unicode"✅ Remote chain removed successfully!"); + console.log(""); + console.log("========================================"); + console.log(string.concat(unicode"✅ Complete on ", sourceChainName, "!")); + console.log("========================================"); + console.log(string.concat("Token Pool: ", vm.toString(tokenPoolAddress))); + console.log(string.concat("Removed Chain: ", helperConfig.getChainName(destChainId))); + console.log(string.concat("Selector: ", vm.toString(remoteChainSelector))); + console.log( + string.concat( + "Token Pool: ", helperConfig.getExplorerUrl(sourceChainId, "/address/", tokenPoolAddress) + ) + ); + console.log("========================================"); + console.log(""); + } + + /// @dev The exhaustive version switch for a whole-chain removal, matching ApplyChainUpdates' + /// lane-update dispatch: 1.5.0 takes the single-argument encoding with one `allowed:false` + /// entry, every later cataloged version takes the modern (removes[], adds[]) encoding with + /// the selector in removes[] and no adds[]. `PoolVersion.resolve` refuses uncataloged + /// versions before this switch runs, and a version added to the catalog without a branch + /// here fails loudly instead of falling through to the modern encoding. + function _buildChainRemovalCalls(PoolVersions.Version version, address poolAddress, uint64 remoteChainSelector) + internal + pure + returns (CctActions.Call[] memory) + { + if (version == PoolVersions.Version.V1_5_0) { + console.log("Pool contract version 1.5.0 detected; using the 1.5.0 lane-update encoding."); + ITokenPoolV150.ChainUpdate[] memory removals = new ITokenPoolV150.ChainUpdate[](1); + removals[0] = ITokenPoolV150.ChainUpdate({ + remoteChainSelector: remoteChainSelector, + allowed: false, + remotePoolAddress: "", + remoteTokenAddress: "", + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + return CctActions.applyChainUpdatesV150(poolAddress, removals); + } + if ( + version == PoolVersions.Version.V1_5_1 || version == PoolVersions.Version.V1_6_1 + || version == PoolVersions.Version.V2_0_0 + ) { + uint64[] memory removes = new uint64[](1); + removes[0] = remoteChainSelector; + TokenPool.ChainUpdate[] memory noAdds = new TokenPool.ChainUpdate[](0); + return CctActions.applyChainUpdates(poolAddress, removes, noAdds); + } + revert( + "RemoveChain: pool version has no chain-removal dispatch branch; extend the switch here and the catalog in src/PoolVersions.sol" + ); + } +} diff --git a/script/configure/remote-pools/RemoveRemotePool.s.sol b/script/configure/remote-pools/RemoveRemotePool.s.sol index c5975b9..2426f81 100644 --- a/script/configure/remote-pools/RemoveRemotePool.s.sol +++ b/script/configure/remote-pools/RemoveRemotePool.s.sol @@ -68,6 +68,13 @@ contract RemoveRemotePool is EoaExecutor { // Resolve and fence the pool version BEFORE any version-shaped read: a 1.5.0 pool must get // the named refusal here, not a raw selector revert from getRemotePools below. (PoolVersions.Version poolVersion,) = PoolVersion.resolve(tokenPoolAddress); + // 1.5.0 holds exactly one remote pool per chain, reachable only via setRemotePool (replace), + // so removing "just the pool" is not a standalone operation: dropping it necessarily drops + // the lane. Point the operator at the whole-chain teardown instead of the generic refusal. + require( + poolVersion != PoolVersions.Version.V1_5_0, + "removeRemotePool is not available on a 1.5.0 pool: 1.5.0 holds one remote pool per chain, so there is no standalone pool removal. To tear down the whole lane use script/configure/remote-chains/RemoveChain.s.sol; to swap the pool use AddRemotePool/ApplyChainUpdates." + ); PoolVersions.requireSupports(PoolVersions.Op.REMOVE_REMOTE_POOL, poolVersion, tokenPoolAddress); TokenPool tokenPool = TokenPool(tokenPoolAddress); diff --git a/script/governance/DeploySafe.s.sol b/script/governance/DeploySafe.s.sol new file mode 100644 index 0000000..67929a2 --- /dev/null +++ b/script/governance/DeploySafe.s.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Script, console} from "forge-std/Script.sol"; +import {ISafe, ISafeProxyFactory, SafeCanonical} from "../../src/base/ISafe.sol"; + +/// @notice Deploys a Safe from the canonical Safe v1.4.1 stack: +/// `SafeProxyFactory.createProxyWithNonce(SafeL2, setup(...), saltNonce)`. +/// +/// Because the factory, singleton, and fallback handler live at the SAME address on every supported +/// chain and `createProxyWithNonce` is CREATE2, the same owners + threshold + saltNonce yield the +/// SAME Safe address on every chain — which is what a mirrored multi-chain fleet needs. The script +/// predicts the CREATE2 address first, asserts the deployment lands on it, and is idempotent: if the +/// predicted address already has code (the Safe was already deployed, here or on a mirrored chain +/// setup rerun), it logs and returns instead of reverting. +/// +/// Environment Variables: +/// SAFE_OWNERS (required) comma-separated owner addresses, e.g. "0xabc...,0xdef...,0x123..." +/// SAFE_THRESHOLD (required) number of owner signatures required (1..len(SAFE_OWNERS)) +/// SAFE_SALT_NONCE (optional) CREATE2 salt nonce, default 0 — reuse the SAME value on every chain +/// to mirror the address +contract DeploySafe is Script { + function run() external returns (address safe) { + address[] memory owners = vm.envAddress("SAFE_OWNERS", ","); + uint256 threshold = vm.envUint("SAFE_THRESHOLD"); + uint256 saltNonce = vm.envOr("SAFE_SALT_NONCE", uint256(0)); + + require(owners.length > 0, "SAFE_OWNERS must list at least one owner"); + require(threshold >= 1 && threshold <= owners.length, "SAFE_THRESHOLD must be in [1, len(SAFE_OWNERS)]"); + require( + SafeCanonical.PROXY_FACTORY.code.length > 0 && SafeCanonical.SAFE_L2_SINGLETON.code.length > 0, + "Canonical Safe v1.4.1 stack not deployed on this chain (factory/singleton have no code)" + ); + + bytes memory initializer = abi.encodeCall( + ISafe.setup, + ( + owners, + threshold, + address(0), // no setup delegatecall + "", + SafeCanonical.COMPATIBILITY_FALLBACK_HANDLER, + address(0), // no payment token + 0, // no payment + payable(address(0)) + ) + ); + address predicted = predictSafeAddress(initializer, saltNonce); + + console.log(""); + console.log("========================================"); + console.log(unicode"🔐 Deploy Safe (canonical v1.4.1, CREATE2)"); + console.log("========================================"); + console.log(string.concat("Owners: ", vm.toString(owners.length))); + for (uint256 i = 0; i < owners.length; i++) { + console.log(string.concat(" [", vm.toString(i), "] ", vm.toString(owners[i]))); + } + console.log(string.concat("Threshold: ", vm.toString(threshold))); + console.log(string.concat("Salt nonce: ", vm.toString(saltNonce))); + console.log(string.concat("Predicted address: ", vm.toString(predicted))); + + if (predicted.code.length > 0) { + console.log(unicode"✅ Safe already deployed at the predicted address; nothing to do."); + console.log("========================================"); + return predicted; + } + + vm.startBroadcast(); + safe = ISafeProxyFactory(SafeCanonical.PROXY_FACTORY) + .createProxyWithNonce(SafeCanonical.SAFE_L2_SINGLETON, initializer, saltNonce); + vm.stopBroadcast(); + + require(safe == predicted, "Deployed Safe address != CREATE2 prediction"); + console.log(unicode"✅ Safe deployed."); + console.log(string.concat("Safe address: ", vm.toString(safe))); + console.log("Same owners + threshold + salt nonce reproduce this address on every chain"); + console.log("with the canonical v1.4.1 stack (CREATE2 same-address property)."); + console.log("========================================"); + console.log(""); + } + + /// @notice Predicts the CREATE2 address `createProxyWithNonce` will deploy to: + /// salt = keccak256(keccak256(initializer) || saltNonce), init code = proxyCreationCode ++ + /// abi.encode(singleton). + function predictSafeAddress(bytes memory initializer, uint256 saltNonce) public pure returns (address) { + bytes32 salt = keccak256(abi.encodePacked(keccak256(initializer), saltNonce)); + bytes memory deploymentData = abi.encodePacked( + ISafeProxyFactory(SafeCanonical.PROXY_FACTORY).proxyCreationCode(), + uint256(uint160(SafeCanonical.SAFE_L2_SINGLETON)) + ); + return address( + uint160( + uint256( + keccak256( + abi.encodePacked(bytes1(0xff), SafeCanonical.PROXY_FACTORY, salt, keccak256(deploymentData)) + ) + ) + ) + ); + } +} diff --git a/script/governance/ExecuteBatch.s.sol b/script/governance/ExecuteBatch.s.sol new file mode 100644 index 0000000..9d1e2cc --- /dev/null +++ b/script/governance/ExecuteBatch.s.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Script, console} from "forge-std/Script.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {SafeBatchLoader} from "../../src/base/SafeBatchLoader.sol"; +import {SafeMode} from "../../src/base/SafeMode.sol"; + +/// @notice Composes several independently emitted Safe batches into ONE Safe meta-transaction. +/// +/// Workflow: run each write script with `MODE=safe` (emit-only) and a distinct `BATCH_NAME` — each +/// run performs its own preflight and writes `batches/..json`. Then run this script +/// with the files to compose, in execution order. It loads every file back into the action layer's +/// `Call[]` (validating each was emitted for THIS chain and THIS Safe), concatenates them, and hands +/// the merged batch to the standard Safe executor: ONE merged Transaction Builder JSON is emitted +/// for review (importable in the Safe{Wallet} UI, proposable via `safe-cli`, unchanged), and with +/// `SAFE_EXEC=direct` it executes as ONE atomic `execTransaction` (a single MultiSendCallOnly batch: +/// one safeTxHash, one signing ceremony, one Safe nonce; a failing call reverts the whole batch). +/// +/// Safe mode only, by design: an EOA has no execution context to batch atomically, so this script +/// does not participate in the `MODE` switch. +/// +/// Environment Variables: +/// SAFE_ADDRESS (required) the Safe executing the batch; every input file must match it +/// BATCH_NAME (required, no default) name of the MERGED batch artifact — explicit so a +/// composition can never silently clobber another batch file +/// BATCH_FILES (required) comma-separated batch JSON paths, in EXECUTION ORDER +/// SAFE_EXEC (optional) unset = emit the merged batch only; `direct` = execute now +/// SAFE_SIGNER_KEYS (required for SAFE_EXEC=direct) comma-separated owner keys, never logged +/// +/// Example: +/// SAFE_ADDRESS=$SAFE BATCH_NAME=full-setup \ +/// BATCH_FILES=batches/claim.11155111.json,batches/set-pool.11155111.json \ +/// forge script script/governance/ExecuteBatch.s.sol --rpc-url $RPC +contract ExecuteBatch is Script { + function run() external returns (string memory mergedBatchPath) { + address safe = vm.envAddress("SAFE_ADDRESS"); + // Read BATCH_NAME without a default: composing under the executor's fallback name is the + // silent-clobber footgun this script exists to avoid. + string memory batchName = vm.envString("BATCH_NAME"); + require(bytes(batchName).length > 0, "BATCH_NAME must be set to name the merged batch"); + string[] memory files = vm.envString("BATCH_FILES", ","); + require(files.length > 0, "BATCH_FILES must list at least one batch JSON"); + + console.log(""); + console.log("========================================"); + console.log(unicode"🧩 Compose Safe batches into one transaction"); + console.log("========================================"); + console.log(string.concat("Safe: ", vm.toString(safe))); + console.log(string.concat("Merged batch: ", batchName)); + console.log(string.concat("Input files: ", vm.toString(files.length))); + + for (uint256 i = 0; i < files.length; i++) { + console.log(string.concat(" [", vm.toString(i + 1), "] ", files[i])); + } + CctActions.Call[] memory merged = SafeBatchLoader.loadMany(files, block.chainid, safe); + console.log(string.concat("Total calls: ", vm.toString(merged.length))); + console.log("========================================"); + + // The standard Safe executor takes over: full per-call review logging, ONE merged canonical + // batch JSON, and (SAFE_EXEC=direct) ONE atomic execTransaction. + mergedBatchPath = SafeMode.run(merged); + } +} diff --git a/script/setup/AcceptAdminRole.s.sol b/script/setup/AcceptAdminRole.s.sol index 0a8f063..bb3b7a3 100644 --- a/script/setup/AcceptAdminRole.s.sol +++ b/script/setup/AcceptAdminRole.s.sol @@ -51,11 +51,12 @@ contract AcceptAdminRole is EoaExecutor { console.log(string.concat(" Token Admin Registry: ", vm.toString(config.tokenAdminRegistry))); console.log(string.concat(" Pending Administrator: ", vm.toString(pendingAdministrator))); - address signer = broadcaster(); - console.log(string.concat(" Signer: ", vm.toString(signer))); + // The account accepting on-chain: the Safe in safe mode, the broadcaster otherwise. + address acceptor = executingAccount(); + console.log(string.concat(" Acceptor: ", vm.toString(acceptor))); console.log(""); - require(pendingAdministrator == signer, "Only the pending administrator can accept the admin role"); + require(pendingAdministrator == acceptor, "Only the pending administrator can accept the admin role"); console.log(string.concat("\n[Step 1] Accepting admin role for token on ", chainName)); executeCalls(CctActions.acceptAdminRole(config.tokenAdminRegistry, tokenAddress)); @@ -67,7 +68,7 @@ contract AcceptAdminRole is EoaExecutor { console.log("========================================"); console.log(string.concat("Token Address: ", vm.toString(tokenAddress))); console.log(string.concat("Token Address: ", helperConfig.getExplorerUrl(chainId, "/address/", tokenAddress))); - console.log(string.concat("New Administrator: ", vm.toString(signer))); + console.log(string.concat("New Administrator: ", vm.toString(acceptor))); console.log("========================================"); console.log(""); } diff --git a/script/setup/ClaimAndAcceptAdmin.s.sol b/script/setup/ClaimAndAcceptAdmin.s.sol new file mode 100644 index 0000000..aafd0fc --- /dev/null +++ b/script/setup/ClaimAndAcceptAdmin.s.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {console} from "forge-std/Script.sol"; +import {HelperConfig} from "../HelperConfig.s.sol"; // Network configuration helper +import {IGetCCIPAdmin} from "@chainlink/contracts-ccip/contracts/interfaces/IGetCCIPAdmin.sol"; +import {IOwner} from "@chainlink/contracts-ccip/contracts/interfaces/IOwner.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {EoaExecutor} from "../../src/base/EoaExecutor.s.sol"; + +/// @notice Claims AND accepts the CCIP token admin as ONE atomic registration pair — the claim sets +/// the executing account as the registry's pending administrator, so the accept in the same batch +/// succeeds. This is the batch-friendly counterpart of running `ClaimAdmin` then `AcceptAdminRole`: +/// `AcceptAdminRole` preflight-requires the pending administrator to ALREADY be set when the script +/// runs, so the two-script sequence cannot be composed into one deferred Safe batch — this wrapper +/// can, because the pair executes together. It uses the same claim-path probe as `ClaimAdmin` +/// (getCCIPAdmin() preferred, owner() fallback). +/// +/// In Safe mode, set CCIP_ADMIN_ADDRESS to the Safe: the Safe is the account executing the pair, so +/// it must be the token's current CCIP admin (or owner) and becomes the registry administrator. +/// +/// Environment Variables: +/// TOKEN / _TOKEN (required) the token to register +/// CCIP_ADMIN_ADDRESS (optional) expected current admin; defaults to the script broadcaster. +/// MUST be the Safe address when running with MODE=safe. +contract ClaimAndAcceptAdmin is EoaExecutor { + HelperConfig public helperConfig; + + function run() external { + helperConfig = new HelperConfig(); + + uint256 chainId = block.chainid; + string memory chainName = helperConfig.getChainName(chainId); + HelperConfig.NetworkConfig memory config = helperConfig.getNetworkConfig(chainId); + + console.log(""); + console.log("========================================"); + console.log(unicode"👑 Claim + Accept Token Admin (one batch)"); + console.log("========================================"); + console.log(string.concat("Chain: ", chainName)); + console.log(string.concat("Action: ", "Claim and accept token admin atomically")); + console.log("========================================"); + console.log(""); + + // Get deployed token address — TOKEN env var takes priority, then {CHAIN}_TOKEN + address tokenAddress = helperConfig.getDeployedToken(chainId); + require( + tokenAddress != address(0), + string.concat( + "Token not deployed. Set TOKEN or ", config.chainNameIdentifier, "_TOKEN environment variable." + ) + ); + + address registryModuleOwnerCustom = config.registryModuleOwnerCustom; + require(registryModuleOwnerCustom != address(0), "RegistryModuleOwnerCustom not configured for this network"); + address tokenAdminRegistry = config.tokenAdminRegistry; + require(tokenAdminRegistry != address(0), "TokenAdminRegistry not configured for this network"); + + // Same claim-path probe as ClaimAdmin: getCCIPAdmin() preferred, owner() fallback. + address currentAdmin; + bool useCCIPAdmin = false; + try IGetCCIPAdmin(tokenAddress).getCCIPAdmin() returns (address admin) { + currentAdmin = admin; + useCCIPAdmin = true; + } catch { + try IOwner(tokenAddress).owner() returns (address admin) { + currentAdmin = admin; + useCCIPAdmin = false; + } catch { + revert("Token must implement either getCCIPAdmin() or owner()"); + } + } + + // The account that must execute the pair (defaults to the EOA broadcaster; the Safe when + // running in Safe mode). + address ccipAdminAddress = vm.envOr("CCIP_ADMIN_ADDRESS", broadcaster()); + + console.log("Registration Pair Parameters:"); + console.log(string.concat(" Token: ", vm.toString(tokenAddress))); + console.log(string.concat(" Current Admin: ", vm.toString(currentAdmin))); + console.log(string.concat(" Expected Admin: ", vm.toString(ccipAdminAddress))); + console.log(string.concat(" Registry Module: ", vm.toString(registryModuleOwnerCustom))); + console.log(string.concat(" TokenAdminRegistry: ", vm.toString(tokenAdminRegistry))); + console.log(string.concat(" Admin Method: ", useCCIPAdmin ? "getCCIPAdmin()" : "owner()")); + console.log(""); + + require(currentAdmin == ccipAdminAddress, "Admin of token doesn't match the expected admin address"); + + CctActions.Call[] memory calls; + if (useCCIPAdmin) { + console.log(string.concat("\n[Step 1] Claiming + accepting admin via getCCIPAdmin() on ", chainName)); + calls = CctActions.registerAndAcceptAdminViaGetCCIPAdmin( + registryModuleOwnerCustom, tokenAdminRegistry, tokenAddress + ); + } else { + console.log(string.concat("\n[Step 1] Claiming + accepting admin via owner() on ", chainName)); + calls = + CctActions.registerAndAcceptAdminViaOwner(registryModuleOwnerCustom, tokenAdminRegistry, tokenAddress); + } + executeCalls(calls); + console.log(unicode"✅ Registration pair executed!"); + + console.log(""); + console.log("========================================"); + console.log(string.concat(unicode"✅ Claim + Accept Complete on ", chainName, "!")); + console.log("========================================"); + console.log(string.concat("Token Address: ", vm.toString(tokenAddress))); + console.log(string.concat("Token Address: ", helperConfig.getExplorerUrl(chainId, "/address/", tokenAddress))); + console.log(string.concat("Admin Address: ", vm.toString(ccipAdminAddress))); + console.log("========================================"); + console.log(""); + } +} diff --git a/src/base/EoaExecutor.s.sol b/src/base/EoaExecutor.s.sol index d6dc221..22e66a3 100644 --- a/src/base/EoaExecutor.s.sol +++ b/src/base/EoaExecutor.s.sol @@ -3,16 +3,51 @@ pragma solidity 0.8.24; import {Script, console} from "forge-std/Script.sol"; import {CctActions} from "../actions/CctActions.sol"; +import {SafeMode} from "./SafeMode.sol"; /// @title EoaExecutor -/// @notice The EOA execution mode of the action layer: takes the `Call[]` a `CctActions` builder -/// produced, broadcasts each call in order from the script signer, and logs each target and -/// function selector. Scripts inherit this instead of calling contracts inline, so the calldata -/// a user reviews in the logs is exactly the calldata the action layer built. +/// @notice The execution entry point of the action layer: takes the `Call[]` a `CctActions` builder +/// produced and hands it to the executor the `MODE` environment variable selects. `MODE` +/// unset (or `eoa`, the default) broadcasts each call in order from the script signer — +/// today's behavior, unchanged. `MODE=safe` routes the IDENTICAL `Call[]` to the Safe +/// executor (`SafeMode`): the batch is emitted for review/signing instead of broadcast. +/// Scripts inherit this instead of calling contracts inline, so the calldata a user reviews +/// is exactly the calldata the action layer built, whichever mode executes it. abstract contract EoaExecutor is Script { - /// @notice Broadcasts every call in order, reverting on the first failure (atomic batch semantics — - /// a dry run surfaces the revert before anything is sent). - function executeCalls(CctActions.Call[] memory calls) internal { + /// @notice Executes the calls in the mode `MODE` selects (default `eoa`). Virtual so tests can + /// capture the built calls without broadcasting or writing batch artifacts. + function executeCalls(CctActions.Call[] memory calls) internal virtual { + string memory mode = _executionMode(); + if (keccak256(bytes(mode)) == keccak256(bytes("eoa"))) { + _broadcastCalls(calls); + } else if (keccak256(bytes(mode)) == keccak256(bytes("safe"))) { + SafeMode.run(calls); + } else { + revert(string.concat("Unknown MODE '", mode, "': use 'eoa' (default) or 'safe'.")); + } + } + + /// @notice The execution mode, from the `MODE` environment variable (default `eoa`). Virtual so + /// tests can pin a mode without mutating the process-wide environment. + function _executionMode() internal view virtual returns (string memory) { + return vm.envOr("MODE", string("eoa")); + } + + /// @notice The account that will EXECUTE the calls in the selected mode: the Safe in `safe` + /// mode, the script broadcaster otherwise. Preflight checks that assert on-chain + /// authority (owner, pending administrator, admin role) must compare against THIS + /// account — comparing against `broadcaster()` wrongly rejects Safe-mode runs, where the + /// broadcaster only emits or submits and the Safe is the account acting on-chain. + function executingAccount() internal returns (address) { + if (keccak256(bytes(_executionMode())) == keccak256(bytes("safe"))) { + return vm.envAddress("SAFE_ADDRESS"); + } + return broadcaster(); + } + + /// @notice The EOA mode: broadcasts every call in order, reverting on the first failure (atomic + /// batch semantics — a dry run surfaces the revert before anything is sent). + function _broadcastCalls(CctActions.Call[] memory calls) private { vm.startBroadcast(); for (uint256 i = 0; i < calls.length; i++) { console.log( diff --git a/src/base/ISafe.sol b/src/base/ISafe.sol new file mode 100644 index 0000000..1c6017d --- /dev/null +++ b/src/base/ISafe.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +/// @notice Minimal interface of a Safe (v1.3.0+) — only the surface the Safe execution mode needs: +/// nonce/owner reads, the on-chain `safeTxHash` computation, and `execTransaction`. Declared +/// locally so the repo does not vendor the full safe-smart-account package for four functions. +/// @dev `operation` is `uint8` here (0 = CALL, 1 = DELEGATECALL); Safe declares it as `Enum.Operation`, +/// which ABI-encodes identically. +interface ISafe { + function setup( + address[] calldata owners, + uint256 threshold, + address to, + bytes calldata data, + address fallbackHandler, + address paymentToken, + uint256 payment, + address payable paymentReceiver + ) external; + + function nonce() external view returns (uint256); + + function domainSeparator() external view returns (bytes32); + + function getOwners() external view returns (address[] memory); + + function getThreshold() external view returns (uint256); + + function isOwner(address owner) external view returns (bool); + + function getTransactionHash( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 _nonce + ) external view returns (bytes32); + + function execTransaction( + address to, + uint256 value, + bytes calldata data, + uint8 operation, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address payable refundReceiver, + bytes memory signatures + ) external payable returns (bool success); +} + +/// @notice Minimal interface of the canonical `SafeProxyFactory` (v1.4.1). +interface ISafeProxyFactory { + function createProxyWithNonce(address singleton, bytes memory initializer, uint256 saltNonce) + external + returns (address proxy); + + function proxyCreationCode() external pure returns (bytes memory); +} + +/// @notice Minimal interface of the canonical `MultiSendCallOnly` (v1.4.1): batches CALLs only, so a +/// malicious batch entry can never DELEGATECALL out of the Safe's context. +interface IMultiSendCallOnly { + function multiSend(bytes memory transactions) external payable; +} + +/// @notice Canonical Safe v1.4.1 deployment addresses. The Safe deployment stack is deployed at the +/// SAME address on every supported chain (deterministic CREATE2 from the canonical deployer), +/// which is what lets `DeploySafe` mirror one Safe address across a fleet of chains. +/// @dev Source: safe-global/safe-deployments v1.4.1 (verified on-chain on Ethereum Sepolia). +library SafeCanonical { + /// @notice `SafeProxyFactory` v1.4.1. + address internal constant PROXY_FACTORY = 0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67; + + /// @notice `SafeL2` v1.4.1 singleton (emits the extra L2 events indexers rely on; safe on L1 too). + address internal constant SAFE_L2_SINGLETON = 0x29fcB43b46531BcA003ddC8FCB67FFE91900C762; + + /// @notice `CompatibilityFallbackHandler` v1.4.1 (gives the Safe ERC-165/1271 compatibility). + address internal constant COMPATIBILITY_FALLBACK_HANDLER = 0xfd0732Dc9E303f09fCEf3a7388Ad10A83459Ec99; + + /// @notice `MultiSendCallOnly` v1.4.1 (the batching target for multi-call Safe transactions). + address internal constant MULTI_SEND_CALL_ONLY = 0x9641d764fc13c8B624c04430C7356C1C7C8102e2; +} diff --git a/src/base/SafeBatchEmitter.sol b/src/base/SafeBatchEmitter.sol new file mode 100644 index 0000000..758419c --- /dev/null +++ b/src/base/SafeBatchEmitter.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Vm} from "forge-std/Vm.sol"; +import {CctActions} from "../actions/CctActions.sol"; + +/// @title SafeBatchEmitter +/// @notice Serializes an action-layer `Call[]` into the canonical Safe Transaction Builder JSON — the +/// exact shape the Safe{Wallet} UI's "Transaction Builder" app imports and `safe-cli` can +/// propose to the Safe Transaction Service. The emitted file carries the IDENTICAL `to`, +/// `value`, and `data` the EOA mode would broadcast, so reviewing the batch reviews the same +/// calldata (`test/governance/SafeMode.t.sol` pins this byte-for-byte). +/// @dev The Safe UI wraps the listed `transactions` in a MultiSend on import, so the individual CALLs +/// are emitted, not the MultiSend wrapper. Values are decimal strings; data is 0x-hex. The JSON is +/// built entirely in Solidity via `vm` cheatcodes — no external tooling touches batch generation — +/// and is key-free: only addresses and calldata ever reach the artifact. +library SafeBatchEmitter { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + /// @dev a single JSON double-quote + function _q() private pure returns (string memory) { + return "\""; + } + + /// @dev `"key":"value"` (quoted string value) + function _str(string memory key, string memory value) private pure returns (string memory) { + string memory q = _q(); + return string.concat(q, key, q, ":", q, value, q); + } + + /// @dev `"key":` (raw/unquoted value: a nested object, number, or null) + function _raw(string memory key, string memory value) private pure returns (string memory) { + string memory q = _q(); + return string.concat(q, key, q, ":", value); + } + + function _transaction(CctActions.Call memory call) private pure returns (string memory) { + string memory obj = string.concat("{", _str("to", vm.toString(call.target))); + obj = string.concat(obj, ",", _str("value", vm.toString(call.value))); + obj = string.concat(obj, ",", _str("data", vm.toString(call.data))); + obj = string.concat(obj, ",", _raw("contractMethod", "null")); + obj = string.concat(obj, ",", _raw("contractInputsValues", "null"), "}"); + return obj; + } + + /// @notice Write the Safe Transaction Builder JSON for `calls` to `path`. + /// @param path output file (must be under a `fs_permissions` read-write path) + /// @param chainId target chain id (stringified in the JSON, per the Safe schema) + /// @param safe the Safe that will execute the batch (`createdFromSafeAddress`) + /// @param name batch name (`meta.name`) + /// @param description batch description (`meta.description`) + /// @param calls the individual CALLs the Safe should perform, unchanged from the action layer + function write( + string memory path, + uint256 chainId, + address safe, + string memory name, + string memory description, + CctActions.Call[] memory calls + ) internal { + string memory txs = "["; + for (uint256 i = 0; i < calls.length; i++) { + txs = string.concat(txs, i == 0 ? "" : ",", _transaction(calls[i])); + } + txs = string.concat(txs, "]"); + + string memory meta = string.concat("{", _str("name", name)); + meta = string.concat(meta, ",", _str("description", description)); + meta = string.concat(meta, ",", _str("txBuilderVersion", "1.17.1")); + meta = string.concat(meta, ",", _str("createdFromSafeAddress", vm.toString(safe))); + meta = string.concat(meta, ",", _str("createdFromOwnerAddress", ""), "}"); + + string memory json = string.concat("{", _str("version", "1.0")); + json = string.concat(json, ",", _str("chainId", vm.toString(chainId))); + json = string.concat(json, ",", _raw("createdAt", "0")); + json = string.concat(json, ",", _raw("meta", meta)); + json = string.concat(json, ",", _raw("transactions", txs), "}"); + + vm.writeFile(path, json); + } +} diff --git a/src/base/SafeBatchLoader.sol b/src/base/SafeBatchLoader.sol new file mode 100644 index 0000000..d85720e --- /dev/null +++ b/src/base/SafeBatchLoader.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Vm} from "forge-std/Vm.sol"; +import {CctActions} from "../actions/CctActions.sol"; + +/// @title SafeBatchLoader +/// @notice Loads a Safe Transaction Builder JSON batch (the artifact `SafeBatchEmitter` writes and +/// the Safe{Wallet} UI imports) back into the action layer's `Call[]` — the exact inverse of +/// `SafeBatchEmitter.write`. This is what lets independently emitted per-operation batches be +/// COMPOSED: load each file, `CctActions.concat` them, and hand the merged `Call[]` to the +/// Safe executor as ONE atomic transaction (`test/governance/ExecuteBatch.t.sol` pins the +/// round-trip byte for byte). +library SafeBatchLoader { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + /// @notice Parse a Transaction Builder JSON file into its chain id, Safe address, and calls. + function load(string memory path) + internal + view + returns (uint256 chainId, address safe, CctActions.Call[] memory calls) + { + string memory json = vm.readFile(path); + // The Safe schema stringifies chainId; `createdAt`/`meta` fields other than the Safe are + // display-only and deliberately ignored here. + chainId = vm.parseUint(vm.parseJsonString(json, ".chainId")); + safe = vm.parseJsonAddress(json, ".meta.createdFromSafeAddress"); + + uint256 count = _transactionCount(json); + calls = new CctActions.Call[](count); + for (uint256 i = 0; i < count; i++) { + string memory prefix = string.concat(".transactions[", vm.toString(i), "]"); + calls[i] = CctActions.Call({ + target: vm.parseJsonAddress(json, string.concat(prefix, ".to")), + value: vm.parseUint(vm.parseJsonString(json, string.concat(prefix, ".value"))), + data: vm.parseJsonBytes(json, string.concat(prefix, ".data")) + }); + } + } + + /// @notice `load` plus the composition guards: the batch must be non-empty and must have been + /// emitted for THIS chain and THIS Safe — mixing batches across chains or Safes is the + /// classic composition footgun, so a mismatch reverts naming the file. + function loadAndValidate(string memory path, uint256 expectedChainId, address expectedSafe) + internal + view + returns (CctActions.Call[] memory calls) + { + (uint256 chainId, address safe, CctActions.Call[] memory loaded) = load(path); + require( + chainId == expectedChainId, + string.concat( + "SafeBatchLoader: ", + path, + " was emitted for chainId ", + vm.toString(chainId), + ", not the current chain ", + vm.toString(expectedChainId) + ) + ); + require( + safe == expectedSafe, + string.concat( + "SafeBatchLoader: ", + path, + " was emitted for Safe ", + vm.toString(safe), + ", not SAFE_ADDRESS ", + vm.toString(expectedSafe) + ) + ); + require(loaded.length > 0, string.concat("SafeBatchLoader: ", path, " contains no transactions")); + return loaded; + } + + /// @notice Loads and validates several batch files and concatenates their calls IN THE GIVEN + /// ORDER — the composition primitive `ExecuteBatch` builds on. Order is execution order. + function loadMany(string[] memory paths, uint256 expectedChainId, address expectedSafe) + internal + view + returns (CctActions.Call[] memory merged) + { + require(paths.length > 0, "SafeBatchLoader: no batch files given"); + for (uint256 i = 0; i < paths.length; i++) { + CctActions.Call[] memory calls = loadAndValidate(paths[i], expectedChainId, expectedSafe); + merged = i == 0 ? calls : CctActions.concat(merged, calls); + } + } + + /// @dev Array length by index probing, the same pattern the JSON-mode scripts use (forge-std has + /// no direct array-length helper). + function _transactionCount(string memory json) private view returns (uint256 count) { + while (vm.keyExistsJson(json, string.concat(".transactions[", vm.toString(count), "]"))) { + count++; + } + } +} diff --git a/src/base/SafeMode.sol b/src/base/SafeMode.sol new file mode 100644 index 0000000..47d2b2b --- /dev/null +++ b/src/base/SafeMode.sol @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {Vm} from "forge-std/Vm.sol"; +import {console} from "forge-std/console.sol"; +import {CctActions} from "../actions/CctActions.sol"; +import {ISafe, IMultiSendCallOnly, SafeCanonical} from "./ISafe.sol"; +import {SafeBatchEmitter} from "./SafeBatchEmitter.sol"; +import {SafeTxHash} from "./SafeTxHash.sol"; + +/// @title SafeMode +/// @notice The Safe execution mode of the action layer (`MODE=safe`). Takes the IDENTICAL `Call[]` the +/// EOA mode would broadcast and, instead of broadcasting it, (1) logs every call for review, +/// (2) emits a Safe Transaction Builder JSON batch under `batches/`, and (3) optionally +/// (`SAFE_EXEC=direct`) executes the batch on the Safe via `execTransaction` with raw ECDSA +/// owner signatures — the universal path that needs no hosted Safe Transaction Service. +/// +/// Environment variables (read only when `MODE=safe`): +/// - `SAFE_ADDRESS` (required) the Safe that owns/administers the targets. +/// - `BATCH_NAME` (optional) batch file basename; default `cct-batch`. +/// - `SAFE_EXEC` (optional) unset/empty = emit the batch only (sign via the Safe{Wallet} +/// UI or `safe-cli`); `direct` = execute `execTransaction` now. +/// - `SAFE_SIGNER_KEYS` (required for `SAFE_EXEC=direct`) comma-separated owner private keys; +/// at least `threshold` of them. Never logged. +/// @dev Review-before-submit is the security gate: the batch JSON (and the logged decode) must be +/// verified against the intended operation BEFORE any signature is produced, and the local +/// EIP-712 `safeTxHash` recompute must equal the Safe's on-chain `getTransactionHash` — both are +/// enforced here, in that order. +library SafeMode { + Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + /// @notice Emit the Transaction Builder batch for `calls` (and execute it when `SAFE_EXEC=direct`). + /// @return path the batch JSON file written under `batches/`. + function run(CctActions.Call[] memory calls) internal returns (string memory path) { + address safe = vm.envAddress("SAFE_ADDRESS"); + string memory batchName = vm.envOr("BATCH_NAME", string("cct-batch")); + + path = emitBatch(batchName, safe, calls); + + string memory execMode = vm.envOr("SAFE_EXEC", string("")); + if (bytes(execMode).length == 0) { + console.log(" Next: import into the Safe{Wallet} Transaction Builder, or propose via safe-cli."); + } else if (_eq(execMode, "direct")) { + execDirect(ISafe(safe), calls); + } else { + revert(string.concat("SafeMode: unknown SAFE_EXEC '", execMode, "' (use 'direct' or leave unset).")); + } + } + + /// @notice Logs every call for review and writes the Safe Transaction Builder JSON batch. + /// @return path the batch JSON file written under `batches/`. + function emitBatch(string memory batchName, address safe, CctActions.Call[] memory calls) + internal + returns (string memory path) + { + console.log(""); + console.log(string.concat(" Safe mode: batching ", vm.toString(calls.length), " call(s) for Safe")); + console.log(string.concat(" Safe: ", vm.toString(safe))); + for (uint256 i = 0; i < calls.length; i++) { + console.log( + string.concat( + " Call ", + vm.toString(i + 1), + "/", + vm.toString(calls.length), + ": target ", + vm.toString(calls[i].target), + " selector ", + vm.toString(abi.encodePacked(bytes4(calls[i].data))) + ) + ); + console.log(string.concat(" data: ", vm.toString(calls[i].data))); + } + + path = string.concat("batches/", batchName, ".", vm.toString(block.chainid), ".json"); + vm.createDir("batches", true); + SafeBatchEmitter.write(path, block.chainid, safe, batchName, "CCT action-layer batch (docs-cct-foundry)", calls); + console.log(string.concat(" Safe batch written: ", path)); + console.log(" REVIEW the batch (decode to/value/data) BEFORE signing or importing it."); + } + + /// @notice Collapse a `Call[]` into the single (to, value, data, operation) a Safe transaction + /// carries: one call passes through as a CALL; multiple calls become ONE atomic + /// DELEGATECALL into the canonical `MultiSendCallOnly`, which replays each inner CALL from + /// the Safe. Inner `to`/`value`/`data` reach the chain byte-identical to the EOA mode. + function encodeForSafe(CctActions.Call[] memory calls) + internal + pure + returns (address to, uint256 value, bytes memory data, uint8 operation) + { + require(calls.length > 0, "SafeMode: empty call batch"); + if (calls.length == 1) { + return (calls[0].target, calls[0].value, calls[0].data, 0); + } + bytes memory packed; + for (uint256 i = 0; i < calls.length; i++) { + // MultiSend packed encoding: operation (1 byte, 0=CALL) || to (20) || value (32) || + // dataLength (32) || data. + packed = abi.encodePacked( + packed, uint8(0), calls[i].target, calls[i].value, calls[i].data.length, calls[i].data + ); + } + return (SafeCanonical.MULTI_SEND_CALL_ONLY, 0, abi.encodeCall(IMultiSendCallOnly.multiSend, (packed)), 1); + } + + /// @notice The direct `execTransaction` path (Mode B): compute the `safeTxHash`, cross-check the + /// local EIP-712 recompute against the Safe's on-chain `getTransactionHash`, sign with the + /// `SAFE_SIGNER_KEYS` owner keys, pack the signatures sorted ascending by signer address, + /// and submit from the script broadcaster. Works on every EVM chain — no Transaction + /// Service needed. + function execDirect(ISafe safe, CctActions.Call[] memory calls) internal { + (address to, uint256 value, bytes memory data, uint8 operation) = encodeForSafe(calls); + uint256 nonce = safe.nonce(); + + bytes32 localHash = SafeTxHash.compute( + block.chainid, + address(safe), + SafeTxHash.SafeTx({ + to: to, + value: value, + data: data, + operation: operation, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: address(0), + nonce: nonce + }) + ); + bytes32 onchainHash = + safe.getTransactionHash(to, value, data, operation, 0, 0, 0, address(0), address(0), nonce); + require(localHash == onchainHash, "SafeMode: local safeTxHash recompute != Safe.getTransactionHash"); + + console.log(string.concat(" safeTxHash: ", vm.toString(localHash), " (local recompute == on-chain)")); + console.log(string.concat(" Safe nonce: ", vm.toString(nonce))); + + bytes memory signatures = _signSorted(safe, localHash); + + vm.startBroadcast(); + bool success = + safe.execTransaction(to, value, data, operation, 0, 0, 0, address(0), payable(address(0)), signatures); + vm.stopBroadcast(); + // With safeTxGas == 0 and gasPrice == 0 the Safe reverts (GS013) when the inner call fails, so + // reaching a `true` return means the Safe emitted ExecutionSuccess. + require(success, "SafeMode: execTransaction returned false"); + console.log(unicode" ✅ Safe execTransaction succeeded (ExecutionSuccess)."); + } + + /// @dev Signs `hash` with every key in `SAFE_SIGNER_KEYS`, validates each signer is a Safe owner and + /// the count meets the threshold, and concatenates the (r,s,v) signatures sorted ascending by + /// signer address — the order `checkSignatures` requires. Keys are never logged. + function _signSorted(ISafe safe, bytes32 hash) private view returns (bytes memory signatures) { + uint256[] memory keys = vm.envUint("SAFE_SIGNER_KEYS", ","); + require( + keys.length >= safe.getThreshold(), "SafeMode: SAFE_SIGNER_KEYS supplies fewer keys than the Safe threshold" + ); + + // Insertion-sort the keys by their signer address (ascending). + for (uint256 i = 1; i < keys.length; i++) { + uint256 key = keys[i]; + uint256 j = i; + while (j > 0 && vm.addr(keys[j - 1]) > vm.addr(key)) { + keys[j] = keys[j - 1]; + j--; + } + keys[j] = key; + } + + for (uint256 i = 0; i < keys.length; i++) { + address signer = vm.addr(keys[i]); + require( + safe.isOwner(signer), + string.concat("SafeMode: signer ", vm.toString(signer), " is not an owner of the Safe") + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(keys[i], hash); + signatures = abi.encodePacked(signatures, r, s, v); + } + } + + function _eq(string memory a, string memory b) private pure returns (bool) { + return keccak256(bytes(a)) == keccak256(bytes(b)); + } +} diff --git a/src/base/SafeTxHash.sol b/src/base/SafeTxHash.sol new file mode 100644 index 0000000..d4871d8 --- /dev/null +++ b/src/base/SafeTxHash.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +/// @title SafeTxHash +/// @notice Independent EIP-712 recompute of a Safe transaction hash (`safeTxHash`), matching Safe +/// v1.3.0+ (`domainSeparator()` is chainId-based). The Safe execution mode cross-checks this +/// local recompute against the Safe's own `getTransactionHash` before any signature is +/// produced — two independent derivations must agree, the same control a production signer +/// applies with the `safe-hash` tool. +library SafeTxHash { + /// @notice `keccak256("EIP712Domain(uint256 chainId,address verifyingContract)")` — the Safe >= 1.3.0 + /// domain, which binds signatures to one chain and one Safe. + bytes32 internal constant DOMAIN_SEPARATOR_TYPEHASH = + keccak256("EIP712Domain(uint256 chainId,address verifyingContract)"); + + /// @notice The SafeTx EIP-712 typehash. + /// @dev GOTCHA: the type string must end in `uint256 nonce` — the EIP-712 STRUCT FIELD name — not + /// `_nonce`, the Solidity PARAMETER name Safe's `getTransactionHash` happens to use. Encoding + /// `_nonce` yields a wrong typehash and a hash that never matches signer devices. + /// `test/governance/SafeTxHash.t.sol` pins this as a regression test. + bytes32 internal constant SAFE_TX_TYPEHASH = keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)" + ); + + /// @notice One Safe transaction, as the EIP-712 SafeTx struct orders its fields. + struct SafeTx { + address to; + uint256 value; + bytes data; + uint8 operation; // 0 = CALL, 1 = DELEGATECALL + uint256 safeTxGas; + uint256 baseGas; + uint256 gasPrice; + address gasToken; + address refundReceiver; + uint256 nonce; + } + + /// @notice The Safe's EIP-712 domain separator, recomputed locally. + function domainSeparator(uint256 chainId, address safe) internal pure returns (bytes32) { + return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, safe)); + } + + /// @notice The EIP-712 struct hash of one SafeTx (dynamic `data` hashed per EIP-712). + function structHash(SafeTx memory t) internal pure returns (bytes32) { + return keccak256( + abi.encode( + SAFE_TX_TYPEHASH, + t.to, + t.value, + keccak256(t.data), + t.operation, + t.safeTxGas, + t.baseGas, + t.gasPrice, + t.gasToken, + t.refundReceiver, + t.nonce + ) + ); + } + + /// @notice The full `safeTxHash`: `keccak256(0x1901 || domainSeparator || structHash)`. Must equal + /// the Safe's on-chain `getTransactionHash` for the same inputs. + function compute(uint256 chainId, address safe, SafeTx memory t) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(chainId, safe), structHash(t))); + } +} diff --git a/test/actions/PoolVersionDispatch.t.sol b/test/actions/PoolVersionDispatch.t.sol index e75e2e0..da69aed 100644 --- a/test/actions/PoolVersionDispatch.t.sol +++ b/test/actions/PoolVersionDispatch.t.sol @@ -10,6 +10,7 @@ import {PoolVersion} from "../../script/utils/PoolVersion.s.sol"; import {ApplyChainUpdates} from "../../script/setup/ApplyChainUpdates.s.sol"; import {AddRemotePool} from "../../script/configure/remote-pools/AddRemotePool.s.sol"; import {RemoveRemotePool} from "../../script/configure/remote-pools/RemoveRemotePool.s.sol"; +import {RemoveChain} from "../../script/configure/remote-chains/RemoveChain.s.sol"; import {BaseForkTest} from "../BaseForkTest.t.sol"; import {HelperConfig} from "../../script/HelperConfig.s.sol"; @@ -95,6 +96,31 @@ contract MockModernPool { } } +/// @dev A pool that reports every chain as UNSUPPORTED. RemoveChain's `isSupportedChain` pre-check +/// must surface the friendly "nothing to remove" refusal against this before it reaches the +/// version dispatch, so this mock carries no typeAndVersion (the pre-check fires first). +contract MockUnsupportedChainPool { + function isSupportedChain(uint64) external pure returns (bool) { + return false; + } +} + +/// @dev The faithful 1.5.0 surface of `Mock150Pool` PLUS the singular-only 1.5.0 write path +/// (`applyChainUpdates(ChainUpdate[])`, selector 0xdb6327dc), which records the calldata it +/// receives. There is deliberately still NO plural `getRemotePools`, so running RemoveChain's +/// whole `_removeChain` body against this mock proves the fence-before-read fix: the script must +/// resolve the version and read through the version-safe helper (singular on 1.5.0) rather than +/// raw-reverting on the absent plural getter, and then emit the 1.5.0 removal encoding here. +contract Mock150PoolWithApply is Mock150Pool { + bytes public lastApplyChainUpdatesCalldata; + + constructor(bytes memory remotePool) Mock150Pool(remotePool) {} + + function applyChainUpdates(ITokenPoolV150.ChainUpdate[] calldata) external { + lastApplyChainUpdatesCalldata = msg.data; + } +} + // ───────────────────────────────────────────────────────────────────────────── // Shims and harnesses // ───────────────────────────────────────────────────────────────────────────── @@ -172,6 +198,30 @@ contract RemoveRemotePoolHarness is RemoveRemotePool { } } +/// @dev Exposes RemoveChain's version-dispatch switch (for byte-equal calldata proofs) and its +/// post-resolution body (for the isSupportedChain pre-check proof), each with an injected pool +/// address — env-based pool resolution is process-global and cannot be exercised race-free +/// while suites run in parallel, mirroring the Add/RemoveRemotePool harnesses. +contract RemoveChainHarness is RemoveChain { + function buildChainRemovalCalls(PoolVersions.Version version, address poolAddress, uint64 remoteChainSelector) + external + pure + returns (CctActions.Call[] memory) + { + return _buildChainRemovalCalls(version, poolAddress, remoteChainSelector); + } + + function invoke( + address tokenPoolAddress, + uint64 remoteChainSelector, + string memory destChainName, + uint256 destChainId + ) external { + helperConfig = new HelperConfig(); + _removeChain(tokenPoolAddress, remoteChainSelector, destChainName, destChainId); + } +} + /// @dev Exposes the script's internal conversion and the exhaustive lane-update dispatch switch. contract ApplyChainUpdatesHarness is ApplyChainUpdates { function convert(TokenPool.ChainUpdate[] memory updates, bool[] memory replaceExisting) @@ -213,10 +263,12 @@ contract PoolVersionDispatchTest is Test { address internal constant POOL = address(0x3333333333333333333333333333333333333333); ApplyChainUpdatesHarness internal harness; + RemoveChainHarness internal removeChainHarness; ResolverShim internal shim; function setUp() public { harness = new ApplyChainUpdatesHarness(); + removeChainHarness = new RemoveChainHarness(); shim = new ResolverShim(); } @@ -580,6 +632,74 @@ contract PoolVersionDispatchTest is Test { } } + // ───────────────────────────────────────────────────────────────────────── + // RemoveChain dispatch: exhaustive whole-lane teardown switch over the catalog + // ───────────────────────────────────────────────────────────────────────── + + /// @dev 1.5.0 tears a lane down through its single-argument applyChainUpdates with ONE + /// `allowed:false` entry, empty remote pool/token bytes, and BOTH rate-limit configs + /// disabled+zeroed (1.5.0's `_validateTokenBucketConfig(cfg, mustBeDisabled=true)` reverts + /// otherwise). The expectation is re-encoded here independently of the script. + function test_RemoveChainDispatch_V150_LegacyAllowedFalseEntry_ByteEqual() public view { + CctActions.Call[] memory calls = + removeChainHarness.buildChainRemovalCalls(PoolVersions.Version.V1_5_0, POOL, SELECTOR); + + assertEq(calls.length, 1, "one call"); + assertEq(calls[0].target, POOL, "target"); + assertEq(calls[0].value, 0, "value"); + assertEq(bytes4(calls[0].data), bytes4(0xdb6327dc), "1.5.0 applyChainUpdates selector"); + + ITokenPoolV150.ChainUpdate[] memory expected = new ITokenPoolV150.ChainUpdate[](1); + expected[0] = ITokenPoolV150.ChainUpdate({ + remoteChainSelector: SELECTOR, + allowed: false, + remotePoolAddress: "", + remoteTokenAddress: "", + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + assertEq( + calls[0].data, + abi.encodeCall(ITokenPoolV150.applyChainUpdates, (expected)), + "1.5.0 removal calldata mismatch vs hand-encoded expectation" + ); + } + + /// @dev 1.5.1 / 1.6.1 / 2.0.0 tear a lane down through the modern two-argument applyChainUpdates + /// with the selector in `toRemove` and an EMPTY `toAdd`. All three share one byte-identical + /// shape; the modern selector 0xe8a1da17 is asserted alongside. + function test_RemoveChainDispatch_ModernVersionsShareOneShape_ByteEqual() public view { + uint64[] memory removes = new uint64[](1); + removes[0] = SELECTOR; + TokenPool.ChainUpdate[] memory noAdds = new TokenPool.ChainUpdate[](0); + bytes memory expected = abi.encodeCall(TokenPool.applyChainUpdates, (removes, noAdds)); + + PoolVersions.Version[3] memory modern = + [PoolVersions.Version.V1_5_1, PoolVersions.Version.V1_6_1, PoolVersions.Version.V2_0_0]; + for (uint256 i = 0; i < modern.length; i++) { + CctActions.Call[] memory calls = removeChainHarness.buildChainRemovalCalls(modern[i], POOL, SELECTOR); + assertEq(calls.length, 1, "one call"); + assertEq(calls[0].target, POOL, "target"); + assertEq(bytes4(calls[0].data), bytes4(0xe8a1da17), "modern applyChainUpdates selector"); + assertEq( + calls[0].data, + expected, + string.concat("modern removal calldata byte-equal for ", PoolVersions.toString(modern[i])) + ); + } + } + + /// @dev A version outside the catalog (UNKNOWN sentinel) must fail loudly at the dispatch switch + /// rather than fall through to the modern encoding, mirroring the lane-update switch. + function test_RemoveChainDispatch_UnknownHitsNoBranch() public { + try removeChainHarness.buildChainRemovalCalls(PoolVersions.Version.UNKNOWN, POOL, SELECTOR) { + fail(); + } catch Error(string memory reason) { + _assertContains(reason, "no chain-removal dispatch branch"); + _assertContains(reason, "src/PoolVersions.sol"); + } + } + // ───────────────────────────────────────────────────────────────────────── // setRateLimits: version-dispatched builder (calldata byte equality) // ───────────────────────────────────────────────────────────────────────── @@ -848,18 +968,21 @@ contract AddRemotePoolFenceForkTest is BaseForkTest { } } -/// @notice Fence-order proof for RemoveRemotePool, mirroring the AddRemotePool proof: a 1.5.0 -/// pool must get the NAMED unsupported-operation refusal before the script's -/// isRemotePool/getRemotePools reads (both absent on 1.5.0) run. +/// @notice Redirect proof for RemoveRemotePool on a 1.5.0 pool. RemoveRemotePool now fences 1.5.0 +/// BEFORE the generic `requireSupports` unsupported-operation refusal: 1.5.0 holds exactly +/// one remote pool per chain, so there is no standalone pool removal, and the script points +/// the operator at the whole-lane teardown (RemoveChain.s.sol) instead. This asserts the NEW +/// redirect message (superseding the old "UnsupportedPoolOperation: removeRemotePool" text) +/// and that it names RemoveChain.s.sol as the alternative. Still fires before any +/// version-shaped read (isRemotePool/getRemotePools, both absent on 1.5.0), so a decode as +/// Error(string) proves it is the curated redirect, not a raw EvmError from a missing selector. contract RemoveRemotePoolFenceForkTest is BaseForkTest { uint64 internal constant MANTLE_SEPOLIA_SELECTOR = 8236463271206331221; - function test_RemoveRemotePool_150Pool_NamedRefusalBeforeRead() public { + function test_RemoveRemotePool_150Pool_RedirectsToRemoveChainBeforeRead() public { Mock150Pool pool150 = new Mock150Pool(abi.encode(address(0x1111))); RemoveRemotePoolHarness script = new RemoveRemotePoolHarness(); - // Error(string) proves the refusal is the curated message, not a raw EvmError from the - // absent isRemotePool/getRemotePools selectors (which would not decode as Error(string)). try script.invoke( address(pool150), address(0x4444444444444444444444444444444444444444), @@ -869,13 +992,204 @@ contract RemoveRemotePoolFenceForkTest is BaseForkTest { ) { revert("RemoveRemotePool unexpectedly succeeded on a 1.5.0 pool"); } catch Error(string memory reason) { - assertTrue(_reasonNamesTheFence(reason), reason); + assertTrue(_contains(reason, "removeRemotePool is not available on a 1.5.0 pool"), reason); + assertTrue(_contains(reason, "script/configure/remote-chains/RemoveChain.s.sol"), reason); } } - function _reasonNamesTheFence(string memory reason) internal pure returns (bool) { - bytes memory b = bytes(reason); - bytes memory n = bytes("UnsupportedPoolOperation: removeRemotePool"); + function _contains(string memory s, string memory needle) internal pure returns (bool) { + bytes memory b = bytes(s); + bytes memory n = bytes(needle); + if (n.length > b.length) return false; + for (uint256 i = 0; i + n.length <= b.length; i++) { + bool matched = true; + for (uint256 j = 0; j < n.length; j++) { + if (b[i + j] != n[j]) { + matched = false; + break; + } + } + if (matched) return true; + } + return false; + } +} + +/// @notice Behavioral + pre-check proofs for RemoveChain against a REAL v2.0.0 fixture pool. +/// The fixture BurnMintTokenPool resolves to V2_0_0, so the modern removal branch is +/// exercised on-chain: add a lane, tear it down with RemoveChain, and assert the chain is +/// fully unsupported afterward. The 1.5.0 branch is covered transitively by the byte-equal +/// dispatch proofs above (same calldata a 1.5.0 pool would receive). Plus the friendly +/// isSupportedChain pre-check that refuses to touch an unconfigured lane. +contract RemoveChainForkTest is BaseForkTest { + uint64 internal constant MANTLE_SEPOLIA_SELECTOR = 8236463271206331221; + address internal constant REMOTE_POOL = address(0x1111111111111111111111111111111111111111); + address internal constant REMOTE_POOL_2 = address(0x5555555555555555555555555555555555555555); + address internal constant REMOTE_TOKEN = address(0x2222222222222222222222222222222222222222); + uint128 internal constant OUTBOUND_CAPACITY = 1_000e18; + uint128 internal constant OUTBOUND_RATE = 0.1e18; + + /// @notice Realistic teardown: the lane is added with ENABLED, non-zero rate limits (the live case, + /// not 0/0), the bucket is read before, and after RemoveChain the whole chain is unsupported + /// AND the rate-limit bucket is gone/zeroed — proving removal is destructive of rate config, + /// as the brain note now claims. Exercises the modern (2.0.0) removal branch on-chain. + function test_RemoveChain_ModernPool_UnsupportsLaneAndClearsRateConfig() public { + (, address poolAddress) = deployTokenAndPoolFixture(); + TokenPool pool = TokenPool(poolAddress); + address owner = pool.owner(); + + bytes[] memory onePool = new bytes[](1); + onePool[0] = abi.encode(REMOTE_POOL); + _exec(owner, _addMantleLane(poolAddress, onePool, true)); + assertTrue(pool.isSupportedChain(MANTLE_SEPOLIA_SELECTOR), "precondition: lane not configured"); + + // Also enable a FAST-FINALITY bucket for the lane (a separate storage mapping on 2.0.0). The + // standard bucket came from applyChainUpdates above; the fast-finality bucket is set through the + // v2 setRateLimitConfig setter with fastFinality:true (owner-gated). + RateLimiter.Config memory ffOut = + RateLimiter.Config({isEnabled: true, capacity: OUTBOUND_CAPACITY, rate: OUTBOUND_RATE}); + RateLimiter.Config memory ffIn = RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}); + _exec( + owner, + CctActions.setRateLimits( + poolAddress, PoolVersions.Version.V2_0_0, MANTLE_SEPOLIA_SELECTOR, true, ffOut, ffIn + ) + ); + + // The lane carries a live, ENABLED outbound rate limit before teardown — in BOTH the standard + // bucket (getCurrentRateLimiterState(sel, false)) and the fast-finality bucket (…, true). + (RateLimiter.TokenBucket memory outBefore,) = pool.getCurrentRateLimiterState(MANTLE_SEPOLIA_SELECTOR, false); + assertTrue(outBefore.isEnabled, "precondition: standard outbound rate limit not enabled"); + assertEq(outBefore.capacity, OUTBOUND_CAPACITY, "precondition: standard outbound capacity"); + (RateLimiter.TokenBucket memory ffOutBefore,) = pool.getCurrentRateLimiterState(MANTLE_SEPOLIA_SELECTOR, true); + assertTrue(ffOutBefore.isEnabled, "precondition: fast-finality outbound rate limit not enabled"); + assertEq(ffOutBefore.capacity, OUTBOUND_CAPACITY, "precondition: fast-finality outbound capacity"); + + // Tear the whole lane down. The fixture owner is the default sender the harness broadcasts as. + new RemoveChainHarness().invoke(poolAddress, MANTLE_SEPOLIA_SELECTOR, "MANTLE_SEPOLIA", 5003); + + assertFalse(pool.isSupportedChain(MANTLE_SEPOLIA_SELECTOR), "lane still supported after RemoveChain"); + uint64[] memory supported = pool.getSupportedChains(); + for (uint256 i = 0; i < supported.length; i++) { + assertTrue(supported[i] != MANTLE_SEPOLIA_SELECTOR, "selector still in getSupportedChains after removal"); + } + // Removal deletes s_remoteChainConfigs[sel] (standard bucket) AND both fast-finality bucket + // mappings (TokenPool.sol:691-693), so BOTH read back zeroed/disabled; a re-add would NOT + // restore them (the rate config is destroyed, not stashed). + (RateLimiter.TokenBucket memory outAfter,) = pool.getCurrentRateLimiterState(MANTLE_SEPOLIA_SELECTOR, false); + assertFalse(outAfter.isEnabled, "standard rate limit still enabled after RemoveChain"); + assertEq(outAfter.capacity, 0, "standard rate-limit capacity not cleared after RemoveChain"); + (RateLimiter.TokenBucket memory ffOutAfter,) = pool.getCurrentRateLimiterState(MANTLE_SEPOLIA_SELECTOR, true); + assertFalse(ffOutAfter.isEnabled, "fast-finality rate limit still enabled after RemoveChain"); + assertEq(ffOutAfter.capacity, 0, "fast-finality rate-limit capacity not cleared after RemoveChain"); + } + + /// @notice REGRESSION for the fence-before-read fix. Runs RemoveChain's WHOLE `_removeChain` body + /// against a faithful 1.5.0 pool (singular getRemotePool only, NO plural getRemotePools). + /// The body must resolve the version first and read via the version-safe helper, then emit + /// the 1.5.0 `allowed:false` removal encoding — which the mock records. On the pre-fix code + /// (raw plural getRemotePools before dispatch) this reverts on the absent plural getter and + /// never completes; confirmed by temporarily reverting the fix in RemoveChain.s.sol (the + /// invoke reverts) and then restoring it (this test passes). + function test_RemoveChain_150Pool_FullBodyUsesVersionSafeReadThenLegacyEncoding() public { + Mock150PoolWithApply pool150 = new Mock150PoolWithApply(abi.encode(REMOTE_POOL)); + + new RemoveChainHarness().invoke(address(pool150), MANTLE_SEPOLIA_SELECTOR, "MANTLE_SEPOLIA", 5003); + + ITokenPoolV150.ChainUpdate[] memory expected = new ITokenPoolV150.ChainUpdate[](1); + expected[0] = ITokenPoolV150.ChainUpdate({ + remoteChainSelector: MANTLE_SEPOLIA_SELECTOR, + allowed: false, + remotePoolAddress: "", + remoteTokenAddress: "", + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + assertEq( + pool150.lastApplyChainUpdatesCalldata(), + abi.encodeCall(ITokenPoolV150.applyChainUpdates, (expected)), + "1.5.0 body did not emit the allowed:false removal encoding through the version-safe read" + ); + } + + /// @notice Last-pool footgun + recovery on the real 2.0.0 fixture. Removing remote pools one by one + /// with RemoveRemotePool NEVER drops the chain: after the LAST pool is gone the chain is + /// still `isSupportedChain==true` but holds ZERO pools, at which point any inbound + /// releaseOrMint from that selector reverts `InvalidSourcePoolAddress` (TokenPool.sol:483-485, + /// `isRemotePool` finds no match — driving a real OffRamp in-fork is impractical, so the + /// zero-pool state is asserted and the consequence cited). Only RemoveChain fully unsupports + /// the lane. + function test_RemoveRemotePool_LastPool_LeavesChainSupportedZeroPools_ThenRemoveChain() public { + (, address poolAddress) = deployTokenAndPoolFixture(); + TokenPool pool = TokenPool(poolAddress); + address owner = pool.owner(); + + bytes[] memory twoPools = new bytes[](2); + twoPools[0] = abi.encode(REMOTE_POOL); + twoPools[1] = abi.encode(REMOTE_POOL_2); + _exec(owner, _addMantleLane(poolAddress, twoPools, true)); + assertEq(pool.getRemotePools(MANTLE_SEPOLIA_SELECTOR).length, 2, "precondition: two remote pools"); + + RemoveRemotePoolHarness rrp = new RemoveRemotePoolHarness(); + + // Remove the first pool: chain stays supported, one pool remains. + rrp.invoke(poolAddress, REMOTE_POOL, MANTLE_SEPOLIA_SELECTOR, "MANTLE_SEPOLIA", 5003); + assertTrue(pool.isSupportedChain(MANTLE_SEPOLIA_SELECTOR), "chain dropped after first removeRemotePool"); + assertEq(pool.getRemotePools(MANTLE_SEPOLIA_SELECTOR).length, 1, "expected one pool left"); + + // Remove the LAST pool: chain STILL supported, but zero pools -> inbound releaseOrMint would + // revert InvalidSourcePoolAddress. removeRemotePool alone can never tear the lane down. + rrp.invoke(poolAddress, REMOTE_POOL_2, MANTLE_SEPOLIA_SELECTOR, "MANTLE_SEPOLIA", 5003); + assertTrue(pool.isSupportedChain(MANTLE_SEPOLIA_SELECTOR), "chain must stay supported-with-zero-pools"); + assertEq(pool.getRemotePools(MANTLE_SEPOLIA_SELECTOR).length, 0, "expected zero pools (the footgun state)"); + + // Recovery: RemoveChain finishes the teardown the pool-by-pool removals could not. + new RemoveChainHarness().invoke(poolAddress, MANTLE_SEPOLIA_SELECTOR, "MANTLE_SEPOLIA", 5003); + assertFalse(pool.isSupportedChain(MANTLE_SEPOLIA_SELECTOR), "RemoveChain did not fully unsupport the lane"); + uint64[] memory supported = pool.getSupportedChains(); + for (uint256 i = 0; i < supported.length; i++) { + assertTrue( + supported[i] != MANTLE_SEPOLIA_SELECTOR, "selector still in getSupportedChains after RemoveChain" + ); + } + } + + function test_RemoveChain_UnsupportedChain_FriendlyPrecheck() public { + MockUnsupportedChainPool pool = new MockUnsupportedChainPool(); + RemoveChainHarness script = new RemoveChainHarness(); + + try script.invoke(address(pool), MANTLE_SEPOLIA_SELECTOR, "MANTLE_SEPOLIA", 5003) { + revert("RemoveChain unexpectedly succeeded on an unsupported chain"); + } catch Error(string memory reason) { + assertTrue(_contains(reason, "Remote chain not supported on this pool (nothing to remove)"), reason); + assertTrue(_contains(reason, "MANTLE_SEPOLIA"), reason); + } + } + + function _addMantleLane(address poolAddress, bytes[] memory remotePools, bool rateLimitEnabled) + internal + pure + returns (CctActions.Call[] memory) + { + uint64[] memory removes = new uint64[](0); + TokenPool.ChainUpdate[] memory adds = new TokenPool.ChainUpdate[](1); + adds[0] = TokenPool.ChainUpdate({ + remoteChainSelector: MANTLE_SEPOLIA_SELECTOR, + remotePoolAddresses: remotePools, + remoteTokenAddress: abi.encode(REMOTE_TOKEN), + outboundRateLimiterConfig: RateLimiter.Config({ + isEnabled: rateLimitEnabled, + capacity: rateLimitEnabled ? OUTBOUND_CAPACITY : 0, + rate: rateLimitEnabled ? OUTBOUND_RATE : 0 + }), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + return CctActions.applyChainUpdates(poolAddress, removes, adds); + } + + function _contains(string memory s, string memory needle) internal pure returns (bool) { + bytes memory b = bytes(s); + bytes memory n = bytes(needle); if (n.length > b.length) return false; for (uint256 i = 0; i + n.length <= b.length; i++) { bool matched = true; diff --git a/test/governance/ExecuteBatch.t.sol b/test/governance/ExecuteBatch.t.sol new file mode 100644 index 0000000..ec17607 --- /dev/null +++ b/test/governance/ExecuteBatch.t.sol @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {TokenAdminRegistry} from "@chainlink/contracts-ccip/contracts/tokenAdminRegistry/TokenAdminRegistry.sol"; +import {CrossChainToken} from "@chainlink/contracts-ccip/contracts/tokens/CrossChainToken.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; +import {BaseForkTest} from "../BaseForkTest.t.sol"; +import {DeploySafe} from "../../script/governance/DeploySafe.s.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {PoolVersions} from "../../src/PoolVersions.sol"; +import {ISafe} from "../../src/base/ISafe.sol"; +import {SafeBatchEmitter} from "../../src/base/SafeBatchEmitter.sol"; +import {SafeBatchLoader} from "../../src/base/SafeBatchLoader.sol"; +import {SafeMode} from "../../src/base/SafeMode.sol"; + +/// @notice PR 3.1.1 proofs: composing independently emitted Safe batches into ONE meta-transaction. +/// - The loader is the emitter's exact inverse (round-trip byte equality over the catalog). +/// - `loadMany` merges in the given order, and the merged calls equal the concatenation of +/// the per-operation builders — i.e. the calldata the EOA mode would broadcast. +/// - A full CCT setup (accept pool ownership + claim/accept registration pair + setPool + +/// applyChainUpdates) executes as ONE `execTransaction` consuming ONE Safe nonce, landing +/// the same pool/registry state as the sequential EOA path. +/// - Atomicity: a mis-ordered batch reverts as a whole with zero partial state. +/// - Composition guards: chain-id mismatch, foreign Safe, and empty batches are rejected. +/// - Gas: one merged meta-tx costs less than the same calls as separate Safe transactions +/// (in-EVM comparison; on-chain the saving is larger by N-1 avoided 21k intrinsic costs). +/// +/// Env note: this suite reuses the exact env values `SafeModeForkTest` sets (same Safe, same +/// signer keys) and never writes `BATCH_NAME`/`SAFE_EXEC`, so it cannot race other suites' +/// env reads; the env-shell of `ExecuteBatch.s.sol` itself is exercised by the live run. +contract ExecuteBatchForkTest is BaseForkTest { + uint256 internal constant OWNER1_KEY = 0xA11CE; + uint256 internal constant OWNER2_KEY = 0xB0B; + uint256 internal constant OWNER3_KEY = 0xC0FFEE; + + uint64 internal constant EVM_SELECTOR = 8236463271206331221; // Mantle Sepolia + address internal constant EVM_REMOTE_POOL = address(0x1111111111111111111111111111111111111111); + address internal constant EVM_REMOTE_TOKEN = address(0x2222222222222222222222222222222222222222); + + address internal token; + address internal pool; + address internal deployer; + ISafe internal safe; + TokenAdminRegistry internal registry; + address internal registryModule; + + function setUp() public override { + super.setUp(); + (token, pool) = deployTokenAndPoolFixture(); + deployer = _scriptBroadcaster(); + registry = TokenAdminRegistry(networkConfig.tokenAdminRegistry); + registryModule = networkConfig.registryModuleOwnerCustom; + + // Identical values to SafeModeForkTest, so parallel suites see one consistent environment. + vm.setEnv( + "SAFE_OWNERS", + string.concat( + vm.toString(vm.addr(OWNER1_KEY)), + ",", + vm.toString(vm.addr(OWNER2_KEY)), + ",", + vm.toString(vm.addr(OWNER3_KEY)) + ) + ); + vm.setEnv("SAFE_THRESHOLD", "2"); + vm.setEnv("SAFE_SALT_NONCE", "0"); + safe = ISafe(new DeploySafe().run()); + vm.setEnv("SAFE_ADDRESS", vm.toString(address(safe))); + vm.setEnv("SAFE_SIGNER_KEYS", string.concat(vm.toString(OWNER1_KEY), ",", vm.toString(OWNER2_KEY))); + } + + // ───────────────────────────────────────────────────────────────────────── + // Loader == emitter⁻¹ + // ───────────────────────────────────────────────────────────────────────── + + /// @dev Round-trip over representative catalog shapes (1-call and 2-call batches): what the + /// emitter writes, the loader returns byte-identically. + function test_Loader_RoundTripsEmitter() public { + _assertRoundTrip( + "p311-rt-pair", CctActions.registerAndAcceptAdminViaGetCCIPAdmin(registryModule, address(registry), token) + ); + _assertRoundTrip("p311-rt-setpool", CctActions.setPool(address(registry), token, pool)); + (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) = _laneInput(); + _assertRoundTrip("p311-rt-lane", CctActions.applyChainUpdates(pool, removes, updates)); + _assertRoundTrip("p311-rt-deposit", CctActions.lockboxDeposit(EVM_REMOTE_POOL, token, 1e18)); + } + + /// @dev `loadMany` merges in the given order and equals the concatenation of the builders — + /// the "merged inner calldata == concatenation of per-op EOA calldata" proof. + function test_LoadMany_MergesInOrder() public { + CctActions.Call[] memory pair = + CctActions.registerAndAcceptAdminViaGetCCIPAdmin(registryModule, address(registry), token); + CctActions.Call[] memory setPoolCall = CctActions.setPool(address(registry), token, pool); + (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) = _laneInput(); + CctActions.Call[] memory lane = CctActions.applyChainUpdates(pool, removes, updates); + + string[] memory paths = new string[](3); + paths[0] = SafeMode.emitBatch("p311-merge-pair", address(safe), pair); + paths[1] = SafeMode.emitBatch("p311-merge-setpool", address(safe), setPoolCall); + paths[2] = SafeMode.emitBatch("p311-merge-lane", address(safe), lane); + + CctActions.Call[] memory merged = SafeBatchLoader.loadMany(paths, block.chainid, address(safe)); + CctActions.Call[] memory expected = CctActions.concat(CctActions.concat(pair, setPoolCall), lane); + + assertEq(merged.length, expected.length, "merged call count mismatch"); + for (uint256 i = 0; i < merged.length; i++) { + assertEq(merged[i].target, expected[i].target, "merged target mismatch"); + assertEq(merged[i].value, expected[i].value, "merged value mismatch"); + assertEq(merged[i].data, expected[i].data, "merged calldata mismatch"); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // One meta-transaction, one nonce, sequential-equal end state + // ───────────────────────────────────────────────────────────────────────── + + /// @dev The full CCT setup as ONE Safe transaction: accept pool ownership, claim+accept the + /// registry administrator, setPool, applyChainUpdates — one `execTransaction`, one nonce. + /// The pool/registry end state must equal the sequential EOA path's. + function test_MergedBatch_ModeB_FullSetup_OneNonce() public { + // Sequential EOA reference (deployer does everything), captured on a snapshot. + uint256 snapshot = vm.snapshotState(); + CctActions.Call[] memory eoaSetup = CctActions.concat( + CctActions.registerAndAcceptAdminViaGetCCIPAdmin(registryModule, address(registry), token), + CctActions.setPool(address(registry), token, pool) + ); + (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) = _laneInput(); + eoaSetup = CctActions.concat(eoaSetup, CctActions.applyChainUpdates(pool, removes, updates)); + _exec(deployer, eoaSetup); + address eoaPool = registry.getPool(token); + bytes memory eoaRemoteToken = TokenPool(pool).getRemoteToken(EVM_SELECTOR); + vm.revertToState(snapshot); + + // Safe path: hand the token's CCIP admin and the pool's pending ownership to the Safe first + // (the EOA-side prerequisites), then everything else happens in ONE Safe transaction. + vm.prank(deployer); + CrossChainToken(token).setCCIPAdmin(address(safe)); + vm.prank(deployer); + TokenPool(pool).transferOwnership(address(safe)); + + string[] memory paths = new string[](4); + paths[0] = SafeMode.emitBatch("p311-full-accept-own", address(safe), CctActions.acceptOwnership(pool)); + paths[1] = SafeMode.emitBatch( + "p311-full-pair", + address(safe), + CctActions.registerAndAcceptAdminViaGetCCIPAdmin(registryModule, address(registry), token) + ); + paths[2] = + SafeMode.emitBatch("p311-full-setpool", address(safe), CctActions.setPool(address(registry), token, pool)); + paths[3] = + SafeMode.emitBatch("p311-full-lane", address(safe), CctActions.applyChainUpdates(pool, removes, updates)); + + CctActions.Call[] memory merged = SafeBatchLoader.loadMany(paths, block.chainid, address(safe)); + assertEq(merged.length, 5, "full setup must merge to five calls"); + + uint256 nonceBefore = safe.nonce(); + SafeMode.execDirect(safe, merged); + assertEq(safe.nonce(), nonceBefore + 1, "the whole setup must consume exactly ONE Safe nonce"); + + assertEq(TokenPool(pool).owner(), address(safe), "Safe must own the pool"); + assertEq(registry.getTokenConfig(token).administrator, address(safe), "Safe must be the administrator"); + assertEq(registry.getTokenConfig(token).pendingAdministrator, address(0), "pending must be cleared"); + assertEq(registry.getPool(token), eoaPool, "registry pool must equal the EOA path"); + assertEq(TokenPool(pool).getRemoteToken(EVM_SELECTOR), eoaRemoteToken, "lane config must equal the EOA path"); + } + + /// @dev Atomicity + ordering: the SAME two calls in the WRONG order (accept before claim) revert + /// the whole meta-transaction (GS013) and leave zero partial state. + function test_MergedBatch_Atomicity_ReversedOrderReverts() public { + vm.prank(deployer); + CrossChainToken(token).setCCIPAdmin(address(safe)); + + string[] memory paths = new string[](2); + paths[0] = + SafeMode.emitBatch("p311-rev-accept", address(safe), CctActions.acceptAdminRole(address(registry), token)); + paths[1] = SafeMode.emitBatch( + "p311-rev-claim", address(safe), CctActions.registerAdminViaGetCCIPAdmin(registryModule, token) + ); + CctActions.Call[] memory merged = SafeBatchLoader.loadMany(paths, block.chainid, address(safe)); + + // expectRevert arms the next EXTERNAL call, so the internal library path goes through a shim. + vm.expectRevert(bytes("GS013")); + this.execDirectExternal(safe, merged); + + assertEq(registry.getTokenConfig(token).administrator, address(0), "no administrator may be set"); + assertEq(registry.getTokenConfig(token).pendingAdministrator, address(0), "no pending admin may survive"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Composition guards + // ───────────────────────────────────────────────────────────────────────── + + function test_Loader_RejectsChainIdMismatch() public { + string memory path = "batches/p311-wrong-chain.json"; + SafeBatchEmitter.write( + path, block.chainid + 1, address(safe), "p311-wrong-chain", "test", CctActions.acceptOwnership(pool) + ); + vm.expectRevert(); + this.loadAndValidateExternal(path, block.chainid, address(safe)); + } + + function test_Loader_RejectsForeignSafe() public { + string memory path = SafeMode.emitBatch("p311-foreign-safe", deployer, CctActions.acceptOwnership(pool)); + vm.expectRevert(); + this.loadAndValidateExternal(path, block.chainid, address(safe)); + } + + function test_Loader_RejectsEmptyBatch() public { + string memory path = "batches/p311-empty.json"; + SafeBatchEmitter.write(path, block.chainid, address(safe), "p311-empty", "test", new CctActions.Call[](0)); + vm.expectRevert(); + this.loadAndValidateExternal(path, block.chainid, address(safe)); + } + + /// @dev external shim so expectRevert applies to the library call. + function loadAndValidateExternal(string memory path, uint256 chainId, address expectedSafe) + external + view + returns (CctActions.Call[] memory) + { + return SafeBatchLoader.loadAndValidate(path, chainId, expectedSafe); + } + + /// @dev external shim so expectRevert applies to the whole Mode B execution. + function execDirectExternal(ISafe execSafe, CctActions.Call[] memory calls) external { + SafeMode.execDirect(execSafe, calls); + } + + // ───────────────────────────────────────────────────────────────────────── + // Gas: one merged meta-tx vs N separate Safe transactions + // ───────────────────────────────────────────────────────────────────────── + + /// @dev The same three rate-limit updates executed (a) as three separate Safe transactions and + /// (b) as one merged meta-transaction, from the identical starting state. The merged run + /// must cost less in-EVM (it additionally saves N-1 x 21k intrinsic gas on-chain, which + /// this in-EVM measurement cannot see). + function test_Gas_MergedVsSequential() public { + // Prerequisite: lane configured, pool owned by the Safe. + (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) = _laneInput(); + _exec(deployer, CctActions.applyChainUpdates(pool, removes, updates)); + vm.prank(deployer); + TokenPool(pool).transferOwnership(address(safe)); + SafeMode.execDirect(safe, CctActions.acceptOwnership(pool)); + + CctActions.Call[][] memory ops = new CctActions.Call[][](3); + ops[0] = _rateLimitOp(200e18, 0.2e18); + ops[1] = _rateLimitOp(300e18, 0.3e18); + ops[2] = _rateLimitOp(400e18, 0.4e18); + + uint256 snapshot = vm.snapshotState(); + + // (a) three separate Safe transactions. + uint256 sequentialGas; + for (uint256 i = 0; i < ops.length; i++) { + uint256 before = gasleft(); + SafeMode.execDirect(safe, ops[i]); + sequentialGas += before - gasleft(); + } + (RateLimiter.TokenBucket memory sequentialState,) = + TokenPool(pool).getCurrentRateLimiterState(EVM_SELECTOR, false); + vm.revertToState(snapshot); + + // (b) one merged meta-transaction. + CctActions.Call[] memory merged = CctActions.concat(CctActions.concat(ops[0], ops[1]), ops[2]); + uint256 beforeMerged = gasleft(); + SafeMode.execDirect(safe, merged); + uint256 mergedGas = beforeMerged - gasleft(); + (RateLimiter.TokenBucket memory mergedState,) = TokenPool(pool).getCurrentRateLimiterState(EVM_SELECTOR, false); + + emit log_named_uint("sequential Safe txs gas (in-EVM)", sequentialGas); + emit log_named_uint("merged meta-tx gas (in-EVM)", mergedGas); + assertLt(mergedGas, sequentialGas, "one merged meta-tx must cost less than N separate Safe txs"); + assertEq(mergedState.capacity, sequentialState.capacity, "both paths must land the same final state"); + assertEq(mergedState.rate, sequentialState.rate, "both paths must land the same final state"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + function _assertRoundTrip(string memory name, CctActions.Call[] memory calls) internal { + string memory path = SafeMode.emitBatch(name, address(safe), calls); + (uint256 chainId, address batchSafe, CctActions.Call[] memory loaded) = SafeBatchLoader.load(path); + assertEq(chainId, block.chainid, string.concat(name, ": chainId mismatch")); + assertEq(batchSafe, address(safe), string.concat(name, ": safe mismatch")); + assertEq(loaded.length, calls.length, string.concat(name, ": call count mismatch")); + for (uint256 i = 0; i < calls.length; i++) { + assertEq(loaded[i].target, calls[i].target, string.concat(name, ": target mismatch")); + assertEq(loaded[i].value, calls[i].value, string.concat(name, ": value mismatch")); + assertEq(loaded[i].data, calls[i].data, string.concat(name, ": data mismatch")); + } + } + + function _rateLimitOp(uint128 capacity, uint128 rate) internal view returns (CctActions.Call[] memory) { + return CctActions.setRateLimits( + pool, + PoolVersions.Version.V2_0_0, + EVM_SELECTOR, + false, + RateLimiter.Config({isEnabled: true, capacity: capacity, rate: rate}), + RateLimiter.Config({isEnabled: true, capacity: capacity / 2, rate: rate / 2}) + ); + } + + function _laneInput() internal pure returns (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) { + removes = new uint64[](0); + bytes[] memory remotePools = new bytes[](1); + remotePools[0] = abi.encode(EVM_REMOTE_POOL); + updates = new TokenPool.ChainUpdate[](1); + updates[0] = TokenPool.ChainUpdate({ + remoteChainSelector: EVM_SELECTOR, + remotePoolAddresses: remotePools, + remoteTokenAddress: abi.encode(EVM_REMOTE_TOKEN), + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: true, capacity: 1_000e18, rate: 0.1e18}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + } +} diff --git a/test/governance/Safe150Compat.t.sol b/test/governance/Safe150Compat.t.sol new file mode 100644 index 0000000..8ad8b09 --- /dev/null +++ b/test/governance/Safe150Compat.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; +import {BaseForkTest} from "../BaseForkTest.t.sol"; +import {CctActions} from "../../src/actions/CctActions.sol"; +import {PoolVersions} from "../../src/PoolVersions.sol"; +import {ISafe, ISafeProxyFactory} from "../../src/base/ISafe.sol"; +import {SafeMode} from "../../src/base/SafeMode.sol"; +import {SafeTxHash} from "../../src/base/SafeTxHash.sol"; + +/// @notice Forward-compatibility proof: the Safe execution mode works unchanged against a Safe +/// deployed from the canonical v1.5.0 stack. The repo's default stays v1.4.1 (the version +/// the Safe{Wallet} UI deploys, with the complete cross-chain canonical rollout — as of +/// 2026-07-10 the v1.5.0 SafeL2 singleton is NOT yet deployed on e.g. Avalanche Fuji, so +/// 1.4.1 remains the only stack that mirrors a fleet address everywhere). What this suite +/// pins: the `safeTxHash` math (EIP-712 domain and SafeTx typehash, frozen since 1.3.0), +/// the `execTransaction` ABI, and the MultiSend batching all hold on 1.5.0, so adopting it +/// later is a constants change (`SafeCanonical`), not a redesign. +/// @dev v1.5.0 canonical addresses from safe-global/safe-deployments v1.5.0 (verified on-chain on +/// Ethereum Sepolia before pinning here). +contract Safe150CompatForkTest is BaseForkTest { + uint256 internal constant OWNER1_KEY = 0xA11CE; + uint256 internal constant OWNER2_KEY = 0xB0B; + uint256 internal constant OWNER3_KEY = 0xC0FFEE; + + uint64 internal constant EVM_SELECTOR = 8236463271206331221; // Mantle Sepolia + address internal constant EVM_REMOTE_POOL = address(0x1111111111111111111111111111111111111111); + address internal constant EVM_REMOTE_TOKEN = address(0x2222222222222222222222222222222222222222); + + // Canonical v1.5.0 stack (Ethereum Sepolia; safe-deployments v1.5.0). + address internal constant SAFE_150_PROXY_FACTORY = 0x14F2982D601c9458F93bd70B218933A6f8165e7b; + address internal constant SAFE_150_L2_SINGLETON = 0xEdd160fEBBD92E350D4D398fb636302fccd67C7e; + address internal constant SAFE_150_FALLBACK_HANDLER = 0x3EfCBb83A4A7AfcB4F68D501E2c2203a38be77f4; + + address internal token; + address internal pool; + address internal deployer; + ISafe internal safe150; + + function setUp() public override { + super.setUp(); + (token, pool) = deployTokenAndPoolFixture(); + deployer = _scriptBroadcaster(); + + // Deploy a 2-of-3 Safe from the canonical v1.5.0 stack (same owners as the 1.4.1 suites). + require(SAFE_150_PROXY_FACTORY.code.length > 0, "v1.5.0 factory missing on this fork"); + require(SAFE_150_L2_SINGLETON.code.length > 0, "v1.5.0 SafeL2 singleton missing on this fork"); + address[] memory owners = new address[](3); + owners[0] = vm.addr(OWNER1_KEY); + owners[1] = vm.addr(OWNER2_KEY); + owners[2] = vm.addr(OWNER3_KEY); + bytes memory initializer = abi.encodeCall( + ISafe.setup, (owners, 2, address(0), "", SAFE_150_FALLBACK_HANDLER, address(0), 0, payable(address(0))) + ); + safe150 = ISafe( + ISafeProxyFactory(SAFE_150_PROXY_FACTORY).createProxyWithNonce(SAFE_150_L2_SINGLETON, initializer, 0) + ); + assertEq(safe150.getThreshold(), 2, "1.5.0 Safe threshold"); + + // The signer keys SafeMode.execDirect reads (same values as the 1.4.1 suites — no env race). + vm.setEnv("SAFE_SIGNER_KEYS", string.concat(vm.toString(OWNER1_KEY), ",", vm.toString(OWNER2_KEY))); + } + + /// @dev The hash math is version-stable: our local EIP-712 recompute equals the 1.5.0 Safe's + /// own `getTransactionHash`, under fuzzing. + function testFuzz_SafeTxHash_MatchesOnChain_150( + address to, + uint256 value, + bytes memory data, + bool delegateCall, + uint256 nonce + ) public view { + SafeTxHash.SafeTx memory t = SafeTxHash.SafeTx({ + to: to, + value: value, + data: data, + operation: delegateCall ? 1 : 0, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: address(0), + nonce: nonce + }); + assertEq( + SafeTxHash.compute(block.chainid, address(safe150), t), + safe150.getTransactionHash(t.to, t.value, t.data, t.operation, 0, 0, 0, address(0), address(0), nonce), + "local safeTxHash recompute must equal a v1.5.0 Safe's getTransactionHash" + ); + } + + /// @dev The full Mode B ceremony runs unchanged against a 1.5.0 Safe: single-call ownership + /// accept, then a BATCHED (MultiSendCallOnly delegatecall) two-call rate-limit change — + /// also proving the canonical 1.4.1 MultiSendCallOnly composes with a 1.5.0 Safe. + function test_ModeB_Ceremony_On150Safe_SingleAndBatched() public { + // Lane + handoff. + (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) = _laneInput(); + _exec(deployer, CctActions.applyChainUpdates(pool, removes, updates)); + vm.prank(deployer); + TokenPool(pool).transferOwnership(address(safe150)); + + // Single-call Safe tx. + uint256 nonceBefore = safe150.nonce(); + SafeMode.execDirect(safe150, CctActions.acceptOwnership(pool)); + assertEq(TokenPool(pool).owner(), address(safe150), "1.5.0 Safe must own the pool"); + + // Batched meta-tx: two rate-limit updates as ONE MultiSend delegatecall. + CctActions.Call[] memory batched = CctActions.concat(_rateLimitOp(200e18, 0.2e18), _rateLimitOp(300e18, 0.3e18)); + SafeMode.execDirect(safe150, batched); + + assertEq(safe150.nonce(), nonceBefore + 2, "two ceremonies must consume exactly two nonces"); + (RateLimiter.TokenBucket memory outbound,) = TokenPool(pool).getCurrentRateLimiterState(EVM_SELECTOR, false); + assertEq(outbound.capacity, 300e18, "the batch's last call must have landed"); + } + + function _rateLimitOp(uint128 capacity, uint128 rate) internal view returns (CctActions.Call[] memory) { + return CctActions.setRateLimits( + pool, + PoolVersions.Version.V2_0_0, + EVM_SELECTOR, + false, + RateLimiter.Config({isEnabled: true, capacity: capacity, rate: rate}), + RateLimiter.Config({isEnabled: true, capacity: capacity / 2, rate: rate / 2}) + ); + } + + function _laneInput() internal pure returns (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) { + removes = new uint64[](0); + bytes[] memory remotePools = new bytes[](1); + remotePools[0] = abi.encode(EVM_REMOTE_POOL); + updates = new TokenPool.ChainUpdate[](1); + updates[0] = TokenPool.ChainUpdate({ + remoteChainSelector: EVM_SELECTOR, + remotePoolAddresses: remotePools, + remoteTokenAddress: abi.encode(EVM_REMOTE_TOKEN), + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: true, capacity: 1_000e18, rate: 0.1e18}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + } +} diff --git a/test/governance/SafeMode.t.sol b/test/governance/SafeMode.t.sol new file mode 100644 index 0000000..8d9f43f --- /dev/null +++ b/test/governance/SafeMode.t.sol @@ -0,0 +1,595 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {TokenPool} from "@chainlink/contracts-ccip/contracts/pools/TokenPool.sol"; +import {TokenAdminRegistry} from "@chainlink/contracts-ccip/contracts/tokenAdminRegistry/TokenAdminRegistry.sol"; +import { + RegistryModuleOwnerCustom +} from "@chainlink/contracts-ccip/contracts/tokenAdminRegistry/RegistryModuleOwnerCustom.sol"; +import {CrossChainToken} from "@chainlink/contracts-ccip/contracts/tokens/CrossChainToken.sol"; +import {RateLimiter} from "@chainlink/contracts-ccip/contracts/libraries/RateLimiter.sol"; +import {BaseForkTest} from "../BaseForkTest.t.sol"; +import {DeploySafe} from "../../script/governance/DeploySafe.s.sol"; +import {AcceptAdminRole} from "../../script/setup/AcceptAdminRole.s.sol"; +import {CctActions, ITokenPoolV150, ILockReleaseV1Liquidity} from "../../src/actions/CctActions.sol"; +import {PoolVersions} from "../../src/PoolVersions.sol"; +import {AdvancedPoolHooks} from "@chainlink/contracts-ccip/contracts/pools/AdvancedPoolHooks.sol"; +import {IERC20} from "@openzeppelin/contracts@5.3.0/token/ERC20/IERC20.sol"; +import {EoaExecutor} from "../../src/base/EoaExecutor.s.sol"; +import {ISafe, SafeCanonical} from "../../src/base/ISafe.sol"; +import {SafeMode} from "../../src/base/SafeMode.sol"; + +/// @dev Test-only: the real AcceptAdminRole script pinned to safe mode via the override (the `MODE` +/// env var is process-wide, see ExecutorHarness). Captures the built calls instead of +/// emitting/broadcasting, so parallel tests never contend on batch files. +contract AcceptAdminRoleSafeHarness is AcceptAdminRole { + CctActions.Call[] public captured; + + function _executionMode() internal pure override returns (string memory) { + return "safe"; + } + + function executeCalls(CctActions.Call[] memory calls) internal override { + for (uint256 i = 0; i < calls.length; i++) { + captured.push(calls[i]); + } + } + + function capturedCount() external view returns (uint256) { + return captured.length; + } +} + +/// @dev Test-only executor harness: pins the execution mode via an override instead of the `MODE` +/// environment variable, because `vm.setEnv` is process-wide and forge runs test contracts in +/// parallel — flipping `MODE` in the environment would leak into every other suite's script runs. +contract ExecutorHarness is EoaExecutor { + string private s_mode; + + constructor(string memory mode) { + s_mode = mode; + } + + function exec(CctActions.Call[] memory calls) external { + executeCalls(calls); + } + + function _executionMode() internal view override returns (string memory) { + return s_mode; + } +} + +/// @dev Test-only minimal v1.x LockRelease pool: mirrors the `ILockReleaseV1Liquidity` surface the action +/// layer targets (the real v1.x LockRelease pool is NOT in the vendored 2.0.0 package, so a shim stands +/// in for it exactly like `ILockReleaseV1Liquidity` itself). `provideLiquidity` pulls tokens via +/// `transferFrom`, so the caller (the rebalancer — here the Safe) must have approved this pool first; +/// the two happen in ONE Safe MultiSend, which is precisely what the flow test proves. +contract MockLockReleaseV1Pool { + IERC20 private immutable i_token; + address private s_rebalancer; + + constructor(IERC20 token_) { + i_token = token_; + } + + function getToken() external view returns (IERC20) { + return i_token; + } + + function getRebalancer() external view returns (address) { + return s_rebalancer; + } + + function setRebalancer(address rebalancer) external { + s_rebalancer = rebalancer; + } + + function provideLiquidity(uint256 amount) external { + require(msg.sender == s_rebalancer, "MockLockRelease: caller is not the rebalancer"); + require(i_token.transferFrom(msg.sender, address(this), amount), "MockLockRelease: transferFrom failed"); + } + + function withdrawLiquidity(uint256 amount) external { + require(msg.sender == s_rebalancer, "MockLockRelease: caller is not the rebalancer"); + require(i_token.transfer(msg.sender, amount), "MockLockRelease: transfer failed"); + } +} + +/// @notice PR 3.1 Safe-mode proofs, re-established in THIS repo against a real Safe deployed from the +/// canonical v1.4.1 stack on a Sepolia fork: +/// - Requirement 2 byte-equality: for the whole action-layer catalog, the Safe Transaction +/// Builder batch JSON and the Mode B `execTransaction` payload carry the IDENTICAL inner +/// `to`/`value`/`data` the EOA mode broadcasts. +/// - Mode B end-to-end: build → hash (local recompute == on-chain) → sign → pack sorted → +/// `execTransaction` → the resulting on-chain state equals the EOA path's end state. +/// - The claim+accept registration pair executes atomically as ONE Safe batch. +/// - The `MODE` switch dispatches correctly and rejects unknown modes. +contract SafeModeForkTest is BaseForkTest { + uint256 internal constant OWNER1_KEY = 0xA11CE; + uint256 internal constant OWNER2_KEY = 0xB0B; + uint256 internal constant OWNER3_KEY = 0xC0FFEE; + + // Fixed lane-config inputs (EVM remote), matching the SetupActions parity fixtures. + uint64 internal constant EVM_SELECTOR = 8236463271206331221; // Mantle Sepolia + address internal constant EVM_REMOTE_POOL = address(0x1111111111111111111111111111111111111111); + address internal constant EVM_REMOTE_TOKEN = address(0x2222222222222222222222222222222222222222); + + address internal token; + address internal pool; + address internal deployer; + ISafe internal safe; + TokenAdminRegistry internal registry; + RegistryModuleOwnerCustom internal registryModule; + + function setUp() public override { + super.setUp(); + (token, pool) = deployTokenAndPoolFixture(); + deployer = _scriptBroadcaster(); + registry = TokenAdminRegistry(networkConfig.tokenAdminRegistry); + registryModule = RegistryModuleOwnerCustom(networkConfig.registryModuleOwnerCustom); + + // Deploy the governed Safe (2-of-3) from the canonical v1.4.1 stack. All values are constant, + // so the CREATE2 address — and therefore every env var below — is identical across parallel + // suites (the same reasoning the fixture env vars rely on). + vm.setEnv( + "SAFE_OWNERS", + string.concat( + vm.toString(vm.addr(OWNER1_KEY)), + ",", + vm.toString(vm.addr(OWNER2_KEY)), + ",", + vm.toString(vm.addr(OWNER3_KEY)) + ) + ); + vm.setEnv("SAFE_THRESHOLD", "2"); + vm.setEnv("SAFE_SALT_NONCE", "0"); + safe = ISafe(new DeploySafe().run()); + + vm.setEnv("SAFE_ADDRESS", vm.toString(address(safe))); + // Two of the three owner keys — meets the threshold. Test-only keys, never real secrets. + vm.setEnv("SAFE_SIGNER_KEYS", string.concat(vm.toString(OWNER1_KEY), ",", vm.toString(OWNER2_KEY))); + vm.setEnv("SAFE_EXEC", ""); + vm.setEnv("BATCH_NAME", "test-dispatch"); + } + + // ───────────────────────────────────────────────────────────────────────── + // MODE switch dispatch + // ───────────────────────────────────────────────────────────────────────── + + /// @dev Default mode broadcasts exactly as before the switch existed: the call lands on-chain from + /// the script signer. + function test_ModeEoa_BroadcastsCalls() public { + ExecutorHarness harness = new ExecutorHarness("eoa"); + harness.exec(CctActions.registerAdminViaGetCCIPAdmin(address(registryModule), token)); + assertEq( + registry.getTokenConfig(token).pendingAdministrator, + deployer, + "eoa mode must broadcast the claim from the script signer" + ); + } + + /// @dev Safe mode must NOT broadcast the inner calls; it emits the batch for review instead. This + /// is the only test that drives the env-reading `SafeMode.run` path (via the dispatch), so the + /// `BATCH_NAME` set in setUp is race-free; the other tests pass parameters directly. + function test_ModeSafe_EmitsBatchWithoutBroadcasting() public { + ExecutorHarness harness = new ExecutorHarness("safe"); + harness.exec(CctActions.registerAdminViaGetCCIPAdmin(address(registryModule), token)); + assertEq( + registry.getTokenConfig(token).pendingAdministrator, + address(0), + "safe mode must not broadcast the inner call" + ); + string memory json = vm.readFile(string.concat("batches/test-dispatch.", vm.toString(block.chainid), ".json")); + assertEq( + vm.parseJsonAddress(json, ".transactions[0].to"), + address(registryModule), + "emitted batch must target the registry module" + ); + } + + function test_ModeUnknown_Reverts() public { + ExecutorHarness harness = new ExecutorHarness("timelock"); + vm.expectRevert(bytes("Unknown MODE 'timelock': use 'eoa' (default) or 'safe'.")); + harness.exec(CctActions.acceptAdminRole(address(registry), token)); + } + + /// @dev AcceptAdminRole's preflight compares the pending administrator against the account + /// executing in the selected mode — the Safe in safe mode, the broadcaster otherwise. With + /// the Safe pending, a safe-mode run passes preflight and builds the accept call; the + /// EOA-mode preflight still rejects a broadcaster that is not the pending administrator. + function test_AcceptAdminRole_SafeMode_PreflightUsesExecutingAccount() public { + // Make the Safe the pending administrator (claim executed by the Safe). + vm.prank(deployer); + CrossChainToken(token).setCCIPAdmin(address(safe)); + _runSafeDirect("safe-claim", CctActions.registerAdminViaGetCCIPAdmin(address(registryModule), token)); + assertEq(registry.getTokenConfig(token).pendingAdministrator, address(safe), "Safe must be pending"); + + // Safe-mode run passes preflight and builds the accept call (captured, not broadcast). + AcceptAdminRoleSafeHarness harness = new AcceptAdminRoleSafeHarness(); + harness.run(); + assertEq(harness.capturedCount(), 1, "safe-mode AcceptAdminRole must build the accept call"); + (address target,, bytes memory data) = harness.captured(0); + assertEq(target, address(registry), "accept must target the TokenAdminRegistry"); + assertEq( + data, + abi.encodeCall(TokenAdminRegistry.acceptAdminRole, (token)), + "accept calldata must match the action layer" + ); + + // EOA-mode preflight unchanged: the broadcaster is not the pending administrator -> revert. + AcceptAdminRole eoaScript = new AcceptAdminRole(); + vm.expectRevert(bytes("Only the pending administrator can accept the admin role")); + eoaScript.run(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Requirement 2: byte-equality over the action-layer catalog + // ───────────────────────────────────────────────────────────────────────── + + /// @dev For EVERY builder in the action-layer catalog: the Safe Transaction Builder JSON round-trips + /// to the IDENTICAL `to`/`value`/`data` the EOA mode would broadcast, and the Mode B + /// `execTransaction` payload (single-call passthrough or MultiSend packing) decodes back to the + /// same bytes. The `Call[]` IS what `EoaExecutor` broadcasts, so equality here is equality with + /// the EOA calldata. + function test_ByteEquality_SetupCatalog() public { + _assertBatchRoundTrip( + "register-admin-via-owner", CctActions.registerAdminViaOwner(address(registryModule), token) + ); + _assertBatchRoundTrip( + "register-admin-via-getccipadmin", CctActions.registerAdminViaGetCCIPAdmin(address(registryModule), token) + ); + _assertBatchRoundTrip("accept-admin-role", CctActions.acceptAdminRole(address(registry), token)); + _assertBatchRoundTrip( + "registration-pair-via-owner", + CctActions.registerAndAcceptAdminViaOwner(address(registryModule), address(registry), token) + ); + _assertBatchRoundTrip( + "registration-pair-via-getccipadmin", + CctActions.registerAndAcceptAdminViaGetCCIPAdmin(address(registryModule), address(registry), token) + ); + _assertBatchRoundTrip("set-pool", CctActions.setPool(address(registry), token, pool)); + _assertBatchRoundTrip( + "transfer-admin-role", CctActions.transferAdminRole(address(registry), token, address(safe)) + ); + _assertBatchRoundTrip("transfer-ownership", CctActions.transferOwnership(pool, address(safe))); + _assertBatchRoundTrip("accept-ownership", CctActions.acceptOwnership(pool)); + (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) = _laneInput(); + _assertBatchRoundTrip("apply-chain-updates", CctActions.applyChainUpdates(pool, removes, updates)); + } + + function test_ByteEquality_ConfigureAndOperationsCatalog() public { + RateLimiter.Config memory outbound = RateLimiter.Config({isEnabled: true, capacity: 1_000e18, rate: 0.1e18}); + RateLimiter.Config memory inbound = RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}); + _assertBatchRoundTrip( + "set-rate-limits-v2", + CctActions.setRateLimits(pool, PoolVersions.Version.V2_0_0, EVM_SELECTOR, false, outbound, inbound) + ); + _assertBatchRoundTrip( + "set-rate-limits-v1", + CctActions.setRateLimits(pool, PoolVersions.Version.V1_6_1, EVM_SELECTOR, false, outbound, inbound) + ); + _assertBatchRoundTrip( + "set-dynamic-config", CctActions.setDynamicConfig(pool, networkConfig.router, deployer, deployer) + ); + _assertBatchRoundTrip( + "add-remote-pool", CctActions.addRemotePool(pool, EVM_SELECTOR, abi.encode(EVM_REMOTE_POOL)) + ); + _assertBatchRoundTrip( + "remove-remote-pool", CctActions.removeRemotePool(pool, EVM_SELECTOR, abi.encode(EVM_REMOTE_POOL)) + ); + address[] memory adds = new address[](1); + adds[0] = deployer; + address[] memory removals = new address[](0); + _assertBatchRoundTrip("apply-allowlist-updates", CctActions.applyAllowListUpdates(pool, removals, adds)); + _assertBatchRoundTrip( + "apply-authorized-caller-updates", CctActions.applyAuthorizedCallerUpdates(pool, adds, removals) + ); + _assertBatchRoundTrip("mint", CctActions.mint(token, deployer, 1e18)); + _assertBatchRoundTrip("lockbox-deposit", CctActions.lockboxDeposit(EVM_REMOTE_POOL, token, 1e18)); + _assertBatchRoundTrip("lockbox-withdraw", CctActions.lockboxWithdraw(EVM_REMOTE_POOL, token, 1e18, deployer)); + address[] memory feeTokens = new address[](1); + feeTokens[0] = token; + _assertBatchRoundTrip("withdraw-fee-tokens", CctActions.withdrawFeeTokens(pool, feeTokens, deployer)); + } + + /// @dev PR #9 (lanes-as-data / version-dispatched pool ops / CCV config / v1.x LockRelease liquidity / + /// 1.5.0 lane shape) added six new action-layer builders. The byte-equality contract is "for EVERY + /// builder in the catalog", so every new builder must round-trip identically under Safe — this + /// extends the catalog to cover them. Targets are the fixture pool (a plausible target address is + /// all byte-equality needs; these builders are never executed here, only encoded and round-tripped). + function test_ByteEquality_VersionedOpsCatalog() public { + // CCV config on the AdvancedPoolHooks: one representative lane with dummy verifier addresses. + address[] memory outboundCCVs = new address[](1); + outboundCCVs[0] = address(0xCC50); + address[] memory inboundCCVs = new address[](1); + inboundCCVs[0] = address(0xCC51); + AdvancedPoolHooks.CCVConfigArg[] memory ccvArgs = new AdvancedPoolHooks.CCVConfigArg[](1); + ccvArgs[0] = AdvancedPoolHooks.CCVConfigArg({ + remoteChainSelector: EVM_SELECTOR, + outboundCCVs: outboundCCVs, + thresholdOutboundCCVs: new address[](0), + inboundCCVs: inboundCCVs, + thresholdInboundCCVs: new address[](0) + }); + _assertBatchRoundTrip("apply-ccv-config", CctActions.applyCCVConfigUpdates(pool, ccvArgs)); + _assertBatchRoundTrip("set-ccv-threshold", CctActions.setThresholdAmount(pool, 500e18)); + + // v1.x LockRelease rebalancer / liquidity surface. + _assertBatchRoundTrip("set-rebalancer", CctActions.setRebalancer(pool, deployer)); + _assertBatchRoundTrip("provide-liquidity", CctActions.provideLiquidity(pool, 1e18)); + _assertBatchRoundTrip("withdraw-liquidity", CctActions.withdrawLiquidity(pool, 1e18)); + + // 1.5.0-shaped lane update (single-argument ChainUpdate[] with an `allowed` flag, one remote pool). + ITokenPoolV150.ChainUpdate[] memory v150Updates = new ITokenPoolV150.ChainUpdate[](1); + v150Updates[0] = ITokenPoolV150.ChainUpdate({ + remoteChainSelector: EVM_SELECTOR, + allowed: true, + remotePoolAddress: abi.encode(EVM_REMOTE_POOL), + remoteTokenAddress: abi.encode(EVM_REMOTE_TOKEN), + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: true, capacity: 1_000e18, rate: 0.1e18}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + _assertBatchRoundTrip("apply-chain-updates-v150", CctActions.applyChainUpdatesV150(pool, v150Updates)); + + // Whole-chain teardown shapes emitted by RemoveChain.s.sol, proven Safe-executable byte-for-byte. + // Modern (1.5.1+): the selector in `toRemove`, an empty `toAdd`. + uint64[] memory chainRemovals = new uint64[](1); + chainRemovals[0] = EVM_SELECTOR; + TokenPool.ChainUpdate[] memory noAdds = new TokenPool.ChainUpdate[](0); + _assertBatchRoundTrip("remove-chain-modern", CctActions.applyChainUpdates(pool, chainRemovals, noAdds)); + + // 1.5.0: a single-argument `allowed:false` entry with disabled, zeroed rate configs. + ITokenPoolV150.ChainUpdate[] memory v150Removal = new ITokenPoolV150.ChainUpdate[](1); + v150Removal[0] = ITokenPoolV150.ChainUpdate({ + remoteChainSelector: EVM_SELECTOR, + allowed: false, + remotePoolAddress: "", + remoteTokenAddress: "", + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + _assertBatchRoundTrip("remove-chain-v150", CctActions.applyChainUpdatesV150(pool, v150Removal)); + } + + // ───────────────────────────────────────────────────────────────────────── + // Mode B end-to-end: end-state equality with the EOA path + // ───────────────────────────────────────────────────────────────────────── + + /// @dev The SAME `applyChainUpdates` inputs, executed (a) by the EOA owner and (b) by the Safe via + /// the full Mode B ceremony (ownership handed to the Safe first, itself via Mode B), must land + /// the IDENTICAL lane config on-chain. + function test_ModeB_EndStateEqualsEoaPath() public { + (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) = _laneInput(); + CctActions.Call[] memory laneCalls = CctActions.applyChainUpdates(pool, removes, updates); + + // (a) EOA path (pool still owned by the deployer). + uint256 snapshot = vm.snapshotState(); + _exec(deployer, laneCalls); + bytes memory eoaRemoteToken = TokenPool(pool).getRemoteToken(EVM_SELECTOR); + bytes[] memory eoaRemotePools = TokenPool(pool).getRemotePools(EVM_SELECTOR); + vm.revertToState(snapshot); + + // (b) Safe path: two-step ownership handoff (accept runs through Mode B — a single-call Safe + // transaction), then the lane config through Mode B (also a single call). + vm.prank(deployer); + TokenPool(pool).transferOwnership(address(safe)); + _runSafeDirect("modeb-accept-ownership", CctActions.acceptOwnership(pool)); + assertEq(TokenPool(pool).owner(), address(safe), "Safe must own the pool after the Mode B accept"); + + _runSafeDirect("modeb-apply-chain-updates", laneCalls); + + assertTrue(TokenPool(pool).isSupportedChain(EVM_SELECTOR), "lane must be configured by the Safe"); + assertEq(TokenPool(pool).getRemoteToken(EVM_SELECTOR), eoaRemoteToken, "remote token must equal the EOA path"); + bytes[] memory safeRemotePools = TokenPool(pool).getRemotePools(EVM_SELECTOR); + assertEq(safeRemotePools.length, eoaRemotePools.length, "remote pool count must equal the EOA path"); + for (uint256 i = 0; i < safeRemotePools.length; i++) { + assertEq(safeRemotePools[i], eoaRemotePools[i], "remote pool must equal the EOA path"); + } + } + + /// @dev The registration pair (claim + accept) executes atomically as ONE Safe MultiSend batch: the + /// claim sets the Safe (the token's CCIP admin) as pending administrator, so the accept in the + /// SAME batch succeeds. + function test_ModeB_RegistrationPair_AtomicBatch() public { + vm.prank(deployer); + CrossChainToken(token).setCCIPAdmin(address(safe)); + + CctActions.Call[] memory pair = + CctActions.registerAndAcceptAdminViaGetCCIPAdmin(address(registryModule), address(registry), token); + assertEq(pair.length, 2, "registration pair must be two calls"); + _runSafeDirect("modeb-registration-pair", pair); + + assertEq(registry.getTokenConfig(token).administrator, address(safe), "Safe must be the administrator"); + assertEq(registry.getTokenConfig(token).pendingAdministrator, address(0), "pending must be cleared"); + } + + /// @dev PR #9 liquidity-under-Safe: `ProvideLiquidity.s.sol` emits `approve` + `provideLiquidity` as ONE + /// two-call batch, and `provideLiquidity` requires `msg.sender == rebalancer`. With the Safe as the + /// pool's rebalancer, that batch must (a) round-trip through the Safe Transaction Builder / MultiSend + /// byte-identically in the correct order (token approve, then pool provideLiquidity), and (b) execute + /// atomically as ONE Safe transaction — the approve and the transferFrom it authorizes both run with + /// `msg.sender == Safe` inside the single MultiSend delegatecall, so the liquidity lands atomically. + function test_ModeB_ProvideLiquidity_TwoCallBatch_UnderSafe() public { + uint256 amount = 1_000e18; + MockLockReleaseV1Pool lrPool = new MockLockReleaseV1Pool(IERC20(token)); + lrPool.setRebalancer(address(safe)); + deal(token, address(safe), amount); + + // The exact batch ProvideLiquidity.s.sol builds: approve(pool, amount) then provideLiquidity(amount). + CctActions.Call[] memory batch = CctActions.concat( + CctActions.approve(token, address(lrPool), amount), CctActions.provideLiquidity(address(lrPool), amount) + ); + + // Structure: two calls, in order, targeting the token (approve) then the pool (provideLiquidity). + assertEq(batch.length, 2, "liquidity batch must be two calls"); + assertEq(batch[0].target, token, "call 0 must target the token (approve)"); + assertEq( + batch[0].data, abi.encodeCall(IERC20.approve, (address(lrPool), amount)), "call 0 calldata must be approve" + ); + assertEq(batch[1].target, address(lrPool), "call 1 must target the pool (provideLiquidity)"); + assertEq( + batch[1].data, + abi.encodeCall(ILockReleaseV1Liquidity.provideLiquidity, (amount)), + "call 1 calldata must be provideLiquidity" + ); + + // (a) Byte-equal round-trip through the Transaction Builder JSON + the Mode B MultiSend payload. + _assertBatchRoundTrip("provide-liquidity-under-safe", batch); + + // (b) Atomic execution as ONE Safe transaction: the Safe's tokens move into the pool. + uint256 nonceBefore = safe.nonce(); + _runSafeDirect("modeb-provide-liquidity", batch); + assertEq(safe.nonce(), nonceBefore + 1, "the two-call liquidity batch must consume exactly ONE Safe nonce"); + assertEq(IERC20(token).balanceOf(address(lrPool)), amount, "pool must hold the provided liquidity"); + assertEq(IERC20(token).balanceOf(address(safe)), 0, "Safe's tokens must have moved into the pool"); + } + + /// @dev Signature packing order is load-bearing: the SAME two valid signatures submitted in + /// DESCENDING signer order must be rejected by the Safe (GS026), while the ascending pack the + /// executor builds succeeds (proven by every other Mode B test). + function test_ModeB_UnsortedSignatures_Revert() public { + vm.prank(deployer); + TokenPool(pool).transferOwnership(address(safe)); + CctActions.Call[] memory calls = CctActions.acceptOwnership(pool); + (address to, uint256 value, bytes memory data, uint8 operation) = SafeMode.encodeForSafe(calls); + bytes32 txHash = + safe.getTransactionHash(to, value, data, operation, 0, 0, 0, address(0), address(0), safe.nonce()); + + // Sign with both owners, then pack DESCENDING by signer address. + (uint256 lowKey, uint256 highKey) = + vm.addr(OWNER1_KEY) < vm.addr(OWNER2_KEY) ? (OWNER1_KEY, OWNER2_KEY) : (OWNER2_KEY, OWNER1_KEY); + (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(highKey, txHash); + (uint8 v2, bytes32 r2, bytes32 s2) = vm.sign(lowKey, txHash); + bytes memory descending = abi.encodePacked(r1, s1, v1, r2, s2, v2); + + vm.expectRevert(bytes("GS026")); + safe.execTransaction(to, value, data, operation, 0, 0, 0, address(0), payable(address(0)), descending); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /// @dev Runs `calls` through the full Safe ceremony (batch emission for review, then the Mode B + /// direct `execTransaction`) — the same code `MODE=safe SAFE_EXEC=direct` drives from the CLI, + /// with the batch name and Safe passed as parameters instead of process-wide env vars (forge + /// runs tests in parallel, so per-test `vm.setEnv` values would race across tests). + function _runSafeDirect(string memory batchName, CctActions.Call[] memory calls) internal { + SafeMode.emitBatch(batchName, address(safe), calls); + SafeMode.execDirect(safe, calls); + } + + /// @dev Emits the Transaction Builder JSON for `calls`, parses it back, and asserts every + /// transaction's `to`/`value`/`data` byte-equal the action-layer calls; then round-trips the + /// Mode B payload (`encodeForSafe`) back to the same calls. + function _assertBatchRoundTrip(string memory name, CctActions.Call[] memory calls) internal { + // JSON leg (the Transaction Builder / Mode A artifact). + string memory path = SafeMode.emitBatch(name, address(safe), calls); + string memory json = vm.readFile(path); + for (uint256 i = 0; i < calls.length; i++) { + string memory prefix = string.concat(".transactions[", vm.toString(i), "]"); + assertEq( + vm.parseJsonAddress(json, string.concat(prefix, ".to")), + calls[i].target, + string.concat(name, ": batch 'to' mismatch") + ); + assertEq( + vm.parseJsonString(json, string.concat(prefix, ".value")), + vm.toString(calls[i].value), + string.concat(name, ": batch 'value' mismatch") + ); + assertEq( + vm.parseJsonBytes(json, string.concat(prefix, ".data")), + calls[i].data, + string.concat(name, ": batch 'data' mismatch") + ); + } + assertFalse( + vm.keyExistsJson(json, string.concat(".transactions[", vm.toString(calls.length), "]")), + string.concat(name, ": batch must not carry extra transactions") + ); + + // Mode B leg (the execTransaction payload). + (address to, uint256 value, bytes memory data, uint8 operation) = SafeMode.encodeForSafe(calls); + if (calls.length == 1) { + assertEq(operation, 0, string.concat(name, ": single call must be a CALL")); + assertEq(to, calls[0].target, string.concat(name, ": Mode B 'to' mismatch")); + assertEq(value, calls[0].value, string.concat(name, ": Mode B 'value' mismatch")); + assertEq(data, calls[0].data, string.concat(name, ": Mode B 'data' mismatch")); + } else { + assertEq(operation, 1, string.concat(name, ": multi-call must DELEGATECALL MultiSend")); + assertEq( + to, SafeCanonical.MULTI_SEND_CALL_ONLY, string.concat(name, ": Mode B must target MultiSendCallOnly") + ); + assertEq(value, 0, string.concat(name, ": MultiSend value must be zero")); + CctActions.Call[] memory decoded = _decodeMultiSend(data); + assertEq(decoded.length, calls.length, string.concat(name, ": MultiSend call count mismatch")); + for (uint256 i = 0; i < calls.length; i++) { + assertEq(decoded[i].target, calls[i].target, string.concat(name, ": MultiSend 'to' mismatch")); + assertEq(decoded[i].value, calls[i].value, string.concat(name, ": MultiSend 'value' mismatch")); + assertEq(decoded[i].data, calls[i].data, string.concat(name, ": MultiSend 'data' mismatch")); + } + } + } + + /// @dev Decodes a `multiSend(bytes)` payload back into the individual calls: strips the selector, + /// abi-decodes the packed bytes, then walks the packed encoding (op:1 || to:20 || value:32 || + /// dataLength:32 || data). Every inner operation must be a CALL (MultiSendCallOnly semantics). + function _decodeMultiSend(bytes memory data) internal pure returns (CctActions.Call[] memory calls) { + // Strip the 4-byte multiSend selector, then abi.decode the single `bytes` argument. + bytes memory args = new bytes(data.length - 4); + for (uint256 i = 0; i < args.length; i++) { + args[i] = data[i + 4]; + } + bytes memory packed = abi.decode(args, (bytes)); + + // First pass: count the packed transactions. + uint256 count; + uint256 offset; + while (offset < packed.length) { + require(uint8(packed[offset]) == 0, "MultiSendCallOnly batch must contain only CALLs"); + uint256 dataLength = _readUint(packed, offset + 53); + offset += 85 + dataLength; + count++; + } + require(offset == packed.length, "malformed MultiSend packing"); + + // Second pass: extract each call. + calls = new CctActions.Call[](count); + offset = 0; + for (uint256 i = 0; i < count; i++) { + address target = address(uint160(_readUint(packed, offset + 1) >> 96)); + uint256 value = _readUint(packed, offset + 21); + uint256 dataLength = _readUint(packed, offset + 53); + bytes memory callData = new bytes(dataLength); + for (uint256 j = 0; j < dataLength; j++) { + callData[j] = packed[offset + 85 + j]; + } + calls[i] = CctActions.Call({target: target, value: value, data: callData}); + offset += 85 + dataLength; + } + } + + /// @dev Reads the 32-byte word at byte offset `start` of `b`. + function _readUint(bytes memory b, uint256 start) private pure returns (uint256 v) { + require(start + 32 <= b.length, "read past end of packed bytes"); + // solhint-disable-next-line no-inline-assembly + assembly { + v := mload(add(add(b, 32), start)) + } + } + + function _laneInput() internal pure returns (uint64[] memory removes, TokenPool.ChainUpdate[] memory updates) { + removes = new uint64[](0); + bytes[] memory remotePools = new bytes[](1); + remotePools[0] = abi.encode(EVM_REMOTE_POOL); + updates = new TokenPool.ChainUpdate[](1); + updates[0] = TokenPool.ChainUpdate({ + remoteChainSelector: EVM_SELECTOR, + remotePoolAddresses: remotePools, + remoteTokenAddress: abi.encode(EVM_REMOTE_TOKEN), + outboundRateLimiterConfig: RateLimiter.Config({isEnabled: true, capacity: 1_000e18, rate: 0.1e18}), + inboundRateLimiterConfig: RateLimiter.Config({isEnabled: false, capacity: 0, rate: 0}) + }); + } +} diff --git a/test/governance/SafeTxHash.t.sol b/test/governance/SafeTxHash.t.sol new file mode 100644 index 0000000..46da525 --- /dev/null +++ b/test/governance/SafeTxHash.t.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.24; + +import {BaseForkTest} from "../BaseForkTest.t.sol"; +import {DeploySafe} from "../../script/governance/DeploySafe.s.sol"; +import {ISafe} from "../../src/base/ISafe.sol"; +import {SafeTxHash} from "../../src/base/SafeTxHash.sol"; + +/// @notice Proves the local EIP-712 `safeTxHash` recompute (`SafeTxHash`) against a REAL Safe deployed +/// from the canonical v1.4.1 stack on a Sepolia fork: the recomputed domain separator and +/// transaction hash must equal the Safe's own `domainSeparator()` / `getTransactionHash()`, +/// including under fuzzed inputs. Also pins the `_nonce` typehash gotcha as a regression test. +contract SafeTxHashForkTest is BaseForkTest { + uint256 internal constant OWNER1_KEY = 0xA11CE; + uint256 internal constant OWNER2_KEY = 0xB0B; + uint256 internal constant OWNER3_KEY = 0xC0FFEE; + + ISafe internal safe; + + function setUp() public override { + super.setUp(); + vm.setEnv( + "SAFE_OWNERS", + string.concat( + vm.toString(vm.addr(OWNER1_KEY)), + ",", + vm.toString(vm.addr(OWNER2_KEY)), + ",", + vm.toString(vm.addr(OWNER3_KEY)) + ) + ); + vm.setEnv("SAFE_THRESHOLD", "2"); + vm.setEnv("SAFE_SALT_NONCE", "0"); + safe = ISafe(new DeploySafe().run()); + } + + // ───────────────────────────────────────────────────────────────────────── + // Typehash regression (the `_nonce` gotcha) + // ───────────────────────────────────────────────────────────────────────── + + /// @dev The canonical SafeTx typehash, as Safe >= 1.3.0 hardcodes it on-chain. + function test_SafeTxTypehash_MatchesCanonicalConstant() public pure { + assertEq( + SafeTxHash.SAFE_TX_TYPEHASH, + 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8, + "SAFE_TX_TYPEHASH must equal the canonical Safe constant" + ); + } + + /// @dev GOTCHA regression: encoding the Solidity PARAMETER name `_nonce` instead of the EIP-712 + /// struct FIELD name `nonce` yields a DIFFERENT typehash — and a `safeTxHash` that never + /// matches signer devices. This pins the trap so it cannot be reintroduced. + function test_SafeTxTypehash_NonceNotUnderscoreNonce() public pure { + bytes32 wrongTypehash = keccak256( + "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 _nonce)" + ); + assertTrue(wrongTypehash != SafeTxHash.SAFE_TX_TYPEHASH, "the _nonce type string must NOT match"); + } + + // ───────────────────────────────────────────────────────────────────────── + // Local recompute == on-chain, against the real Safe + // ───────────────────────────────────────────────────────────────────────── + + function test_DomainSeparator_MatchesOnChain() public view { + assertEq( + SafeTxHash.domainSeparator(block.chainid, address(safe)), + safe.domainSeparator(), + "local domain separator recompute must equal Safe.domainSeparator()" + ); + } + + function test_SafeTxHash_MatchesOnChain_SimpleTransfer() public view { + SafeTxHash.SafeTx memory t = SafeTxHash.SafeTx({ + to: address(0x1111111111111111111111111111111111111111), + value: 1 ether, + data: hex"", + operation: 0, + safeTxGas: 0, + baseGas: 0, + gasPrice: 0, + gasToken: address(0), + refundReceiver: address(0), + nonce: safe.nonce() + }); + assertEq( + SafeTxHash.compute(block.chainid, address(safe), t), + safe.getTransactionHash( + t.to, + t.value, + t.data, + t.operation, + t.safeTxGas, + t.baseGas, + t.gasPrice, + t.gasToken, + t.refundReceiver, + t.nonce + ), + "local safeTxHash recompute must equal Safe.getTransactionHash()" + ); + } + + /// @dev Fuzzed cross-check: for arbitrary to/value/data/gas params and both operations, the local + /// recompute equals the on-chain hash. This is the strongest form of the three-way check the + /// production ceremony applies (recompute == getTransactionHash == safe-hash). + function testFuzz_SafeTxHash_MatchesOnChain( + address to, + uint256 value, + bytes memory data, + bool delegateCall, + uint256 safeTxGas, + uint256 baseGas, + uint256 gasPrice, + address gasToken, + address refundReceiver, + uint256 nonce + ) public view { + SafeTxHash.SafeTx memory t = SafeTxHash.SafeTx({ + to: to, + value: value, + data: data, + operation: delegateCall ? 1 : 0, + safeTxGas: safeTxGas, + baseGas: baseGas, + gasPrice: gasPrice, + gasToken: gasToken, + refundReceiver: refundReceiver, + nonce: nonce + }); + assertEq( + SafeTxHash.compute(block.chainid, address(safe), t), + safe.getTransactionHash( + t.to, + t.value, + t.data, + t.operation, + t.safeTxGas, + t.baseGas, + t.gasPrice, + t.gasToken, + t.refundReceiver, + t.nonce + ), + "fuzzed local safeTxHash recompute must equal Safe.getTransactionHash()" + ); + } + + // ───────────────────────────────────────────────────────────────────────── + // Deploy helper properties + // ───────────────────────────────────────────────────────────────────────── + + function test_DeploySafe_OwnersAndThreshold() public view { + assertEq(safe.getThreshold(), 2, "threshold must be 2"); + address[] memory owners = safe.getOwners(); + assertEq(owners.length, 3, "must have 3 owners"); + assertTrue(safe.isOwner(vm.addr(OWNER1_KEY)), "owner1 missing"); + assertTrue(safe.isOwner(vm.addr(OWNER2_KEY)), "owner2 missing"); + assertTrue(safe.isOwner(vm.addr(OWNER3_KEY)), "owner3 missing"); + } + + /// @dev Idempotence: rerunning the deploy script for the same owners/threshold/salt returns the + /// SAME address without reverting — the property a mirrored multi-chain rollout relies on. + function test_DeploySafe_RerunReturnsSameAddress() public { + address again = new DeploySafe().run(); + assertEq(again, address(safe), "rerun must return the same CREATE2 address"); + } +} From ad42bc4f0992ec7aa027cd680705f25dcff4a015 Mon Sep 17 00:00:00 2001 From: SyedAsadKazmi Date: Tue, 14 Jul 2026 23:41:16 +0530 Subject: [PATCH 2/3] fix 'use of inefficient hashing mechanism; consider using inline assembly' warning --- src/base/SafeTxHash.sol | 45 ++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/base/SafeTxHash.sol b/src/base/SafeTxHash.sol index d4871d8..8c2f8ca 100644 --- a/src/base/SafeTxHash.sol +++ b/src/base/SafeTxHash.sol @@ -37,32 +37,39 @@ library SafeTxHash { } /// @notice The Safe's EIP-712 domain separator, recomputed locally. - function domainSeparator(uint256 chainId, address safe) internal pure returns (bytes32) { - return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, safe)); + function domainSeparator(uint256 chainId, address safe) internal pure returns (bytes32 result) { + bytes memory encoded = abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, safe); + assembly { + result := keccak256(add(encoded, 0x20), mload(encoded)) + } } /// @notice The EIP-712 struct hash of one SafeTx (dynamic `data` hashed per EIP-712). - function structHash(SafeTx memory t) internal pure returns (bytes32) { - return keccak256( - abi.encode( - SAFE_TX_TYPEHASH, - t.to, - t.value, - keccak256(t.data), - t.operation, - t.safeTxGas, - t.baseGas, - t.gasPrice, - t.gasToken, - t.refundReceiver, - t.nonce - ) + function structHash(SafeTx memory t) internal pure returns (bytes32 result) { + bytes memory encoded = abi.encode( + SAFE_TX_TYPEHASH, + t.to, + t.value, + keccak256(t.data), + t.operation, + t.safeTxGas, + t.baseGas, + t.gasPrice, + t.gasToken, + t.refundReceiver, + t.nonce ); + assembly { + result := keccak256(add(encoded, 0x20), mload(encoded)) + } } /// @notice The full `safeTxHash`: `keccak256(0x1901 || domainSeparator || structHash)`. Must equal /// the Safe's on-chain `getTransactionHash` for the same inputs. - function compute(uint256 chainId, address safe, SafeTx memory t) internal pure returns (bytes32) { - return keccak256(abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(chainId, safe), structHash(t))); + function compute(uint256 chainId, address safe, SafeTx memory t) internal pure returns (bytes32 result) { + bytes memory encoded = abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(chainId, safe), structHash(t)); + assembly { + result := keccak256(add(encoded, 0x20), mload(encoded)) + } } } From 123f7149b511b8559c8348fa2ecbd9d0110171d4 Mon Sep 17 00:00:00 2001 From: SyedAsadKazmi Date: Wed, 15 Jul 2026 09:10:32 +0530 Subject: [PATCH 3/3] fix: use executingAccount() instead of broadcaster() in ClaimAdmin and ClaimAndAcceptAdmin --- script/setup/ClaimAdmin.s.sol | 4 ++-- script/setup/ClaimAndAcceptAdmin.s.sol | 9 ++++----- src/base/SafeTxHash.sol | 3 ++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/script/setup/ClaimAdmin.s.sol b/script/setup/ClaimAdmin.s.sol index fdf23a7..55e46a3 100644 --- a/script/setup/ClaimAdmin.s.sol +++ b/script/setup/ClaimAdmin.s.sol @@ -59,8 +59,8 @@ contract ClaimAdmin is EoaExecutor { } } - // Get CCIP admin address from environment variable (defaults to the EOA broadcasting the transaction) - address ccipAdminAddress = vm.envOr("CCIP_ADMIN_ADDRESS", broadcaster()); + // The account that must execute the claim: the Safe in safe mode, the broadcaster otherwise. + address ccipAdminAddress = vm.envOr("CCIP_ADMIN_ADDRESS", executingAccount()); console.log("Claim Admin Parameters:"); console.log(string.concat(" Token: ", vm.toString(tokenAddress))); diff --git a/script/setup/ClaimAndAcceptAdmin.s.sol b/script/setup/ClaimAndAcceptAdmin.s.sol index aafd0fc..9537c03 100644 --- a/script/setup/ClaimAndAcceptAdmin.s.sol +++ b/script/setup/ClaimAndAcceptAdmin.s.sol @@ -21,8 +21,8 @@ import {EoaExecutor} from "../../src/base/EoaExecutor.s.sol"; /// /// Environment Variables: /// TOKEN / _TOKEN (required) the token to register -/// CCIP_ADMIN_ADDRESS (optional) expected current admin; defaults to the script broadcaster. -/// MUST be the Safe address when running with MODE=safe. +/// CCIP_ADMIN_ADDRESS (optional) expected current admin; defaults to the executing account +/// (the Safe in safe mode, the broadcaster otherwise). contract ClaimAndAcceptAdmin is EoaExecutor { HelperConfig public helperConfig; @@ -71,9 +71,8 @@ contract ClaimAndAcceptAdmin is EoaExecutor { } } - // The account that must execute the pair (defaults to the EOA broadcaster; the Safe when - // running in Safe mode). - address ccipAdminAddress = vm.envOr("CCIP_ADMIN_ADDRESS", broadcaster()); + // The account that must execute the pair: the Safe in safe mode, the broadcaster otherwise. + address ccipAdminAddress = vm.envOr("CCIP_ADMIN_ADDRESS", executingAccount()); console.log("Registration Pair Parameters:"); console.log(string.concat(" Token: ", vm.toString(tokenAddress))); diff --git a/src/base/SafeTxHash.sol b/src/base/SafeTxHash.sol index 8c2f8ca..c55989f 100644 --- a/src/base/SafeTxHash.sol +++ b/src/base/SafeTxHash.sol @@ -67,7 +67,8 @@ library SafeTxHash { /// @notice The full `safeTxHash`: `keccak256(0x1901 || domainSeparator || structHash)`. Must equal /// the Safe's on-chain `getTransactionHash` for the same inputs. function compute(uint256 chainId, address safe, SafeTx memory t) internal pure returns (bytes32 result) { - bytes memory encoded = abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(chainId, safe), structHash(t)); + bytes memory encoded = + abi.encodePacked(bytes1(0x19), bytes1(0x01), domainSeparator(chainId, safe), structHash(t)); assembly { result := keccak256(add(encoded, 0x20), mload(encoded)) }