Skip to content

feat: Safe multisig execution mode, plus on-chain chain/pool removal#7

Open
aelmanaa wants to merge 1 commit into
mainfrom
feat/safe-multisig-execution-mode
Open

feat: Safe multisig execution mode, plus on-chain chain/pool removal#7
aelmanaa wants to merge 1 commit into
mainfrom
feat/safe-multisig-execution-mode

Conversation

@aelmanaa

@aelmanaa aelmanaa commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What

Adds a Safe multisig execution mode on top of the shared action layer, including composition of
multiple operations into one Safe transaction. The MODE environment variable selects the executor
for the Call[] every write script already builds:

  • MODE unset (or eoa): today's behavior, unchanged: same commands, same flags, same broadcast
    path, same output. No existing README command changes and no new required env var.
  • MODE=safe: the identical Call[] is emitted as a Safe Transaction Builder JSON batch under
    batches/ for review and signing (importable in the Safe{Wallet} UI, proposable via safe-cli).
    SAFE_EXEC=direct additionally executes it via execTransaction with raw ECDSA owner
    signatures; this works on any EVM chain, with no dependency on the hosted Safe Transaction
    Service.
  • Batch composition: independently emitted batch files compose into one atomic Safe transaction,
    with one review artifact, one safeTxHash, one signing round, one nonce:
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 $RPC

Why

A production issuer cannot run privileged operations from a single EOA. Because every operation is
already a pure Call[] builder (CctActions), Safe support is an executor concern only: no
operation is re-implemented, and byte-equality tests prove the Safe batch carries exactly the
calldata the EOA path broadcasts, so reviewing one mode reviews them all.

How

  • src/base/EoaExecutor.s.sol: executeCalls dispatches on MODE (default eoa, exact previous
    body); executingAccount() resolves the account acting on-chain (the Safe in safe mode) so
    authority preflights work in both modes. All write scripts inherit the switch with zero
    per-script changes.
  • src/base/SafeMode.sol: the Safe executor: review logging, batch emission, and the direct
    execTransaction path (nonce read, EIP-712 hash recompute cross-checked against the Safe's
    getTransactionHash, signatures packed sorted ascending by signer address).
  • src/base/SafeTxHash.sol: independent EIP-712 safeTxHash recompute. The SafeTx type string
    ends in uint256 nonce; encoding the Solidity parameter name _nonce instead yields a hash that
    never matches signer devices; a regression test pins this.
  • src/base/SafeBatchEmitter.sol / src/base/SafeBatchLoader.sol: canonical Transaction Builder
    JSON out and back in (the loader validates chain id, Safe address, and non-emptiness; loadMany
    concatenates files in execution order).
  • script/governance/ExecuteBatch.s.sol: composes emitted batches (BATCH_FILES, required
    BATCH_NAME) and hands the merged batch to the standard Safe executor.
  • script/governance/DeploySafe.s.sol: canonical v1.4.1 CREATE2 deploy; predicts the address,
    asserts the deployment lands on it, idempotent: the same owners/threshold/salt reproduce the
    same Safe address on every chain.
  • script/setup/ClaimAndAcceptAdmin.s.sol: the claim+accept registration pair as one atomic
    operation, so registration can ride in a deferred batch.
  • docs/governance-modes.md: the architecture doc (mode switch, both Safe paths, the
    review-before-submit gate, safeTxHash mechanics, independent signature verification, batching
    recipe and composition rules, Transaction Service support matrix).

On-chain chain/pool removal

Also adds on-chain lane teardown alongside the Safe work:

  • script/configure/remote-chains/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+ uses the modern toRemove[] shape, with an isSupportedChain pre-check and a version-safe read before dispatch.
  • script/configure/remote-pools/RemoveRemotePool.s.sol: 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.

Tests

test/governance/ (Sepolia fork, real Safe deployed from the canonical stack in setUp):

  • Byte-equality over the full action-layer catalog: batch JSON, MultiSend payload, and merged
    compositions all round-trip to the identical to/value/data the EOA mode broadcasts.
  • Direct execTransaction path end-to-end: ownership handed to the Safe (accept executed through
    the Safe), then applyChainUpdates executed by the Safe lands the identical end state as the
    EOA path; a full CCT setup (accept ownership + registration pair + setPool + applyChainUpdates)
    runs as one execTransaction consuming a single nonce.
  • Atomicity: a mis-ordered batch reverts as a whole (GS013) with zero partial state; unsorted
    signature packs are rejected (GS026).
  • Fuzzed safeTxHash recompute == on-chain getTransactionHash; the _nonce typehash regression;
    composition guards (chain-id mismatch, foreign Safe, empty batch).
  • Gas: one merged Safe transaction costs less than the same calls as separate Safe transactions
    (about 20% on a five-operation setup from live receipts, roughly 685k versus 545k).
  • Safe v1.5.0 forward compatibility: a Safe deployed from the canonical v1.5.0 stack passes the
    fuzzed hash cross-check and the full flow (single + batched). The default stays v1.4.1: it is
    what the Safe{Wallet} UI deploys, and only the 1.4.1 stack currently has the complete canonical
    rollout for cross-chain address mirroring.

