EVM · Token Engineering · Lesson

Burn a Token,
Mint an NFT

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.

ERC-20 ERC-721 ERC-1155 CREATE2 EIP-1167
Module 01 · The Fork

The Setup, and Which Standard Fits

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:

Option A

Two separate contracts — ERC-20 + ERC-721

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.

Option B

A single ERC-1155

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.

The deciding question

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.

ConcernTwo contracts (A)Single ERC-1155 (B)
Marketplace supportFirst-class ERC-721Second-class on some venues
DeploymentsTwo (or three)One
User approvalsApprove the redeemerSingle setApprovalForAll
AtomicityCross-contract call, still atomicInternal _burn + _mint
ownerOf / enumerationNativeNot native
Gas per new "set"Higher (deploys)Lowest (a state write)
Self-check: your NFTs are loyalty badges that never leave your app. Which option?
Click to reveal
Option B (ERC-1155). Nothing here needs first-class ERC-721 behavior, so you're paying deploy + tooling overhead for a property you don't use. One contract, atomic burn+mint, cheapest per new set.
Module 02 · The Mechanism

Atomic Redemption: Contracts Calling Contracts

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.

Interactive · Step through one redeem() call
Approve
🔥
burnFrom
mintTo
NFT lands

The minimal three-contract sketch

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);
  }
}
Watch out · reentrancy _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.
Module 03 · The Wiring

Deploy Order & the Permission Model

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:

Two permissions must exist

Permission 1 · burning the user's ERC-20

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.

Permission 2 · minting the ERC-721

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.

Where should the redeem logic live?

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.

Honest complexity read The cross-contract call is the easy part — maybe 150 lines across three small OZ-based contracts, five minutes of wiring. Complexity comes entirely from the product rules: cheap (N-to-1 burns, sequential IDs, supply caps), moderate (token "types" that pick different NFTs → pushes you toward ERC-1155 input), or genuinely tricky (randomness → Chainlink VRF; reentrancy).
Module 04 · Scaling Up

The Factory Pattern

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.

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.

Module 05 · The Elegant Trick

CREATE2 & Deterministic Addresses

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.

Interactive · Predict an address before deploy

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.)

Predicted address →

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.

Free safety property

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.

Two honest caveats

Module 06 · Cheap at Scale

Minimal Proxy Clones (EIP-1167)

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.

Module 07 · The Money Question

What It All Costs

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:

ApproachPer-set costWhy
Naive factory (3 full deploys)~2–5M gasRe-stores three full bytecodes every time
Factory + clones (EIP-1167)~150–300k gasThree tiny proxies + initializer writes
ERC-1155 new token ID~25–80k gasNot a deploy at all — just a state write
Interactive · Cumulative cost calculator

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.

Naive factory
Factory + clones
ERC-1155 IDs

The honest decision framing

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.

The real takeaway Don't pick on the gas delta between clones and ERC-1155. Pick on whether you need real ERC-721s — then the clone flow makes the "yes" path affordable enough that cost stops being the concern. And if cost is live and you're on mainnet, moving to an L2 dwarfs every other optimization here. The only trustworthy numbers come from forge test --gas-report against your actual contracts.
copied ✓