Skip to content

Commit 0fdea5d

Browse files
committed
EOA MultiSend
1 parent b02f171 commit 0fdea5d

File tree

10 files changed

+268
-22
lines changed

10 files changed

+268
-22
lines changed

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
[submodule "lib/openzeppelin-contracts"]
55
path = lib/openzeppelin-contracts
66
url = https://github.com/OpenZeppelin/openzeppelin-contracts
7+
[submodule "lib/safe-smart-account"]
8+
path = lib/safe-smart-account
9+
url = https://github.com/safe-global/safe-smart-account

lib/safe-smart-account

Submodule safe-smart-account added at bf943f8

remappings.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ erc4626-tests/=lib/openzeppelin-contracts/lib/erc4626-tests/
33
forge-std/=lib/forge-std/src/
44
halmos-cheatcodes/=lib/openzeppelin-contracts/lib/halmos-cheatcodes/src/
55
openzeppelin-contracts/=lib/openzeppelin-contracts/
6+
safe-smart-account/=lib/safe-smart-account/contracts

script/BatchCallAndSponsor.s.sol

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,7 @@ import {Vm} from "forge-std/Vm.sol";
66
import {BatchCallAndSponsor} from "../src/BatchCallAndSponsor.sol";
77
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
88
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
9-
10-
contract MockERC20 is ERC20 {
11-
constructor() ERC20("Mock Token", "MOCK") {}
12-
13-
function mint(address to, uint256 amount) external {
14-
_mint(to, amount);
15-
}
16-
}
9+
import "test/MockERC20.sol";
1710