Full suite: 74 passed / 0 failed (all 48 pre-existing tests untouched). forge fmt --check and
forge lint clean.

Transaction Service probe (2026-07-10): Sepolia sep 200, Base Sepolia basesep 200, Mantle
Sepolia mnt-sep 200 (newly served), Avalanche Fuji / Ink Sepolia / 0G Galileo / Plume testnet 404.
A 404 gates only the Transaction Service path; the direct path works everywhere.

Live testnet validation

Sepolia + Avalanche Fuji, existing 2-of-3 Safe 0xf55FEba2D9c767Bf8018362d9eE4F378dcEB727E. All
acceptance items passed against throwaway, explorer-verified fixtures on both chains (Sepolia:
token 0x48d94D0a2d6182fEA98143d588cd79A019bF11B3, pool
0x03A2e316D205c90fe412523c4F8B3Cc6DD12D6c5; Fuji: token
0x3CDff159eb74a9c30FE44a8813255228000e283e, pool 0x6d9300Fa54e023Ee04F7a4680A385396d4A9Ac0f):

  • EOA non-regression: every script group ran with MODE unset (dry run, then broadcast); zero new
    env vars, behavior unchanged.
  • Direct execTransaction path on both chains: accept-ownership and rate-limit changes; final
    state equals the EOA-applied state exactly; the submitter was a non-owner. On Fuji the
    Transaction Service returns 404, so the direct path is the only Safe path there, validating it
    as the universal fallback.
  • safeTxHash three ways: script recompute == getTransactionHash == independent tool, on both
    chains (Sepolia nonce 23
    0xce4568d561eea90aef5b1baa9a5c0f63df882f811d5b3d8183b4b9f2ca756ed9; Fuji nonce 25
    0x583a8f2519a7e92ba76d283b566f8f733b2712570b33d653e23b616002a803ec).
  • Review gate: the emitted batch decoded and matched an independently encoded cast calldata
    expectation byte-for-byte before anything was submitted.
  • Transaction Service path on Sepolia: proposed and co-signed to 2/2 through safe-cli 1.9.0,
    visible in the Safe{Wallet} queue, executed at nonce 24
    (exec tx);
    the tx-service proposal data and the MODE=safe batch data are byte-identical for the same
    operation. (safe-cli 1.9.0's execute underprices EIP-1559 gas on Sepolia; the permissionless
    fallback is documented in docs/governance-modes.md.)
  • Full role handoff: pool owner, registry administrator, getCCIPAdmin, defaultAdmin,
    rateLimitAdmin, feeAdmin all moved to the Safe on both chains; a deployer-EOA attempt at a
    gated operation reverts Unauthorized; live CCIP token transfers ran in both directions through
    the Safe-configured lane afterwards
    (0xe7fc694072e9a9a950a9db0556bfe82b0b173ecd3707893bd9a3af1388e140dd,
    0xc8c883117b44bf2dfafa27c1d20bb9589578368d036b0b73c5f73d5bcd9ac385).

@aelmanaa aelmanaa requested review from a team as code owners July 10, 2026 12:05
@aelmanaa aelmanaa requested a review from SyedAsadKazmi July 10, 2026 12:05
@github-actions

Copy link
Copy Markdown

👋 aelmanaa, thanks for creating this pull request!

To help reviewers, please consider creating future PRs as drafts first. This allows you to self-review and make any final changes before notifying the team.

Once you're ready, you can mark it as "Ready for review" to request feedback. Thanks!

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.
@aelmanaa aelmanaa force-pushed the feat/safe-multisig-execution-mode branch from 0048fd8 to 2b93f55 Compare July 13, 2026 16:44
@aelmanaa aelmanaa changed the title feat: add Safe multisig execution mode on the shared action layer feat: Safe multisig execution mode, plus on-chain chain/pool removal Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant