Few things are more romantic than optimizing for gas costs. OK, it’s about as romantic as getting your teeth cleaned. Still, getting your teeth cleaned is important, and so is optimizing for gas costs and doing preliminary audits. Using the code from my prior article on zk-SNARK in an ETH tumbler, here’s what I improved:
The prior contract, while a functional zk-SNARK mixer, squandered gas on redundant storage writes, oversized types, and verbose events. We’ve optimized with surgical precision:
- Immutable zkVerifier: Declaring zkVerifier as immutable eliminates SSTORE ops post-deployment (20,000 gas for a new slot, 5,000 for updates). Previously mutable, each reassignment — if it occurred — bled gas. Now, it’s fixed at construction, saving ~5,000 gas per avoided write. For zk-SNARK users, this ensures the verifier’s address, critical for proof validation, is tamper-proof without bloating execution cost.
- Type Compression: Swapped uint256 for uint32 in participantCount, minParticipants, depositBlock, and delayBlocks. A uint256 (32 bytes) costs 20,000 gas per slot, while uint32 (4 bytes) packs tighter when bundled in structs. Ethereum’s block number (~15s/block) won’t breach 2³² (4.29B) for ~136 years, and MAX_DELAY_BLOCKS (100) fits trivially. This slashes storage costs by ~75% per variable, assuming tight packing by the EVM’s storage layout.
- Event Streamlining: Pruned Deposited and Withdrawn events. Previously, Deposited(address indexed sender, uint256 amount) and Withdrawn(address indexed recipient, uint256 amount) logged indexed topics (375 gas each) and data (8 gas/byte). Now, Deposited(bytes32 commitment) and Withdrawn(address recipient) drop indexing and extraneous fields (e.g., amount, fixed at 0.1 ETH). This cuts ~750 gas per emission, a boon for zk-SNARK workflows where off-chain indexing often suffices via commitment/nullifier tracking.
- Array Elimination: Excised the participants dynamic array, which grew with each deposit (22,500 gas per slot allocation). Its O(n) storage cost and potential iteration overhead (e.g., future participant checks) were profligate. We rely instead on participantCount and deposits mapping, reducing gas to O(1) per deposit. For zk-SNARK mixers, participant anonymity doesn’t necessitate on-chain enumeration — off-chain coordination or nullifier checks suffice.
- Conditional Counter Logic: participantCount += 1 now executes only if below uint32.max, avoiding pointless SSTOREs (5,000 gas) in overflow edge cases. This is a minor optimization but aligns with zk-SNARK’s ethos of lean state management.
These changes don’t touch the zk-SNARK verifier’s gas footprint — typically 200,000–500,000 gas for pairing-heavy proofs — but trim the contract’s overhead, leaving more headroom for proof submission.
Reentrancy Mitigation: Sealing Recursive Exploits
The prior withdraw and emergencyWithdraw functions courted disaster by issuing .call transfers before state updates, a classic reentrancy vector. A malicious recipient contract could reenter, resubmitting a valid zk-SNARK proof before withdrawn or nullifiers updated, draining the mixer. We’ve fortified:
Checks-Effects-Interactions (CEI):
- In withdraw, state updates (dep.withdrawn = true, nullifiers[nullifierHash] = true) now precede the ETH transfer. The zk-SNARK proof’s validity — checked via verifyProof(a, b, c, input) — is confirmed first, then effects (marking the deposit spent and nullifier used) commit before the interaction (.call). Gas overhead is unchanged; it’s a reordering win.
- emergencyWithdraw mirrors this: dep.withdrawn = true flips before the transfer, closing the reentrancy window. For zk-SNARK experts, this ensures nullifier uniqueness isn’t compromised by recursive calls, preserving the mixer’s integrity.
This CEI adherence is non-negotiable in a contract handling ETH payouts alongside zk-SNARK proofs, where state consistency is paramount.
Overflow Safeguards: Arithmetic Integrity
Solidity ^0.8.0’s built-in overflow checks already protect uint256 ops (e.g., block.number + dep.delayBlocks), reverting on wraparound. We’ve refined further:
Typed Precision:
- depositBlock and delayBlocks use uint32, with uint32(block.number) casting in deposit. Since block.number increments ~1 per 15s, 2³² exceeds Ethereum’s foreseeable lifespan, and MAX_DELAY_BLOCKS (100) fits trivially. The comparison block.number >= dep.depositBlock + dep.delayBlocks leverages uint256 promotion, triggering overflow reversion if dep.depositBlock + dep.delayBlocks exceeds 2²⁵⁶ — 1 — an impossible scenario here.
- generateDelay returns uint32, with % MAX_DELAY_BLOCKS ensuring bounds. No unchecked blocks exist, so zk-SNARK users can trust delay logic won’t silently wrap.
These type choices align with zk-SNARK’s deterministic rigor, ensuring timing predicates hold without edge-case failures.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;interface IZkVerifier {
function verifyProof(
uint[2] calldata a,
uint[2][2] calldata b,
uint[2] calldata c,
uint[2] calldata input
) external view returns (bool);
}
contract EnhancedEthTumbler {
IZkVerifier public immutable zkVerifier; // Immutable to save gas
uint256 public constant FIXED_DEPOSIT = 0.1 ether;
uint256 public constant MAX_DELAY_BLOCKS = 100;
uint32 public minParticipants = 3; // Use uint32 for smaller storage
uint32 public participantCount; // Reduced size
struct Deposit {
bytes32 commitment;
uint32 depositBlock; // uint32 safe for ~136 years at 15s/block
uint32 delayBlocks; // uint32 sufficient for MAX_DELAY_BLOCKS
bool withdrawn;
}
mapping(address => Deposit) public deposits;
mapping(bytes32 => bool) public nullifiers;
// Simplified events to reduce gas
event Deposited(bytes32 commitment);
event Withdrawn(address recipient);
constructor(address _zkVerifier) {
zkVerifier = IZkVerifier(_zkVerifier);
}
// Pseudo-random delay with reduced gas
function generateDelay(address sender) internal view returns (uint32) {
bytes32 hash = keccak256(abi.encodePacked(block.timestamp, sender));
return uint32(uint256(hash) % MAX_DELAY_BLOCKS) + 1;
}
// Deposit with gas optimization
function deposit(bytes32 _commitment) external payable {
require(msg.value == FIXED_DEPOSIT, "Must send exact fixed deposit");
Deposit storage dep = deposits[msg.sender];
require(dep.commitment == bytes32(0), "Already deposited");
uint32 delay = generateDelay(msg.sender);
dep.commitment = _commitment;
dep.depositBlock = uint32(block.number); // Safe downcast
dep.delayBlocks = delay;
dep.withdrawn = false;
// Increment participantCount only if truly new
if (participantCount < type(uint32).max) participantCount += 1;
emit Deposited(_commitment);
}
// Withdraw with reentrancy protection and gas savings
function withdraw(
uint[2] calldata a,
uint[2][2] calldata b,
uint[2] calldata c,
uint[2] calldata input,
address recipient
) external {
require(participantCount >= minParticipantsව
Deposit storage dep = deposits[msg.sender];
require(dep.commitment != bytes32(0), "No deposit found");
require(!dep.withdrawn, "Already withdrawn");
require(block.number >= dep.depositBlock + dep.delayBlocks, "Delay not elapsed");
bytes32 nullifierHash = bytes32(input[0]);
require(!nullifiers[nullifierHash], "Nullifier already used");
require(zkVerifier.verifyProof(a, b, c, input), "Invalid zk-SNARK proof");
// Checks-Effects-Interactions: Update state first
dep.withdrawn = true;
nullifiers[nullifierHash] = true;
// Interaction last: Send ETH
(bool sent, ) = recipient.call{value: FIXED_DEPOSIT}("");
require(sent, "Failed to send ETH");
emit Withdrawn(recipient);
}
// Emergency withdraw with reentrancy protection
function emergencyWithdraw() external {
Deposit storage dep = deposits[msg.sender];
require(dep.commitment != bytes32(0), "No deposit found");
require(!dep.withdrawn, "Already withdrawn");
require(block.number > dep.depositBlock + MAX_DELAY_BLOCKS * 2, "Too early");
dep.withdrawn = true;
(bool sent, ) = msg.sender.call{value: FIXED_DEPOSIT}("");
require(sent, "Failed to send ETH");
}
receive() external payable {}
}
For Solidity and zk-SNARK adepts, this iteration optimizes gas by slashing storage and event costs, fortifies reentrancy via CEI, and locks down overflows with type discipline — all without perturbing the zk-SNARK core (commitment/nullifier/proof triplet). The verifier’s gas heft remains a bottleneck — pairing ops in verifyProof dominate — but could be mitigated with precompiles or relayers, topics beyond this scope. The result is a leaner, safer mixer, primed for audit and deployment by those wielding proving keys and Etherscan alike.