You have a fungible token that a user can redeem. The moment they redeem, a distinct NFT is minted to them. This lesson walks the whole design space — from "which standard?" all the way down to CREATE2 factories and what each path actually costs in gas.
The goal in one line: a user holds a fungible token, they burn it, and in exchange a distinct, non-fungible token lands in their wallet. Before writing a single line, the first real decision is how many contracts and which standards.
Two credible shapes:
A standard ERC-20 for the redeemable token, a standard ERC-721 for the resulting NFT, and redemption logic that burns the caller's ERC-20 and mints them an NFT.
Pros: battle-tested standard interfaces; easiest for wallets, marketplaces, and indexers to understand; clean separation of concerns. Cons: two deployments; the redeemer needs burn permission and mint rights wired up.
One fungible ID for the redeemable token, non-fungible IDs (amount 1) for the results — all in one contract.
Pros: one deployment, one approval, atomic burn + mint in a single call, shared balance tracking. Cons: ERC-1155 "NFTs" are second-class on some marketplaces; you lose ERC-721-native features (ownerOf, per-token enumeration, some royalty tooling); provenance/metadata conventions are messier.
It almost always reduces to one thing: do the resulting NFTs need to be first-class ERC-721s? Traded on OpenSea-style markets, referenced by other contracts that expect the ERC-721 interface, enumerable? If yes → Option A. If they're really receipts or badges that live inside your own ecosystem → Option B's atomicity and single-approval UX is very attractive.
| Concern | Two contracts (A) | Single ERC-1155 (B) |
|---|---|---|
| Marketplace support | First-class ERC-721 | Second-class on some venues |
| Deployments | Two (or three) | One |
| User approvals | Approve the redeemer | Single setApprovalForAll |
| Atomicity | Cross-contract call, still atomic | Internal _burn + _mint |
| ownerOf / enumeration | Native | Not native |
| Gas per new "set" | Higher (deploys) | Lowest (a state write) |
The core worry people have — "can redeeming the ERC-20 automatically trigger the NFT mint?" — has a clean answer: yes, in the same transaction. On the EVM, one contract can call a function on another contract synchronously. So "burn the ERC-20 and mint the ERC-721" happens atomically: if the mint reverts, the burn reverts too, and vice versa. No async, no second transaction, no keeper or off-chain bot needed. One redeem() call does both.
Solidity ^0.8, leaning entirely on OpenZeppelin:
// RedeemableToken.sol — the fungible token contract RedeemableToken is ERC20, ERC20Burnable, Ownable { constructor() ERC20("Redeemable", "RDM") Ownable(msg.sender) {} function mint(address to, uint256 amt) external onlyOwner { _mint(to, amt); } }
// ResultNFT.sol — the NFT you get back contract ResultNFT is ERC721, Ownable { uint256 public nextId; address public redeemer; function setRedeemer(address r) external onlyOwner { redeemer = r; } function mintTo(address to) external returns (uint256 id) { require(msg.sender == redeemer, "not redeemer"); id = nextId++; _safeMint(to, id); } }
// Redeemer.sol — the orchestrator contract Redeemer { ERC20Burnable public immutable token; IResultNFT public immutable nft; uint256 public immutable cost; // ERC-20 burned per redemption // user must approve(this, cost) first function redeem() external returns (uint256) { token.burnFrom(msg.sender, cost); // burn BEFORE mint return nft.mintTo(msg.sender); } }
_safeMint calls back into the recipient if it's a contract. Burn before you mint, and/or add a reentrancy guard. Standard to handle — but it's exactly where careless code breaks.
Each contract needs to know about the other — a chicken-and-egg problem. It's solved by deploying one first, then using owner-guarded setter functions to connect them. Standard pattern. The precise sequence:
msg.sender against it, so you call setRedeemer(redeemerAddress) after deploy. Without this, anyone could mint for free.setNftContract(...).The user calls approve(redeemer, cost) once, then the redeemer calls burnFrom(). This is the standard ERC-20 approve/transfer dance — there's no way around the user signing an approval first. A one-transaction permit (EIP-2612) is a nice UX upgrade that folds the approval into the same tx, at the cost of more code.
The NFT checks msg.sender == authorizedMinter inside its mint function, and you set that authorized address to the redeemer. This is why deploy order and the setter matter.
You can put redeem() directly in the ERC-20, or use a separate third "redeemer" contract. The third-contract approach is worth the extra deployment because:
For a simple 1:1 flow, putting it in the ERC-20 directly is valid and saves a deployment. Nothing wrong with it.
If you'll be spinning up many ERC-20 + ERC-721 pairs, doing the deploy-deploy-setMinter dance by hand every time is error-prone. A factory contract automates the overhead: one createSet() call deploys everything and wires the permissions in a single atomic transaction.
setMinter call you might forget.contract SetFactory { function createSet(uint256 cost) external returns (address t, address n, address r) { ResultNFT nft = new ResultNFT(); // deploy NFT RedeemableToken tok = new RedeemableToken(); // deploy token Redeemer red = new Redeemer(tok, nft, cost); // deploy redeemer nft.setRedeemer(address(red)); // wire mint rights // all-or-nothing: one tx, no broken half-states return (address(tok), address(nft), address(red)); } }
The factory itself is a one-time deploy. Every pair after that is a call into it — which is exactly why the recurring cost of those secondary deploys (Module 07) is what actually matters at volume.
The chicken-and-egg wiring gets even cleaner if the contracts could be born already knowing each other's addresses. That's what CREATE2 gives you.
Normally a contract's address depends on the deployer's nonce, so you can't know it until you've deployed. With CREATE2, the address is a pure function of three inputs:
address = f(deployer, salt, bytecode) — a chosen salt, the deployer, and the contract's bytecode. Change any one and the address changes; keep them fixed and the address is knowable before the contract exists.
Tweak the salt or bytecode and watch the predicted address recompute — the way CREATE2 lets you compute where a contract will live. (Illustrative hash, not a real keccak256.)
That dissolves the chicken-and-egg problem entirely. You compute the redeemer's future address, then deploy the ERC-721 and ERC-20 with that address already baked in as their authorized minter, then deploy the redeemer to exactly the address you predicted. No setters. No post-deploy wiring at all — the relationships are immutable from block one.
Because the address is a hash of bytecode + salt, you can't deploy different code to a predicted address. Anyone who precomputed an address expecting a specific redeemer is guaranteed to get exactly that redeemer, or nothing. This is why CREATE2 underpins deterministic cross-chain deployments and counterfactual (account-abstraction) wallets.
CREATE2 handles where. Clones handle how cheaply. If you're mass-producing sets, deploying three full contracts every time re-stores identical bytecode on-chain — the single most expensive thing you can do. EIP-1167 fixes that.
Deploy your token logic once as an implementation. Then each set is a set of tiny (~45-byte) proxies that delegatecall into the shared logic. You get the same behavior at roughly 10× cheaper deploys — because you're no longer re-storing the logic.
The one adjustment: clones can't use constructors. So the "baked-in addresses" move from constructor immutables to an initialize() call that the factory makes atomically right after cloning. Because it's all one transaction, the preconfigured property is preserved.
function createSet(bytes32 salt) external { address nft = Clones.cloneDeterministic(nftImpl, salt); // CREATE2 clone address tok = Clones.cloneDeterministic(tokImpl, salt); address red = Clones.cloneDeterministic(redImpl, salt); IResultNFT(nft).initialize(red); // wire, since no constructor IToken(tok).initialize(address(this)); IRedeemer(red).initialize(tok, nft, cost); }
The three compose cleanly: the factory removes manual overhead, CREATE2 removes the back-and-forth by making addresses knowable in advance, and clones make it cheap when you're doing it a lot.
These are orders of magnitude, not quotes — real gas depends on your exact code, and live gas price + chain swamp everything. But the shape is what matters, and it's stark. Three tiers for the recurring, per-set cost:
| Approach | Per-set cost | Why |
|---|---|---|
| Naive factory (3 full deploys) | ~2–5M gas | Re-stores three full bytecodes every time |
| Factory + clones (EIP-1167) | ~150–300k gas | Three tiny proxies + initializer writes |
| ERC-1155 new token ID | ~25–80k gas | Not a deploy at all — just a state write |
Drag the number of sets and flip the chain. Watch the gap open up — and watch the chain choice dwarf the clone-vs-1155 difference.
Cost alone points at ERC-1155 — but cost was never the deciding factor. The ERC-721 question is. You pay the clone/factory premium specifically to get first-class ERC-721 result NFTs. If those NFTs must trade on standard marketplaces, be referenced by ERC-721-expecting contracts, or expose ownerOf/enumeration, that extra gas is the price of the property, and it's reasonable. If they don't, you're paying it for nothing and ERC-1155 wins on cost and simplicity.
forge test --gas-report against your actual contracts.