From 048331c65cbb8f8c23a58683b33d2172259d2d6a Mon Sep 17 00:00:00 2001 From: aelmanaa Date: Sat, 31 Oct 2026 15:50:41 +0000 Subject: [PATCH] feat: fall back to canonical MultiSendCallOnly on unserved chains getMultiSendCallOnly reverted with MultiSendCallOnlyNotFound on every chain the Safe transaction service does not serve, so batches were impossible there even when the canonical MultiSendCallOnly is deployed. - On the current chain, fall back to the canonical v1.4.1 address, then v1.3.0, if it has code. Served chains resolve as before. - Add Client.withMultiSendCallOnly(address) to set an explicit address, which takes precedence; address(0) clears it. - getMultiSendCallOnly is now view instead of pure. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++ src/Safe.sol | 33 +++++++++++++++- test/Safe.t.sol | 83 +++++++++++++++++++++++++++++++++++++++ test/SafeSimulation.t.sol | 16 ++++++++ 4 files changed, 136 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 999c442..d70226f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,12 @@ safe.proposeTransactionsWithSignature(targets, datas, sender, signature); **⚠️ Important**: Batch transactions require `Enum.Operation.DelegateCall` (not `Call`). Using `Call` causes signature validation errors. +Batches delegatecall `MultiSendCallOnly`. On a chain the Safe transaction service does not serve, the library uses the canonical v1.4.1 deployment, then v1.3.0, whichever has code on the current chain. For a deployment at another address, set it on the client: + +```solidity +safe.withMultiSendCallOnly(0x...); +``` + #### Simulation (no hardware wallet required) Simulate transactions against a local fork before broadcasting. No signing device is needed — the library writes directly to the Safe's `approvedHashes` storage slot. diff --git a/src/Safe.sol b/src/Safe.sol index 45cd152..035f72d 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -48,6 +48,8 @@ library Safe { struct Client { Instance[] instances; + // Set with {withMultiSendCallOnly}; address(0) means resolve it from the chain id. + address multiSendCallOnly; } struct ExecTransactionParams { @@ -89,8 +91,25 @@ library Safe { return string.concat(SAFE_TRANSACTION_SERVICE_BASE_URL, "/", getNetworkShortName(chainId), "/api"); } - // Keep the first Client parameter so existing `using Safe for *` call sites remain unchanged. - function getMultiSendCallOnly(Client storage, uint256 chainId) internal pure returns (MultiSendCallOnly) { + /// @notice Use `multiSendCallOnly` for batches instead of the address resolved from the chain id + /// @dev For chains whose MultiSendCallOnly is not at a canonical address. address(0) clears the override. + /// + /// @param self The Safe client + /// @param multiSendCallOnly The MultiSendCallOnly contract to delegatecall for batches + /// @return self The Safe client + function withMultiSendCallOnly(Client storage self, address multiSendCallOnly) internal returns (Client storage) { + self.multiSendCallOnly = multiSendCallOnly; + return self; + } + + /// @notice Return the MultiSendCallOnly contract used for batches on `chainId` + /// @dev In order: the client override set with {withMultiSendCallOnly}; the deployment safe-deployments + /// lists for a chain the transaction service serves; and, for the current chain only, the canonical + /// v1.4.1 then v1.3.0 address if it has code. Reverts with MultiSendCallOnlyNotFound otherwise. + function getMultiSendCallOnly(Client storage self, uint256 chainId) internal view returns (MultiSendCallOnly) { + if (self.multiSendCallOnly != address(0)) { + return MultiSendCallOnly(self.multiSendCallOnly); + } if (chainId == 324) { return MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_V130_ZKSYNC); } @@ -104,6 +123,16 @@ library Safe { if (_usesV141CanonicalMultiSend(chainId)) { return MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL); } + // Chains without a transaction service often still have the canonical deployments from the singleton + // factory. Code can only be checked on the current chain. + if (chainId == block.chainid) { + if (MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL.code.length > 0) { + return MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL); + } + if (MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL.code.length > 0) { + return MultiSendCallOnly(MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL); + } + } revert MultiSendCallOnlyNotFound(chainId); } diff --git a/test/Safe.t.sol b/test/Safe.t.sol index 159088b..9fc4474 100644 --- a/test/Safe.t.sol +++ b/test/Safe.t.sol @@ -126,3 +126,86 @@ contract SafeConfigTest is Test { return address(safe.getMultiSendCallOnly(chainId)); } } + +contract SafeMultiSendFallbackTest is Test { + using Safe for *; + + address constant MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL = 0x40A2aCCbd92BCA938b02010E17A5b8929b49130D; + address constant MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL = 0x9641d764fc13c8B624c04430C7356C1C7C8102e2; + // Ink Sepolia: the official transaction service does not serve it. + uint256 constant UNSERVED_CHAIN_ID = 763373; + + Safe.Client safe; + bytes v130Code; + bytes v141Code; + + function setUp() public { + vm.createSelectFork("https://mainnet.base.org"); + v130Code = MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL.code; + v141Code = MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL.code; + assertGt(v130Code.length, 0); + assertGt(v141Code.length, 0); + // Start every test with no MultiSendCallOnly code; each test etches the deployments it needs. + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL, ""); + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL, ""); + vm.chainId(UNSERVED_CHAIN_ID); + safe.initialize(address(0xBEEF)); + } + + function test_Safe_getMultiSendCallOnly_unservedChainFallsBackToV141() public { + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL, v141Code); + assertEq(address(safe.getMultiSendCallOnly(UNSERVED_CHAIN_ID)), MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL); + } + + function test_Safe_getMultiSendCallOnly_unservedChainFallsBackToV130() public { + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL, v130Code); + assertEq(address(safe.getMultiSendCallOnly(UNSERVED_CHAIN_ID)), MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL); + } + + function test_Safe_getMultiSendCallOnly_unservedChainPrefersV141() public { + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL, v130Code); + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL, v141Code); + assertEq(address(safe.getMultiSendCallOnly(UNSERVED_CHAIN_ID)), MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL); + } + + function test_Safe_getMultiSendCallOnly_unservedChainRevertsWithoutCode() public { + vm.expectRevert(abi.encodeWithSelector(Safe.MultiSendCallOnlyNotFound.selector, UNSERVED_CHAIN_ID)); + this.exposedGetMultiSendCallOnly(UNSERVED_CHAIN_ID); + } + + function test_Safe_getMultiSendCallOnly_fallbackOnlyChecksCurrentChain() public { + // Code on the current chain says nothing about another chain. + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL, v141Code); + vm.expectRevert(abi.encodeWithSelector(Safe.MultiSendCallOnlyNotFound.selector, 31337)); + this.exposedGetMultiSendCallOnly(31337); + } + + function test_Safe_getMultiSendCallOnly_servedChainIgnoresFallback() public { + // Base (8453) uses v1.3.0 even when only v1.4.1 has code. + vm.chainId(8453); + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL, v141Code); + assertEq(address(safe.getMultiSendCallOnly(8453)), MULTI_SEND_CALL_ONLY_ADDRESS_V130_CANONICAL); + } + + function test_Safe_withMultiSendCallOnly_overridesResolution() public { + address custom = address(0xCAFE); + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL, v141Code); + safe.withMultiSendCallOnly(custom); + + assertEq(address(safe.getMultiSendCallOnly(UNSERVED_CHAIN_ID)), custom); + assertEq(address(safe.getMultiSendCallOnly(8453)), custom); + + (address to,) = safe.getProposeTransactionsTargetAndData(new address[](0), new bytes[](0)); + assertEq(to, custom); + } + + function test_Safe_withMultiSendCallOnly_zeroAddressClearsOverride() public { + vm.etch(MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL, v141Code); + safe.withMultiSendCallOnly(address(0xCAFE)).withMultiSendCallOnly(address(0)); + assertEq(address(safe.getMultiSendCallOnly(UNSERVED_CHAIN_ID)), MULTI_SEND_CALL_ONLY_ADDRESS_V141_CANONICAL); + } + + function exposedGetMultiSendCallOnly(uint256 chainId) external view returns (address) { + return address(safe.getMultiSendCallOnly(chainId)); + } +} diff --git a/test/SafeSimulation.t.sol b/test/SafeSimulation.t.sol index 0923a44..84a3885 100644 --- a/test/SafeSimulation.t.sol +++ b/test/SafeSimulation.t.sol @@ -109,6 +109,22 @@ contract SafeSimulationTest is Test { assertFalse(ok); } + function test_Safe_simulateTransactionsNoSign_unservedChainUsesCanonicalMultiSend() public { + // Ink Sepolia (763373) has no official transaction service. The Base fork has the canonical + // MultiSendCallOnly deployments, as such chains often do. + vm.chainId(763373); + + address[] memory targets = new address[](2); + bytes[] memory datas = new bytes[](2); + targets[0] = WETH; + datas[0] = abi.encodeCall(IWETH.withdraw, (0)); + targets[1] = WETH; + datas[1] = abi.encodeCall(IWETH.withdraw, (0)); + + bool ok = safe.simulateTransactionsNoSign(targets, datas, SIGNER_1); + assertTrue(ok); + } + // ------------------------------------------------------------------------- // Multi-sig simulation // -------------------------------------------------------------------------