diff --git a/.gitmodules b/.gitmodules index 690924b..e7301a8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/safe-smart-account"] + path = lib/safe-smart-account + url = https://github.com/safe-global/safe-smart-account diff --git a/lib/safe-smart-account b/lib/safe-smart-account new file mode 160000 index 0000000..bf943f8 --- /dev/null +++ b/lib/safe-smart-account @@ -0,0 +1 @@ +Subproject commit bf943f80fec5ac647159d26161446ac5d716a294 diff --git a/remappings.txt b/remappings.txt index 918ed31..8c6d1b2 100644 --- a/remappings.txt +++ b/remappings.txt @@ -3,3 +3,4 @@ erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/ forge-std/=lib/forge-std/src/ halmos-cheatcodes/=lib/openzeppelin-contracts/lib/halmos-cheatcodes/src/ openzeppelin-contracts/=lib/openzeppelin-contracts/ +safe-smart-account/=lib/safe-smart-account/contracts diff --git a/script/DeployMultisend.s.sol b/script/DeployMultisend.s.sol new file mode 100644 index 0000000..9f78e33 --- /dev/null +++ b/script/DeployMultisend.s.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "forge-std/Script.sol"; +import "forge-std/Vm.sol"; +import "src/EOAMultisend.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract DeployBatchCaller is Script { + EOAMultisend public multisend; + + function run() external { + uint256 deployerPk = vm.envUint("DEPLOYER_KEY"); + + vm.startBroadcast(deployerPk); + + multisend = new EOAMultisend(); + + vm.stopBroadcast(); + } +} diff --git a/src/EOAMultisend.sol b/src/EOAMultisend.sol new file mode 100644 index 0000000..0a2675c --- /dev/null +++ b/src/EOAMultisend.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import "lib/safe-smart-account/contracts/libraries/MultiSendCallOnly.sol"; +import "lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol"; +import "lib/openzeppelin-contracts/contracts/utils/cryptography/MessageHashUtils.sol"; + +/// @title EOAMultisend +/// @author bh2smith (inheriting from azf20's Batcher) +/// @notice Simple multicall contract for EOAs via EIP-7702 +/// @dev WARNING: THIS CONTRACT IS AN EXPERIMENT AND HAS NOT BEEN AUDITED. +contract EOAMultisend is MultiSendCallOnly { + //////////////////////////////////////////////////////////////////////// + // Errors + //////////////////////////////////////////////////////////////////////// + + /// @notice Thrown when a signature is invalid. + error InvalidSignature(); + error InvalidAuthority(); + + //////////////////////////////////////////////////////////////////////// + // Functions + //////////////////////////////////////////////////////////////////////// + + /// @notice Internal nonce used for replay protection. + uint256 public nonce; + + /// @notice Executes a set of calls. + /// @param calls - The calls to execute. + function execute(bytes memory calls) public { + if (msg.sender != address(this)) revert InvalidAuthority(); + multiSend(calls); + } + + /// @notice Executes a set of calls on behalf of the Account, given an EOA signature for authorization. + /// @param calls - The calls to execute. + /// @param signature - The EOA signature over the calls + function execute(bytes memory calls, bytes calldata signature) public { + bytes32 digest = keccak256(abi.encodePacked(block.chainid, nonce++, calls)); + + bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(digest); + + address signer = ECDSA.recover(ethSignedMessageHash, signature); + + if (signer != address(this)) { + revert InvalidSignature(); + } + + multiSend(calls); + } + + fallback() external payable {} + receive() external payable {} +} diff --git a/test/EOAMultisend.t.sol b/test/EOAMultisend.t.sol new file mode 100644 index 0000000..5b102ea --- /dev/null +++ b/test/EOAMultisend.t.sol @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Test, console2} from "forge-std/Test.sol"; +import "forge-std/Vm.sol"; +import "lib/openzeppelin-contracts/contracts/utils/cryptography/MessageHashUtils.sol"; +import "src/EOAMultisend.sol"; +import "test/MockERC20.sol"; + +contract EOAMultisendTest is Test { + // Alice's address and private key (EOA with no initial contract code). + address payable ALICE_ADDRESS = payable(0x70997970C51812dc3A010C7d01b50e0d17dc79C8); + uint256 constant ALICE_PK = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d; + + // Bob's address and private key (Bob will execute transactions on Alice's behalf). + address constant BOB_ADDRESS = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC; + uint256 constant BOB_PK = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a; + + // The contract that Alice will delegate execution to. + EOAMultisend public implementation; + + // ERC-20 token contract for minting test tokens. + MockERC20 public token; + + function setUp() public { + // Deploy the delegation contract (Alice will delegate calls to this contract). + implementation = new EOAMultisend(); + + // Deploy an ERC-20 token contract where Alice is the minter. + token = new MockERC20(); + + // Fund accounts + vm.deal(ALICE_ADDRESS, 10 ether); + token.mint(ALICE_ADDRESS, 1000e18); + } + + function testDirectExecution() public { + console2.log("Sending 1 ETH from Alice to Bob and transferring 100 tokens to Bob in a single transaction"); + + // Encode the ETH transfer call + bytes memory ethTransferData = ""; + bytes memory ethTransferEncoded = abi.encodePacked( + uint8(0), // operation (0 for call) + BOB_ADDRESS, // to + uint256(1 ether), // value + uint256(0), // data length + ethTransferData // data + ); + + // Encode the token transfer call + bytes memory tokenTransferData = abi.encodeCall(ERC20.transfer, (BOB_ADDRESS, 100e18)); + bytes memory tokenTransferEncoded = abi.encodePacked( + uint8(0), // operation (0 for call) + address(token), // to + uint256(0), // value + uint256(tokenTransferData.length), // data length + tokenTransferData // data + ); + + // Combine both encoded calls + bytes memory encodedCalls = abi.encodePacked(ethTransferEncoded, tokenTransferEncoded); + + vm.signAndAttachDelegation(address(implementation), ALICE_PK); + + vm.startPrank(ALICE_ADDRESS); + EOAMultisend(ALICE_ADDRESS).execute(encodedCalls); + vm.stopPrank(); + + assertEq(BOB_ADDRESS.balance, 1 ether); + assertEq(token.balanceOf(BOB_ADDRESS), 100e18); + } + + function testSponsoredExecution() public { + console2.log("Sending 1 ETH from Alice to a random address while the transaction is sponsored by Bob"); + address recipient = makeAddr("recipient"); + bytes memory encodedCalls = abi.encodePacked( + uint8(0), // operation (0 for call) + recipient, // to + uint256(1 ether), // value + uint256(0), // data length + "" // data + ); + + // Alice signs a delegation allowing `implementation` to execute transactions on her behalf. + Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), ALICE_PK); + + // Bob attaches the signed delegation from Alice and broadcasts it. + vm.startBroadcast(BOB_PK); + vm.attachDelegation(signedDelegation); + + // Verify that Alice's account now temporarily behaves as a smart contract. + bytes memory code = address(ALICE_ADDRESS).code; + require(code.length > 0, "no code written to Alice"); + + bytes32 digest = keccak256(abi.encodePacked(block.chainid, EOAMultisend(ALICE_ADDRESS).nonce(), encodedCalls)); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, MessageHashUtils.toEthSignedMessageHash(digest)); + bytes memory signature = abi.encodePacked(r, s, v); + + // As Bob, execute the transaction via Alice's temporarily assigned contract. + EOAMultisend(ALICE_ADDRESS).execute(encodedCalls, signature); + + vm.stopBroadcast(); + + assertEq(recipient.balance, 1 ether); + } + + function testWrongSignature() public { + console2.log("Test wrong signature: Execution should revert with 'Invalid signature'."); + + bytes memory data = abi.encodeCall(MockERC20.mint, (BOB_ADDRESS, 50)); + bytes memory encodedCalls = abi.encodePacked( + uint8(0), // operation (0 for call) + address(token), // to + uint256(0), // value + uint256(data.length), // data length + data // data + ); + + // Alice signs a delegation allowing `implementation` to execute transactions on her behalf. + Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), ALICE_PK); + + // Bob attaches the signed delegation from Alice and broadcasts it. + vm.startBroadcast(BOB_PK); + vm.attachDelegation(signedDelegation); + + bytes32 digest = keccak256(abi.encodePacked(EOAMultisend(ALICE_ADDRESS).nonce(), encodedCalls)); + // Sign with the wrong key (Bob's instead of Alice's). + (uint8 v, bytes32 r, bytes32 s) = vm.sign(BOB_PK, MessageHashUtils.toEthSignedMessageHash(digest)); + bytes memory signature = abi.encodePacked(r, s, v); + + vm.expectRevert(EOAMultisend.InvalidSignature.selector); + EOAMultisend(ALICE_ADDRESS).execute(encodedCalls, signature); + vm.stopBroadcast(); + } + + function testReplayAttack() public { + console2.log("Test replay attack: Reusing the same signature should revert."); + + bytes memory encodedCalls = abi.encodePacked( + uint8(0), // operation (0 for call) + makeAddr("recipient"), // to + uint256(1 ether), // value + uint256(0), // data length + "" // data + ); + + // Alice signs a delegation allowing `implementation` to execute transactions on her behalf. + Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), ALICE_PK); + + // Bob attaches the signed delegation from Alice and broadcasts it. + vm.startBroadcast(BOB_PK); + vm.attachDelegation(signedDelegation); + + uint256 nonceBefore = EOAMultisend(ALICE_ADDRESS).nonce(); + bytes32 digest = keccak256(abi.encodePacked(block.chainid, nonceBefore, encodedCalls)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, MessageHashUtils.toEthSignedMessageHash(digest)); + bytes memory signature = abi.encodePacked(r, s, v); + + // First execution: should succeed. + EOAMultisend(ALICE_ADDRESS).execute(encodedCalls, signature); + vm.stopBroadcast(); + + // Attempt a replay: reusing the same signature should revert because nonce has incremented. + vm.expectRevert(EOAMultisend.InvalidSignature.selector); + EOAMultisend(ALICE_ADDRESS).execute(encodedCalls, signature); + } +}