How to Build an NFT Marketplace on the Wyvern Protocol
“Wyvern” is the protocol OpenSea ran for its first two years. I now think every exchange protocol gets one of its design decisions right and one wrong, and Wyvern is the example I reach for to show both. The question this post answers is concrete: if I want an NFT marketplace — a place to trade non-fungible tokens, one-of-a-kind tokens — what does Wyvern actually hand me, and what do I have to build?
What this post is about. The Wyvern v3 contracts, read the way I wish someone had read them to me. You will learn what an order contains (less than you would expect), how a trade is authorised, and which pieces — order book, matcher, fees, cancellation — you still have to write yourself. The excerpts come from the fork described halfway through, and one of them contains a bug I introduced.
What you need to know first. Three words carry the rest of the post. An NFT is a token that stands for one unique thing; the ERC-721 standard is the contract interface for it. A maker is whoever creates and signs an order, and a taker is whoever fills it. An order is a signed promise to trade on stated terms; it is data, not a transaction.
Short answer: a settlement kernel — the core contract that validates and executes matched orders — and almost everything else.
The situation
An atomic swap — a trade where either both legs happen or neither does — between two ERC-721s is easy to write badly. The naive version is an escrow — a contract that holds assets until conditions are met — per asset type: one contract for ERC721-for-ERC721, one for ERC721-for-ERC20 (ERC-20 is the standard for interchangeable tokens, like a stablecoin), one for ERC1155-for-ERC20 (ERC-1155 lets one contract issue many token types at once). Each has its own storage, accounting and bugs. Add a bundle sale — an NFT, a payment, a commission, a royalty — and you need a fourth contract, or a MultiSend (a helper that batches several transfers into one call), or a lot of hope.
Wyvern’s answer was to separate three things that the naive design fuses together:
- What is being offered — data, in an order.
- How that offer is checked — a contract you choose, that answers a question about a piece of calldata, the bytes a call carries to name a function and its arguments.
- Who may trade it — a proxy per user, and a signature.
The protocol ships the settlement kernel. Everything else is yours.
Why this design
Four design decisions, and what each one buys
1. Authorisation happens off chain. A maker — whoever creates and signs an order — signs an EIP-712 order. EIP-712 is the standard for signing structured data, so wallets can display what is being signed. No transaction, no gas, no on-chain nonce. The exchange verifies the signature at match time, and only at match time.
2. One generic contract for every asset pair. Instead of one escrow per combination, an order is “execute this call, and validate it with this selector” — the selector is the 4-byte function name inside the calldata. A new asset type needs no new exchange contract, because the exchange never knew what an asset was.
3. One proxy per user. The maker approves their ERC-20/721/1155 once, to their own proxy — a small contract that acts on the user’s behalf. The exchange never holds funds, never touches allowances, and never has a balance. The proxy is the trading identity, which means the exchange itself can be redeployed and upgraded without users migrating anything.
4. Validation is a static call — a read-only call that cannot change any state. The matcher supplies the actual calldata. A STATICCALL into the order’s static target re-derives what that calldata does — right token, right amount, right ratio — and returns the new fill (how much of the order has been used). The exchange never interprets the calldata; it asks a contract that you wrote what it means.
The order is smaller than you expect
1 | return keccak256(abi.encode( |
contracts/exchange/ExchangeCore.sol:121. This is the hash a maker signs. keccak256 is Ethereum’s hash function, ORDER_TYPEHASH is a constant naming the kind of message, and salt is a random number that makes two otherwise-identical orders hash differently.
Notice what is not in the order: no token address, no token id, no price. Those live inside staticExtradata, and they are decoded by the static target. The price is not a field of the protocol; it is a property of the code you chose. This is the single most important thing to understand about Wyvern, and it cuts both ways.
Four ways to be authorised
1 | /* (a): sent by maker */ |
contracts/exchange/ExchangeCore.sol:202. Branch (a) lets the maker execute their own order directly; branch (b) accepts an order the maker approved on chain beforehand. The rest of the original function computes the hash that must be signed and continues with the two signature branches below.
Branch (c) is EIP-1271: if the maker is itself a contract, the exchange asks that contract whether the signature is valid and accepts a fixed magic value — a constant meaning “yes”. A contract maker (a Gnosis Safe, an NFT marketplace’s own escrow) is a first-class citizen. Branch (d) is plain ECDSA — the signature scheme Ethereum wallets use — over the EIP-712 hash, with a personal_sign fallback (the wallet call that signs a plain message) for wallets that cannot sign typed data.
The proxy is the user
1 | require( |
contracts/registry/AuthenticatedProxy.sol:77. Only the user may call their own proxy — or, while revoked is false, a contract the registry has approved. revoked is a kill switch: the user can cut the exchange’s access to their proxy without any transaction from the exchange. The DelegateCall branch matters most: a delegatecall runs another contract’s code as if it were this contract’s own code, spending this contract’s assets. It is what lets the atomicizer run in the proxy’s context and move the proxy’s assets. The original function handles the plain Call case the same way, with dest.call(data).
The atomicizer is a library, not a contract
1 | for (uint256 i = 0; i < addrs.length; i++) { |
contracts/WyvernAtomicizer.sol:35. A library is helper code that runs in its caller’s context rather than as its own contract. The excerpt keeps only the call-and-check part; the original loop also reassembles each sub-call’s bytes from one flat array before calling it.
A bundle — NFT plus payment plus commission plus royalty — is one call with a list of sub-calls, and any failure reverts the whole thing. It is DELEGATECALLed from the maker’s proxy, so the sub-calls spend the maker’s approvals.
How it works
1 | maker taker |
The maker creates and signs an order; the taker is whoever fills it. Arrows read top to bottom as steps in time: the once-only setup, the free off-chain signature, and then the single box — the one on-chain transaction — which validates both sides, runs two proxy calls, and finishes with an event for your indexer (the program that turns events into database rows) to record.
Two details in that diagram are easy to miss. The two sides are asymmetric: call 1 executes, then call 2, so the second side can observe the first’s effects; and the static calls run after the effects, so they check the resulting state, not the intended state.
There is no on-chain cancel. In v3.1 you rely on expirationTime (where 0 means never), on the maker bumping the salt, or on the maker burning their own remaining fill with setOrderFill. Every one of those is a transaction the maker pays for, which is why marketplaces wrap listing in a relayer — a service that submits transactions on a user’s behalf.
What you still have to build
This is the part that decides whether “quick” is true.
| Piece | Why the protocol does not do it |
|---|---|
| Order book and indexer | Wyvern emits OrdersMatched, OrderApproved, OrderFillChanged. You consume them and keep your own database. |
| Matcher | Choosing counterparties, constructing the two Calls, and packing them into the uint256[16] the exchange expects. |
| API and frontend | The protocol has no notion of a user interface. |
| Proxy registration UX | Every user must register a proxy and approve tokens to it before they can trade. This is a mandatory first-run step and it is the worst onboarding you can design. |
| Metadata and asset resolution | Names, images, attributes — entirely off chain. |
| Fees and royalties | Wyvern takes no fee and holds no payment. The price is a transferFrom inside the atomicizer, made from the taker’s proxy to the maker, and if you want a commission and a royalty, you atomicize three transfers. The fork’s own demo does exactly that. |
| Gas policy | The taker pays; the maker pays nothing; a relayer is your problem. |
| Cancellation bookkeeping | Expiry, salt bumps, fill decrements — all off chain except the fill. |
The fork
This repository is a fork of wyvernprotocol/wyvern-v3 with 15 commits of my own work, all in March 2022: Chinese documentation with three diagrams, a new Rinkeby deployment (Rinkeby was an Ethereum test network), a baobab network (the testnet of Klaytn, a separate layer-1 blockchain), a ganache network for local work (ganache runs a private blockchain on your machine), a rewritten test suite, and a fee-splitting demo.
Two of the changes are not cosmetic, and one of them is a bug I introduced.
1 | uint256 finalAmount = abi.decode( |
contracts/StaticMarket.sol:340. Each line slices 32 bytes out of the calldata and decodes them as one number: the price at byte offset 616, the commission at 716, the royalty at 816. Upstream reads the ERC-20 amount at byte offset 68 — the end of a standard transferFrom(address,address,uint256) payload. My version reads 616, 716 and 816, where the amounts live in a three-transfer payload. But the fork’s own checkERC20Side still asserts the standard 68-byte payload, so the two disagree: the price-ratio check degenerates and the fill stops advancing. I widened the offset to support the fee split without updating the assertion that validates it.
The other change is subtler: in both ERC-1155↔ERC-20 paths, the ERC-20-side assertions are commented out, and anyERC20ForERC1155 now requires a DelegateCall where upstream used a Call. The assertions were commented out while I was getting the fee-splitting flow to work, and they never came back.
The fork also never took upstream’s commit “Send ether with .call to support smart contract takers”, so it still uses transfer(msg.value). A plain transfer gives a contract receiving ETH only a 2300-gas stipend — a small fixed gas budget — which is not enough for a contract wallet to do anything in response. So a contract taker cannot receive ETH.
Trade-offs
“No payment inside the contract” is the whole design. The exchange never custodies, never mints, never takes a fee. There is no allowance accounting, no fee logic, no settlement risk, no insolvency surface. The bill arrives immediately: you build fees and royalties yourself, the taker’s proxy must be pre-approved rather than the exchange, and the price is enforced by your static-call code rather than by the protocol.
The gas shape is unusual. A match costs two effectful proxy calls, two STATICCALLs carrying large ABI-encoded payloads, optionally a DELEGATECALL into the atomicizer, and byte-wise calldata comparisons. It is cheap for the maker — one proxy setup, then free signatures — and comparatively expensive per match. That is the right shape when setup amortises over many trades, and the wrong shape for a market where every user trades once.
The audit — a paid review of contract code by a security firm — says something the ecosystem did not want to hear. audits/v3.0/ contains Decenter’s report from December 2018 — three findings, all minor. audits/v3.1/ contains only AUDIT_SPEC.md, no published report, and the spec still says v3.1 was not on mainnet. Critically, that spec puts the static-call libraries out of scope — “auxiliary library contracts… are the responsibility of the user”.
Those libraries are exactly where price and asset validation live. The economic security of a Wyvern market is in code the protocol disclaims. You are not buying a verified exchange; you are buying a settlement kernel and writing your own validation.
And it is superseded. Wyvern v3.1 was never OpenSea’s production protocol — OpenSea ran v2 and migrated to Seaport in 2022–23. A platform built today would choose Seaport (one order covers ERC-1155/721/20, payments are first-class with protocol fees and payouts, criteria resolvers — contracts that decide which assets qualify — replace static calls) plus Permit2 for allowances (Permit2 is a contract that lets one token approval serve many applications).
What I would do differently
Do not fork the protocol to change the platform. The fork exists because I wanted Chinese docs and a testnet deployment, and both are documentation changes, not protocol changes. Both contract changes I made were attempts to bend a settlement kernel into a fee-splitting marketplace, and both broke an invariant the protocol depended on. If I were starting over, the fork would contain exactly the docs and nothing else.
Write the static targets first, with tests, before writing the platform. The static target is the price. getERC20AmountFromCalldata reading the wrong offset is not a subtle bug in a helper — it silently disabled the only thing standing between a taker and a bad price. Every static target needs a test that asserts the exact calldata shape it expects, and the check should live next to the target, not in a different contract.
Assume you will write the fee logic, and design the order flow around it. The demo’s three-transferFrom atomicization works, but it means the fee split is encoded in calldata that the protocol does not understand and the audit does not cover. A platform that takes a fee should make the fee a first-class part of the protocol — which is, more or less, what Seaport’s ProtocolFee and payout mechanisms are.
Do the cancellation work before the listing work. There is no on-chain cancel in v3.1. A user who wants to pull a listing needs a relayer to submit a transaction for them, and a platform that does not have a relayer will accumulate listings nobody can remove. Decide the relayer policy before you launch, not after the first support ticket about a stale listing.
And if you are building this in 2026, do not build this. Use Seaport and Permit2. What Wyvern still teaches is worth more than the code: authorise off chain and verify on chain; separate what is offered from how it is checked; keep the matcher dumb and stateless; make fill, expiry and cancellation accounting explicit; and amortise per-user setup — but only if your users trade repeatedly.
References
- wyvernprotocol/wyvern-v3 — the upstream protocol repository this post forks
- Wyvern Protocol — the project’s site
- ProjectOpenSea/seaport — the protocol that superseded Wyvern
- ProjectOpenSea/permit2 — the token-approval contract to pair with Seaport
- EIP-712: typed structured data hashing and signing — how orders are signed
- EIP-1271: standard signature validation method for contracts — the contract-signature check
- Solidity documentation — the language the contracts are written in