3.7.4 External Interaction Patterns
The patterns in this section govern how a contract interacts with the outside world: users wanting to prove eligibility, signed off-chain messages redeemed on-chain, multi-step operations batched into one transaction, callbacks during token transfers, and proofs about contract provenance. They share a common thread — moving work off the blockchain when possible, and structuring the on-chain portion to be efficient, verifiable, and resistant to manipulation.
Each pattern here has a specific class of problem it solves, a recognizable idiomatic form, and a set of foot-guns that have produced production exploits. This section is shorter per pattern than the prior sections because each pattern is narrower in scope and the depth lives in cross-referenced sections that cover signatures, MEV, and oracle exposure in their own right.
Commit-Reveal
The basic problem: any transaction in the mempool is visible to searchers before inclusion. A user submitting an auction bid, a vote, a guess in a game, or any action whose value depends on secrecy has leaked that information the moment they broadcast. Searchers can front-run, copy, or counter the action before the user's transaction is mined.
Commit-Reveal splits the action into two phases. Phase one (commit): the user submits a hash of their action plus a random salt. The hash reveals nothing about the action itself. Phase two (reveal): after the commit window closes, the user submits the original action plus the salt; the contract verifies the hash matches and accepts the action.
Idiomatic Form
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SealedBidAuction {
enum Phase { Commit, Reveal, Settled }
Phase public phase = Phase.Commit;
uint256 public commitDeadline;
uint256 public revealDeadline;
mapping(address => bytes32) public commitments;
mapping(address => uint256) public revealedBids;
address public highBidder;
uint256 public highBid;
constructor(uint256 commitDuration, uint256 revealDuration) {
commitDeadline = block.timestamp + commitDuration;
revealDeadline = commitDeadline + revealDuration;
}
function commit(bytes32 commitment) external payable {
require(phase == Phase.Commit, "not commit phase");
require(block.timestamp <= commitDeadline, "commit closed");
require(commitments[msg.sender] == bytes32(0), "already committed");
require(msg.value > 0, "deposit required");
commitments[msg.sender] = commitment;
}
function reveal(uint256 bidAmount, bytes32 salt) external {
if (block.timestamp > commitDeadline && phase == Phase.Commit) {
phase = Phase.Reveal;
}
require(phase == Phase.Reveal, "not reveal phase");
require(block.timestamp <= revealDeadline, "reveal closed");
bytes32 expected = keccak256(abi.encode(msg.sender, bidAmount, salt));
require(commitments[msg.sender] == expected, "bad reveal");
revealedBids[msg.sender] = bidAmount;
if (bidAmount > highBid) {
highBid = bidAmount;
highBidder = msg.sender;
}
}
}
Three details merit attention:
The commitment hash includes msg.sender. Without this, an attacker who sees a reveal on-chain could replay the same commitment from their own address to claim someone else's bid. Including the sender binds the commitment to the originator.
The salt is non-optional. A commitment hash of just (bidAmount) over a small space of likely bid values can be brute-forced by a searcher. With a 256-bit salt, the search space is computationally infeasible. The salt must be chosen randomly by the user and kept secret until reveal.
Deposits with the commit are required. Without a financial cost to committing, the pattern is vulnerable to a denial-of-service variant where the attacker submits commits they never intend to reveal, wasting block space and skewing perceived participation.
Trade-offs
Commit-Reveal trades user experience for fairness. Two transactions are required. Users who commit but fail to reveal (network issues, lost salt, change of mind) typically forfeit their deposit — a refund mechanism for missed reveals undermines the financial commitment that protects the auction.
The pattern protects against front-running but not against selective non-reveal: a participant who sees the public reveal of an opponent and realizes they've lost can simply not reveal, paying the deposit cost. For high-stakes scenarios, the deposit must exceed the expected value of the option to walk away — which is itself a hard parameter to set.
Commit-Reveal is treated more fully in Section 3.11.3 (MEV mitigation patterns) along with batch auctions and threshold-encrypted mempools.
Merkle Proofs
The basic problem: a contract needs to verify that some user, value, or claim was part of a set determined at contract deployment. Storing the entire set on-chain costs ~20,000 gas per entry — for an allowlist of 50,000 users, this is a billion gas just to populate storage.
A Merkle proof stores only the root hash of the set (one 32-byte slot) and verifies membership by reconstructing the path from a claimed leaf to the known root. Verification costs ~1,000 gas per tree level — for a 50,000-entry set (16 levels), about 16,000 gas total. Off-chain infrastructure stores the full set and serves proofs.
Idiomatic Form
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/cryptography/MerkleProof.sol";
import "@openzeppelin/contracts/utils/structs/BitMaps.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract MerkleAirdrop {
using BitMaps for BitMaps.BitMap;
bytes32 public immutable merkleRoot;
IERC20 public immutable token;
BitMaps.BitMap private claimed;
error AlreadyClaimed();
error InvalidProof();
constructor(bytes32 _root, IERC20 _token) {
merkleRoot = _root;
token = _token;
}
function claim(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata proof
) external {
if (claimed.get(index)) revert AlreadyClaimed();
bytes32 leaf = keccak256(
bytes.concat(keccak256(abi.encode(index, account, amount)))
);
if (!MerkleProof.verify(proof, merkleRoot, leaf)) revert InvalidProof();
claimed.set(index);
require(token.transfer(account, amount), "transfer failed");
}
}
The pattern combines naturally with the Bitmap Nonces pattern from Section 3.7.2 — each Merkle leaf has a unique index, and the bitmap tracks which indices have been claimed.
Several details prevent classic Merkle airdrop bugs:
Double-hashing the leaf (keccak256(bytes.concat(keccak256(...)))) defends against second-preimage attacks on Merkle trees. An internal node hash can otherwise be presented as a "leaf" — the depths are not distinguishable from a hash alone. OpenZeppelin v5's implementation handles this internally if you use their library to generate the tree as well; mismatched generators and verifiers are a frequent integration bug.
The leaf encodes (index, account, amount), not just the account. Without index, two leaves for the same account at different amounts would have indistinguishable claim records. Without amount, the airdrop is bound to send a fixed amount per recipient; encoding it allows differentiated amounts per recipient.
OpenZeppelin's MerkleProof.verify handles the sort. Sibling hashes are concatenated in sorted order at each level, so the same leaf and proof verify regardless of which side is which. Custom implementations that don't sort produce subtly different roots and proofs that fail to verify.
Off-Chain Tooling
Generating the Merkle tree off-chain requires care to match the on-chain verifier exactly. The standard tools:
@openzeppelin/merkle-tree— the canonical JavaScript library; generates trees compatible with the contracts libraryStandardMerkleTreespecifically handles the double-hashing convention used in OZ v5
import { StandardMerkleTree } from "@openzeppelin/merkle-tree";
const values = [
[0, "0xAlice...", "1000000000000000000"],
[1, "0xBob...", "2500000000000000000"],
// ... thousands more
];
const tree = StandardMerkleTree.of(values, ["uint256", "address", "uint256"]);
console.log("Root:", tree.root); // store this on-chain
console.log("Proof for Alice:", tree.getProof([0, "0xAlice...", "1000000000000000000"]));
The contract stores the root; the dApp generates proofs at claim time using the stored tree data. Users only need the proof for their own leaf.
Trade-offs
Merkle proofs assume the eligibility set is fixed at root-commitment time. Adding entries later requires a new root, which means existing proofs from the old set may need to be re-issued or revalidated. For fixed campaigns (airdrops, allowlist mints), this is fine. For dynamic eligibility (changes daily based on activity), Merkle proofs are awkward — consider on-chain registries with explicit add/remove operations.
The off-chain tree storage is a centralization point. If the project loses the tree data and only the root remains on-chain, no one can claim anymore. Standard mitigation is to publish the tree data (IPFS, GitHub, etc.) at deployment so any participant can regenerate proofs independently.
Multicall
The basic problem: a user wants to perform several operations atomically — say, approve a token and deposit it and stake the resulting LP token. Without multicall, this requires three transactions, three signatures, three gas costs, and three opportunities for state to change between operations.
Multicall lets a single transaction execute multiple function calls against the same contract. The contract's external function accepts an array of calldata payloads and invokes each one via delegatecall to itself.
Idiomatic Form
OpenZeppelin's Multicall is the standard implementation:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/Multicall.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Vault is ERC20, Multicall {
constructor() ERC20("Vault Share", "vSHR") {}
function deposit(uint256 amount) external {
// ... deposit logic
}
function stake(uint256 amount) external {
// ... staking logic
}
}
A user can now batch:
bytes[] memory calls = new bytes[](2);
calls[0] = abi.encodeWithSignature("deposit(uint256)", 1000e18);
calls[1] = abi.encodeWithSignature("stake(uint256)", 1000e18);
vault.multicall(calls);
Both calls execute in a single transaction, sharing one nonce and one gas overhead. If either reverts, the entire batch reverts.
The Critical Pitfall: msg.value with Multicall
The single most-exploited bug in Multicall implementations involves payable functions. If multicall is itself payable, the msg.value is visible to every nested call — meaning a payable function called via multicall sees the same msg.value it would see in a direct call, even if multiple payable calls are in the batch.
// VULNERABLE pattern
function multicall(bytes[] calldata calls) external payable {
for (uint256 i = 0; i < calls.length; ++i) {
(bool ok, ) = address(this).delegatecall(calls[i]);
require(ok);
}
}
function deposit() external payable {
balances[msg.sender] += msg.value; // BUG: each delegatecall sees the full msg.value
}
A user sends 1 ETH with a multicall containing five deposit() calls. Each delegatecall sees msg.value == 1 ether, so each call credits the user 1 ETH — they've turned 1 ETH into 5 ETH of credited balance.
This is a real, recurring exploit class. It hit at least one major protocol in 2024.
The mitigation: either make multicall non-payable (forbid sending ETH with batches), or use OZ's Multicall which is specifically designed to handle this correctly by tracking the value across calls. If you need payable multicall, audit the value-handling logic carefully. A pragmatic rule of thumb: payable multicall is a tripwire; avoid it unless you have a clear reason and a careful implementation.
Trade-offs
Multicall is purely an additive feature — a contract works the same with or without it from the perspective of any individual function. The cost is:
- Slightly larger bytecode (the multicall implementation)
- The careful-with-
msg.valuediscipline above - Some operations that should not be batched (e.g., commit and reveal in the same transaction defeats the purpose) need explicit guards
For most contracts where users perform composable operations, Multicall is a clear win. ERC-4626 vaults, Uniswap V3 positions, and most DeFi protocols include it.
NFT Receive Hooks (Safe Transfers)
The basic problem: ERC-721 and ERC-1155 tokens can be sent to contracts that don't know how to handle them, resulting in tokens permanently stuck at the contract address with no way to recover them. The original ERC-721 standard had no defense against this — transfer to a contract address worked the same as transfer to an EOA.
The fix in the standard is safeTransferFrom, which calls a hook on the recipient contract (onERC721Received for ERC-721, onERC1155Received and onERC1155BatchReceived for ERC-1155). The hook returns a magic value confirming the contract knows how to handle the token; if the recipient is not a contract, or if the hook reverts, or if the magic value is wrong, the transfer reverts.
Idiomatic Form: Implementing the Hook
A contract that holds NFTs should accept them via the hook:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/utils/ERC721Holder.sol";
import "@openzeppelin/contracts/token/ERC1155/utils/ERC1155Holder.sol";
contract NFTVault is ERC721Holder, ERC1155Holder {
// ERC721Holder implements onERC721Received returning the magic value
// ERC1155Holder implements onERC1155Received and onERC1155BatchReceived
function withdrawERC721(address token, uint256 tokenId) external {
// ... access control + transfer logic
IERC721(token).safeTransferFrom(address(this), msg.sender, tokenId);
}
}
OpenZeppelin's ERC721Holder and ERC1155Holder provide the minimum-viable implementations. They accept all incoming tokens unconditionally.
Custom Hook Logic
A more sophisticated contract can use the hook to perform actions atomically with receipt:
contract AuctionHouse {
mapping(uint256 => Auction) public auctions;
struct Auction {
address seller;
uint256 minBid;
}
function onERC721Received(
address /* operator */,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
uint256 minBid = abi.decode(data, (uint256));
auctions[tokenId] = Auction({ seller: from, minBid: minBid });
return this.onERC721Received.selector;
}
}
The seller can deposit an NFT and create an auction listing in a single transaction — no separate approval, no separate listing call. The data parameter carries the auction parameters; the hook does the work.
The Reentrancy Risk
ERC-721 and ERC-1155 safeTransfer* invokes the recipient hook before the transfer completes from the sender's perspective in some implementations. This is a reentrancy vector — a malicious recipient hook can re-enter the token contract or the calling protocol.
The defense is the same as any other reentrancy: apply CEI and nonReentrant to functions that invoke safeTransferFrom. Section 3.8.2 covers the specific case of ERC-777, ERC-1363, and ERC-721's safeTransfer as reentrancy vectors. The general rule: safeTransfer family functions execute external code; treat them as call.
Trade-offs
Using the hook pattern adds dependency on the token contract correctly implementing the safe-transfer flow. Most modern NFT contracts do, but legacy contracts or non-standard implementations may not. Code that depends on onERC721Received being called should fail gracefully if a token arrives via plain transferFrom instead — typically by also implementing a withdrawal function for stuck tokens, gated by access control.
ERC-20 Permit (EIP-2612)
The basic problem: traditional ERC-20 spending requires the user to call approve and then call the spending contract in a separate transaction. Two transactions, two gas fees, and a brief on-chain window where the approval exists without being used (which has its own well-known exploit class — the "approval front-running" attack).
EIP-2612 introduces permit, which allows a user to authorize spending via an off-chain signature that the spending contract submits along with its operation. One transaction, one gas fee, no exposed approval.
Idiomatic Form
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract SwapRouter {
function swapWithPermit(
IERC20Permit token,
uint256 amount,
uint256 deadline,
uint8 v, bytes32 r, bytes32 s
) external {
// The permit signature authorizes this contract to spend `amount`
token.permit(msg.sender, address(this), amount, deadline, v, r, s);
IERC20(address(token)).transferFrom(msg.sender, address(this), amount);
_swap(amount);
}
}
The user signs an EIP-712 typed message off-chain expressing "I, msg.sender, authorize address(this) to spend amount of this token until deadline." The router submits the signature; the token's permit function validates and sets the allowance; the router consumes the allowance immediately. The user paid one gas fee for everything.
Issuing the Permit
For an ERC-20 contract to support permits, it inherits ERC20Permit:
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract MyToken is ERC20Permit {
constructor() ERC20("My Token", "MYT") ERC20Permit("My Token") {}
}
ERC20Permit handles the EIP-712 domain separator, the nonce tracking, and the signature verification. The token's owner can sign permits using their wallet's typed-data signing API (e.g., eth_signTypedData_v4 in MetaMask).
The Permit Front-Running Pitfall
A subtle issue: anyone can submit a valid permit signature, not just the recipient. If a user signs a permit and posts it publicly (or it leaks), a third party can submit the permit transaction, paying the gas, and then... nothing. The permit just sets an allowance — it doesn't do anything dangerous on its own. But it does consume the nonce.
This becomes an attack when a contract's flow expects to atomically submit-permit-and-spend. An attacker who pre-submits just the permit (separately from the spend) leaves the spend transaction in a state where the permit has already been used — the spend may now fail to validate a fresh permit, or worse, may proceed without authorization in a logic bug.
Defense: contracts that consume permits should be tolerant of "permit already consumed" — typically by checking the allowance after permit rather than trusting permit to succeed. OpenZeppelin's SafeERC20.safePermit does this; raw permit calls do not.
function safeSwap(IERC20Permit token, uint256 amount, uint256 deadline,
uint8 v, bytes32 r, bytes32 s) external {
try token.permit(msg.sender, address(this), amount, deadline, v, r, s) {} catch {}
// Either the permit succeeded, or it was pre-consumed — check allowance now
require(IERC20(address(token)).allowance(msg.sender, address(this)) >= amount, "insufficient");
// ... proceed
}
Trade-offs
Permit is one of the highest-impact UX improvements available to a token contract — saving users a full transaction per spend operation. The trade-off is the permit-front-running consideration above and the dependency on the token actually implementing EIP-2612. Many older tokens do not; the universal alternative (Permit2 from Uniswap) wraps any ERC-20 with permit-like functionality but requires the user to approve Permit2 once globally.
Note that USDC and DAI implement variations of permit (USDC implements EIP-2612; DAI implements an earlier non-standard variant). Multi-token applications need to handle both — or use Permit2 universally.
Factory Proofs
The basic problem: a protocol wants to verify, on-chain, that a contract address was deployed by a trusted factory — not by an attacker impersonating the factory's output. This is critical for cross-contract trust: a lending pool that accepts "any LP token from our DEX as collateral" must distinguish real LP tokens from attacker-deployed lookalikes.
The pattern relies on Ethereum's deterministic address derivation. A contract's address is fully determined by (deployer_address, nonce) for CREATE or (deployer_address, salt, init_code_hash) for CREATE2. If the factory is known, the address is verifiable.
CREATE2 Factory Pattern
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract PoolFactory {
address public immutable poolImplementation;
event PoolCreated(address indexed token0, address indexed token1, address pool);
constructor(address _impl) {
poolImplementation = _impl;
}
function createPool(address token0, address token1) external returns (address pool) {
require(token0 < token1, "tokens not sorted");
bytes32 salt = keccak256(abi.encode(token0, token1));
bytes memory bytecode = abi.encodePacked(
type(Pool).creationCode,
abi.encode(token0, token1)
);
assembly {
pool := create2(0, add(bytecode, 0x20), mload(bytecode), salt)
if iszero(pool) { revert(0, 0) }
}
emit PoolCreated(token0, token1, pool);
}
function computePoolAddress(address token0, address token1) public view returns (address) {
require(token0 < token1, "tokens not sorted");
bytes32 salt = keccak256(abi.encode(token0, token1));
bytes32 initCodeHash = keccak256(abi.encodePacked(
type(Pool).creationCode,
abi.encode(token0, token1)
));
return address(uint160(uint256(keccak256(abi.encodePacked(
hex"ff",
address(this),
salt,
initCodeHash
)))));
}
function isAuthenticPool(address pool, address token0, address token1) external view returns (bool) {
return pool == computePoolAddress(token0, token1);
}
}
Any contract can call isAuthenticPool(pool, token0, token1) and get a definitive answer: this pool address was either deployed by this factory with these tokens, or it was not. No external state, no oracle, no off-chain lookup.
Trade-offs
Factory proofs work cleanly for CREATE2-deployed contracts where the init code is stable. They become awkward when:
- The factory deploys multiple contract versions (init code hash changes per version)
- The deployed contracts are proxies that may be upgraded (the proxy address is verifiable; the implementation it points to is not, without separate logic)
- The factory itself can be replaced or upgraded (a new factory deploys to different addresses)
Uniswap V2 uses factory proofs extensively to validate that LP pairs are genuine. Uniswap V3 continues the pattern with a more complex init code hash that includes the fee tier. The pattern is mature, well-understood, and the foundation for trust between cooperating protocols.
Quick Reference
| Pattern | Solves | Watch out for |
|---|---|---|
| Commit-Reveal | Front-running, transaction order sensitivity | Salt secrecy, selective non-reveal, UX cost of two transactions |
| Merkle Proofs | Storing large fixed-membership sets cheaply | Generator/verifier mismatch, second-preimage attacks (use double-hash), tree data availability |
| Multicall | Batching multiple ops into one transaction | Payable multicall + msg.value reuse; do not batch commit and reveal |
| NFT Safe Transfers | Tokens stuck at contracts that can't handle them | Hook reentrancy; non-standard tokens that skip the hook |
| ERC-20 Permit | Approve + spend in one transaction | Permit front-running (use try/catch + allowance check); USDC/DAI nonstandard variants |
| Factory Proofs | Verifying contract provenance on-chain | Version drift; proxy implementations need separate verification |
Cross-References
- MEV mitigation — Section 3.11.3 covers Commit-Reveal alongside batch auctions and threshold-encrypted mempools
- Bitmap nonces — Section 3.7.2 covers the bitmap pattern that Merkle airdrops typically use to track claims
- Reentrancy — Section 3.8.2 covers the
safeTransferfamily as reentrancy vectors - EIP-712 signatures — Section 3.8.8 covers the typed-data signing scheme used by Permit, multi-sig, and Permit2
- Front-running — Section 4.11 covers detection of front-running vulnerabilities during audit
- Real exploits — Section 3.10 includes the Nomad initialization mistake and similar provenance-trust failures