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…

ERC-721 onERC721Received callback reentrancy occurs when _safeMint or safeTransferFrom invokes a hook on the recipient contract before state finalization, letting the attacker re-enter the minting or transfer function. Because the callback fires mid-execution, attackers can bypass per-wallet mint caps, double-claim airdrops, or manipulate marketplace state. The mitigation is OpenZeppelin's ReentrancyGuard plus checks-effects-interactions ordering around every _safeMint call.
··7 min read

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.

One Solidity tip + 1 case study per month

ERC-721 onERC721Received Reentrancy: The safeMint Trap | Crypto Hawking