ERC-721 onERC721Received Reentrancy: The safeMint Trap
safeMint is not safe. The ERC-721 standard mandates a callback into the receiver contract before _safeMint returns, handing attackers a reentrancy primitive most devs never check for. From free-mint exploits to marketplace drainers, the onERC721Received hook has quietly powered some of the dumbest…
The Bug: safeMint Calls You Back
Every Solidity dev learns to use `_safeMint` instead of `_mint` because "it's safer." What the tutorials skip: `_safeMint` is only safer for the *receiver* — it protects them from getting NFTs stuck in a contract that can't handle them. For the *minter*, it's strictly more dangerous than `_mint`, because it invokes `onERC721Received` on the recipient mid-transaction.
That callback is a reentrancy primitive. If the recipient is a contract the attacker controls, they get arbitrary code execution between the moment the NFT is assigned and the moment your function returns. Any state you update *after* `_safeMint` is fair game.
The ERC-721 spec (EIP-721) explicitly mandates this behavior:
> When transfer is complete, this function checks if `_to` is a smart contract. If so, it calls `onERC721Received` on `_to` and throws if the return value is not `bytes4(keccak256("onERC721Received(address,uint256,bytes)"))`.
The word "complete" is misleading. The ownership mapping is updated, sure — but your business logic (mint counters, supply checks, payment splits) probably isn't.
Vulnerable Code: The Free-Mint Exploit
Here's a pattern I've audited dozens of times. Looks fine. Isn't.
```solidity
contract LimitedNFT is ERC721 {
uint256 public totalMinted;
uint256 public constant MAX_SUPPLY = 10_000;
uint256 public constant MAX_PER_WALLET = 5;
mapping(address => uint256) public mintedBy;
function mint(uint256 qty) external payable {
require(msg.value == 0.05 ether * qty, "wrong price");
require(mintedBy[msg.sender] + qty <= MAX_PER_WALLET, "cap");
require(totalMinted + qty <= MAX_SUPPLY, "sold out");
for (uint256 i = 0; i < qty; i++) {
_safeMint(msg.sender, totalMinted + i);
}
totalMinted += qty;
mintedBy[msg.sender] += qty;
}
}
```
The checks pass on entry. The `_safeMint` loop fires `onERC721Received` on the attacker's contract on every iteration. Inside that callback, the attacker re-enters `mint` — and at that point, `mintedBy[msg.sender]` and `totalMinted` are *still zero*. The require statements pass again. And again. And again, until gas runs out.
Result: one wallet drains the entire mint, bypassing the per-wallet cap and likely the max supply (depending on the off-by-one). Several 2022-2023 free-mint projects got cleared out this exact way — HypeBears and a handful of Solana-bridge collections lost the entire allocation to single addresses, with floors crashing to zero within minutes.
The Fix: Effects Before Interactions, Plus a Guard
Two changes. Both required.
```solidity
contract LimitedNFT is ERC721, ReentrancyGuard {
uint256 public totalMinted;
uint256 public constant MAX_SUPPLY = 10_000;
uint256 public constant MAX_PER_WALLET = 5;
mapping(address => uint256) public mintedBy;
function mint(uint256 qty) external payable nonReentrant {
require(msg.value == 0.05 ether * qty, "wrong price");
require(mintedBy[msg.sender] + qty <= MAX_PER_WALLET, "cap");
require(totalMinted + qty <= MAX_SUPPLY, "sold out");
// EFFECTS first
uint256 startId = totalMinted;
totalMinted += qty;
mintedBy[msg.sender] += qty;
// INTERACTIONS last
for (uint256 i = 0; i < qty; i++) {
_safeMint(msg.sender, startId + i);
}
}
}
```
Update counters *before* `_safeMint`. The `nonReentrant` modifier is belt-and-suspenders, but you want both: CEI alone protects this function, but `nonReentrant` also blocks cross-function reentrancy into `claimAirdrop`, `stake`, or whatever else lives on the contract.
If you don't actually need the receiver check (e.g., minting to EOAs only via a Merkle drop), just use `_mint`. The "always use safeMint" advice is cargo-cult. EOAs can't implement `onERC721Received` anyway — the callback only matters for contract recipients.
Marketplace Variant: The Cross-Function Drain
The mint case is the simplest. The nastier variant is marketplace reentrancy. Consider:
```solidity
function buy(uint256 tokenId) external payable {
Listing memory l = listings[tokenId];
require(msg.value >= l.price, "underpaid");
IERC721(l.nft).safeTransferFrom(l.seller, msg.sender, tokenId);
payable(l.seller).transfer(l.price);
delete listings[tokenId];
}
```
The `safeTransferFrom` triggers `onERC721Received` on the buyer. The listing hasn't been deleted yet. The buyer re-enters `buy` for the same tokenId, pays again, gets the seller paid twice — but more interestingly, if the marketplace has a `cancelListing` path or coupled state in another function, the attacker now has a window to manipulate it. Variants of this pattern hit smaller NFT marketplaces during the 2022 cycle for six- and seven-figure losses; the OpenSea Wyvern proxy issues and several fork-of-a-fork marketplaces shared the same DNA.
Detection Checklist
When reviewing an ERC-721 contract, grep for `_safeMint` and `safeTransferFrom` and ask:
1. Is every state variable touched by this function updated *before* the safe call?
2. Is there a `nonReentrant` modifier on every external entry point that touches NFT state?
3. Does the function read state *after* the safe call? If yes, that read is attacker-controlled.
4. Are there other functions (claim, stake, refund) sharing state? They need the same guard.
5. Do you actually need `_safeMint`, or would `_mint` suffice?
Our [free AI audit](https://www.cryptohawking.com/audit) flags every `_safeMint` call that precedes a state write — it's one of the highest-signal heuristics we run. For production launches where a single mint-snipe wipes out your treasury, the [manual audit](https://www.cryptohawking.com/audit/manual) ($5,000, three business days, paid in ETH/SOL/USDT) walks the full call graph including cross-contract callbacks.
ERC-1155 Has the Same Problem, Twice
Quick note: ERC-1155's `onERC1155Received` and `onERC1155BatchReceived` are the same primitive with a batch variant. If your contract supports both standards, you have two callback surfaces. The fix is identical: CEI plus a single reentrancy guard covering all entry points.
TL;DR
`_safeMint` invokes attacker code. Treat it like an external call, because that's what it is. Update all state before it fires, slap `nonReentrant` on the entry point, and stop assuming the word "safe" in a function name means anything.
FAQ
Is _mint safer than _safeMint for the minter?
Yes, from a reentrancy perspective. `_mint` doesn't invoke any callback on the recipient, so there's no opportunity for the attacker to re-enter. The tradeoff: if you mint to a contract that doesn't implement `onERC721Received`, the NFT can get permanently stuck. For mints to EOAs only (e.g., Merkle-gated drops where you control the allowlist), `_mint` is the correct choice. For public mints where contract recipients are plausible, use `_safeMint` *with* CEI ordering and a `nonReentrant` modifier. Don't pick based on the function name — pick based on threat model.
Does ReentrancyGuard alone fix the issue without reordering state updates?
For single-function reentrancy, yes — `nonReentrant` blocks the recursive call entirely. But you should still follow checks-effects-interactions because (a) defense in depth matters, (b) `nonReentrant` doesn't protect against read-only reentrancy where another contract reads your stale state mid-call, and (c) if you later add a function that legitimately needs to be callable from within `onERC721Received`, you may remove or weaken the guard and reintroduce the bug. CEI is free. Use both.
Can ERC-721 reentrancy happen on transferFrom (non-safe)?
No. `transferFrom` updates the ownership mapping and emits a Transfer event with no callback to the recipient. The reentrancy surface is exclusively in `safeTransferFrom` and `_safeMint`, both of which invoke `onERC721Received` when the destination is a contract. If your marketplace or staking contract uses plain `transferFrom`, you don't have an ERC-721 callback reentrancy issue — though you may still have generic reentrancy via ETH transfers, ERC-777 hooks, or external calls elsewhere in the function.
How do attackers know to deploy a contract recipient instead of using an EOA?
Most production exploits are scripted in advance. Attackers monitor mempool and verified contract deployments for new NFT collections, fork the contract locally, run a fuzzer or static analyzer like Slither against it, and identify whether `_safeMint` is called before state updates. If yes, they deploy an attacker contract with a malicious `onERC721Received` and front-run the public mint. Some MEV searchers automate this entirely — they have generic templates for callback reentrancy that swap in the target contract address. Assume sophisticated adversaries on day one.
Does using OpenZeppelin's ERC721 implementation protect me automatically?
No. OpenZeppelin's `_safeMint` correctly implements the EIP-721 spec, which means it correctly invokes the callback — exposing your contract to reentrancy if your own logic doesn't handle it. The OZ contracts are unopinionated about your business logic ordering; they assume you know what you're doing. The mint counter, supply cap, payment handling, and allowlist checks are all your code, and that's where the bug lives. OpenZeppelin ships `ReentrancyGuard` in the same package — use it.