Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
33 changes: 31 additions & 2 deletions src/Safe.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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);
}

Expand Down
83 changes: 83 additions & 0 deletions test/Safe.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
16 changes: 16 additions & 0 deletions test/SafeSimulation.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
// -------------------------------------------------------------------------
Expand Down
Loading