Developing Batch Transaction System
User wants to add liquidity to a Uniswap V3 pool. In reality this is: approve token A → approve token B → mint position. Three transactions, three MetaMask confirmations, three gas payments. If front-run happens between approve and mint — position creates at undesirable price. Batch transactions solve this: one confirmation, one gas, atomic execution.
Two Approaches to Batch: Router and EIP-4337
Router pattern — aggregator contract that accepts array of calls and executes them sequentially. Simplest option — Multicall3 from MakerDAO, deployed to most EVM networks at 0xcA11bde05977b3631167028862bE2a173976CA11.
struct Call3 {
address target;
bool allowFailure;
bytes callData;
}
function aggregate3(Call3[] calldata calls)
external
payable
returns (Result[] memory returnData);
allowFailure: false makes entire batch atomic — if one call reverts, everything rolls back. allowFailure: true allows continuing batch on individual call error — use when partial execution is okay.
Router pattern problem: approve token to router contract address. User must trust router won't steal tokens. For custom routers this creates UX barrier and requires audit.
EIP-4337 (Account Abstraction) — different level. User controls smart contract wallet that can execute multiple calls in one UserOperation. Approve + action are atomic, without intermediate trust in router. Stack: Biconomy, Safe{Core} AA SDK, ZeroDev.
Choice depends on context: for protocol batch — router, for wallet-level automation — EIP-4337.
Gas Savings: Count Honestly
Each transaction in EVM costs minimum 21,000 gas (intrinsic cost). Batch of 5 operations in separate transactions: 5 × 21,000 = 105,000 gas only on intrinsic. Via Multicall3 — once 21,000 + router overhead (~2,000 gas) + gas for each call without intrinsic cost.
| Scenario | Separate transactions | Batch (Multicall3) | Savings |
|---|---|---|---|
| 3 ERC-20 transfer | 3 × 65,000 = 195,000 | ~125,000 | ~36% |
| 5 approve + swap | 5 × 46,000 = 230,000 | ~148,000 | ~36% |
| 10 NFT mint | 10 × 120,000 = 1,200,000 | ~650,000 | ~46% |
Real numbers depend on logic of each call, but 30-50% savings on gas intrinsic cost is conservative estimate.
Custom Batch System: When Multicall3 Isn't Enough
Multicall3 doesn't accept ETH with distribution across calls (only common msg.value). Doesn't support callbacks. Doesn't store state between calls in batch.
For complex scenarios we write custom BatchExecutor:
contract BatchExecutor {
struct BatchCall {
address target;
uint256 value;
bytes data;
bool requireSuccess;
}
function executeBatch(BatchCall[] calldata calls)
external
payable
returns (bytes[] memory results)
{
results = new bytes[](calls.length);
for (uint256 i = 0; i < calls.length; i++) {
(bool success, bytes memory result) = calls[i].target.call{
value: calls[i].value
}(calls[i].data);
if (calls[i].requireSuccess) {
require(success, _getRevertMsg(result));
}
results[i] = result;
}
}
}
Critical security check: delegate target address choice to user? If contract accepts arbitrary target — attacker can call arbitrary contract on behalf of BatchExecutor. If BatchExecutor holds token approvals — this is a drain. Limit target to whitelist or verify contract doesn't hold foreign assets.
Frontend Integration via wagmi/viem
On client side we form call list and encode via viem:
import { encodeFunctionData } from 'viem';
import { multicall3Abi } from './abis';
const calls = [
{
target: tokenAddress,
allowFailure: false,
callData: encodeFunctionData({
abi: erc20Abi,
functionName: 'approve',
args: [spenderAddress, amount]
})
},
{
target: protocolAddress,
allowFailure: false,
callData: encodeFunctionData({
abi: protocolAbi,
functionName: 'deposit',
args: [amount]
})
}
];
await walletClient.writeContract({
address: MULTICALL3_ADDRESS,
abi: multicall3Abi,
functionName: 'aggregate3',
args: [calls]
});
Development timeline: integrating Multicall3 into existing dApp — 1-2 days. Custom BatchExecutor with whitelist logic and tests — 3-5 days.







