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 // -------------------------------------------------------------------------