1811
contract BatchCallAndSponsorScript is Script {
1912
// Alice's address and private key (EOA with no initial contract code).

script/DeployMultisend.s.sol

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.28;
3+
4+
import "forge-std/Script.sol";
5+
import "forge-std/Vm.sol";
6+
import "src/EOAMultisend.sol";
7+
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
8+
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
9+
10+
contract DeployBatchCaller is Script {
11+
EOAMultisend public multisend;
12+
13+
function run() external {
14+
uint256 deployerPk = vm.envUint("DEPLOYER_KEY");
15+
16+
vm.startBroadcast(deployerPk);
17+
18+
multisend = new EOAMultisend();
19+
20+
vm.stopBroadcast();
21+
}
22+
}

src/BatchCallAndSponsor.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
pragma solidity ^0.8.20;
2+
pragma solidity ^0.8.28;
33

44
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
55
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

src/EOAMultisend.sol

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.28;
3+
4+
import "lib/safe-smart-account/contracts/libraries/MultiSendCallOnly.sol";
5+
import "lib/openzeppelin-contracts/contracts/utils/cryptography/ECDSA.sol";
6+
import "lib/openzeppelin-contracts/contracts/utils/cryptography/MessageHashUtils.sol";
7+
8+
/// @title EOAMultisend
9+
/// @author bh2smith (inheriting from azf20's Batcher)
10+
/// @notice Simple multicall contract for EOAs via EIP-7702
11+
/// @dev WARNING: THIS CONTRACT IS AN EXPERIMENT AND HAS NOT BEEN AUDITED.
12+
contract EOAMultisend is MultiSendCallOnly {
13+
////////////////////////////////////////////////////////////////////////
14+
// Errors
15+
////////////////////////////////////////////////////////////////////////
16+
17+
/// @notice Thrown when a signature is invalid.
18+
error InvalidSignature();
19+
error InvalidAuthority();
20+
21+
////////////////////////////////////////////////////////////////////////
22+
// Functions
23+
////////////////////////////////////////////////////////////////////////
24+
25+
/// @notice Internal nonce used for replay protection.
26+
uint256 public nonce;
27+
28+
/// @notice Executes a set of calls.
29+
/// @param calls - The calls to execute.
30+
function execute(bytes memory calls) public {
31+
if (msg.sender != address(this)) revert InvalidAuthority();
32+
multiSend(calls);
33+
}
34+
35+
/// @notice Executes a set of calls on behalf of the Account, given an EOA signature for authorization.
36+
/// @param calls - The calls to execute.
37+
/// @param signature - The EOA signature over the calls
38+
function execute(bytes memory calls, bytes calldata signature) public {
39+
bytes32 digest = keccak256(abi.encodePacked(block.chainid, nonce++, calls));
40+
41+
bytes32 ethSignedMessageHash = MessageHashUtils.toEthSignedMessageHash(digest);
42+
43+
address signer = ECDSA.recover(ethSignedMessageHash, signature);
44+
45+
if (signer != address(this)) {
46+
revert InvalidSignature();
47+
}
48+
49+
multiSend(calls);
50+
}
51+
52+
fallback() external payable {}
53+
receive() external payable {}
54+
}

test/BatchCallAndSponsor.t.sol

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
11
// SPDX-License-Identifier: MIT
2-
pragma solidity ^0.8.20;
2+
pragma solidity ^0.8.28;
33

44
import {Test, console2} from "forge-std/Test.sol";
5-
import {Vm} from "forge-std/Vm.sol";
6-
import {BatchCallAndSponsor} from "../src/BatchCallAndSponsor.sol";
7-
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
8-
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
9-
10-
contract MockERC20 is ERC20 {
11-
constructor() ERC20("Mock Token", "MOCK") {}
12-
13-
function mint(address to, uint256 amount) external {
14-
_mint(to, amount);
15-
}
16-
}
5+
import "forge-std/Vm.sol";
6+
import "src/BatchCallAndSponsor.sol";
7+
import "test/MockERC20.sol";
8+
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
179

1810
contract BatchCallAndSponsorTest is Test {
1911
// Alice's address and private key (EOA with no initial contract code).

test/EOAMultisend.t.sol

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.28;
3+
4+
import {Test, console2} from "forge-std/Test.sol";
5+
import "forge-std/Vm.sol";
6+
import "src/EOAMultisend.sol";
7+
import "test/MockERC20.sol";
8+
import "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";
9+
10+
contract EOAMultisendTest is Test {
11+
// Alice's address and private key (EOA with no initial contract code).
12+
address payable ALICE_ADDRESS = payable(0x70997970C51812dc3A010C7d01b50e0d17dc79C8);
13+
uint256 constant ALICE_PK = 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d;
14+
15+
// Bob's address and private key (Bob will execute transactions on Alice's behalf).
16+
address constant BOB_ADDRESS = 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC;
17+
uint256 constant BOB_PK = 0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a;
18+
19+
// The contract that Alice will delegate execution to.
20+
EOAMultisend public implementation;
21+
22+
// ERC-20 token contract for minting test tokens.
23+
MockERC20 public token;
24+
25+
function setUp() public {
26+
// Deploy the delegation contract (Alice will delegate calls to this contract).
27+
implementation = new EOAMultisend();
28+
29+
// Deploy an ERC-20 token contract where Alice is the minter.
30+
token = new MockERC20();
31+
32+
// Fund accounts
33+
vm.deal(ALICE_ADDRESS, 10 ether);
34+
token.mint(ALICE_ADDRESS, 1000e18);
35+
}
36+
37+
function testDirectExecution() public {
38+
console2.log("Sending 1 ETH from Alice to Bob and transferring 100 tokens to Bob in a single transaction");
39+
40+
// Encode the ETH transfer call
41+
bytes memory ethTransferData = "";
42+
bytes memory ethTransferEncoded = abi.encodePacked(
43+
uint8(0), // operation (0 for call)
44+
BOB_ADDRESS, // to
45+
uint256(1 ether), // value
46+
uint256(0), // data length
47+
ethTransferData // data
48+
);
49+
50+
// Encode the token transfer call
51+
bytes memory tokenTransferData = abi.encodeCall(ERC20.transfer, (BOB_ADDRESS, 100e18));
52+
bytes memory tokenTransferEncoded = abi.encodePacked(
53+
uint8(0), // operation (0 for call)
54+
address(token), // to
55+
uint256(0), // value
56+
uint256(tokenTransferData.length), // data length
57+
tokenTransferData // data
58+
);
59+
60+
// Combine both encoded calls
61+
bytes memory encodedCalls = abi.encodePacked(ethTransferEncoded, tokenTransferEncoded);
62+
63+
vm.signAndAttachDelegation(address(implementation), ALICE_PK);
64+
65+
vm.startPrank(ALICE_ADDRESS);
66+
EOAMultisend(ALICE_ADDRESS).execute(encodedCalls);
67+
vm.stopPrank();
68+
69+
assertEq(BOB_ADDRESS.balance, 1 ether);
70+
assertEq(token.balanceOf(BOB_ADDRESS), 100e18);
71+
}
72+
73+
function testSponsoredExecution() public {
74+
console2.log("Sending 1 ETH from Alice to a random address while the transaction is sponsored by Bob");
75+
address recipient = makeAddr("recipient");
76+
bytes memory encodedCalls = abi.encodePacked(
77+
uint8(0), // operation (0 for call)
78+
recipient, // to
79+
uint256(1 ether), // value
80+
uint256(0), // data length
81+
"" // data
82+
);
83+
84+
// Alice signs a delegation allowing `implementation` to execute transactions on her behalf.
85+
Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), ALICE_PK);
86+
87+
// Bob attaches the signed delegation from Alice and broadcasts it.
88+
vm.startBroadcast(BOB_PK);
89+
vm.attachDelegation(signedDelegation);
90+
91+
// Verify that Alice's account now temporarily behaves as a smart contract.
92+
bytes memory code = address(ALICE_ADDRESS).code;
93+
require(code.length > 0, "no code written to Alice");
94+
95+
bytes32 digest = keccak256(abi.encodePacked(block.chainid, EOAMultisend(ALICE_ADDRESS).nonce(), encodedCalls));
96+
97+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, MessageHashUtils.toEthSignedMessageHash(digest));
98+
bytes memory signature = abi.encodePacked(r, s, v);
99+
100+
// As Bob, execute the transaction via Alice's temporarily assigned contract.
101+
EOAMultisend(ALICE_ADDRESS).execute(encodedCalls, signature);
102+
103+
vm.stopBroadcast();
104+
105+
assertEq(recipient.balance, 1 ether);
106+
}
107+
108+
function testWrongSignature() public {
109+
console2.log("Test wrong signature: Execution should revert with 'Invalid signature'.");
110+
111+
bytes memory data = abi.encodeCall(MockERC20.mint, (BOB_ADDRESS, 50));
112+
bytes memory encodedCalls = abi.encodePacked(
113+
uint8(0), // operation (0 for call)
114+
address(token), // to
115+
uint256(0), // value
116+
uint256(data.length), // data length
117+
data // data
118+
);
119+
120+
// Alice signs a delegation allowing `implementation` to execute transactions on her behalf.
121+
Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), ALICE_PK);
122+
123+
// Bob attaches the signed delegation from Alice and broadcasts it.
124+
vm.startBroadcast(BOB_PK);
125+
vm.attachDelegation(signedDelegation);
126+
127+
bytes32 digest = keccak256(abi.encodePacked(EOAMultisend(ALICE_ADDRESS).nonce(), encodedCalls));
128+
// Sign with the wrong key (Bob's instead of Alice's).
129+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(BOB_PK, MessageHashUtils.toEthSignedMessageHash(digest));
130+
bytes memory signature = abi.encodePacked(r, s, v);
131+
132+
vm.expectRevert(EOAMultisend.InvalidSignature.selector);
133+
EOAMultisend(ALICE_ADDRESS).execute(encodedCalls, signature);
134+
vm.stopBroadcast();
135+
}
136+
137+
function testReplayAttack() public {
138+
console2.log("Test replay attack: Reusing the same signature should revert.");
139+
140+
bytes memory encodedCalls = abi.encodePacked(
141+
uint8(0), // operation (0 for call)
142+
makeAddr("recipient"), // to
143+
uint256(1 ether), // value
144+
uint256(0), // data length
145+
"" // data
146+
);
147+
148+
// Alice signs a delegation allowing `implementation` to execute transactions on her behalf.
149+
Vm.SignedDelegation memory signedDelegation = vm.signDelegation(address(implementation), ALICE_PK);
150+
151+
// Bob attaches the signed delegation from Alice and broadcasts it.
152+
vm.startBroadcast(BOB_PK);
153+
vm.attachDelegation(signedDelegation);
154+
155+
uint256 nonceBefore = EOAMultisend(ALICE_ADDRESS).nonce();
156+
bytes32 digest = keccak256(abi.encodePacked(block.chainid, nonceBefore, encodedCalls));
157+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ALICE_PK, MessageHashUtils.toEthSignedMessageHash(digest));
158+
bytes memory signature = abi.encodePacked(r, s, v);
159+
160+
// First execution: should succeed.
161+
EOAMultisend(ALICE_ADDRESS).execute(encodedCalls, signature);
162+
vm.stopBroadcast();
163+
164+
// Attempt a replay: reusing the same signature should revert because nonce has incremented.
165+
vm.expectRevert(EOAMultisend.InvalidSignature.selector);
166+
EOAMultisend(ALICE_ADDRESS).execute(encodedCalls, signature);
167+
}
168+
}

test/MockERC20.sol

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.28;
3+
4+
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
5+
6+
contract MockERC20 is ERC20 {
7+
constructor() ERC20("Mock Token", "MOCK") {}
8+
9+
function mint(address to, uint256 amount) external {
10+
_mint(to, amount);
11+
}
12+
}

0 commit comments

Comments
 (0)