Appearance
Marketplace
LootMarket is a fixed-price peer-to-peer marketplace. Sellers sign orders off-chain; buyers fill them on-chain. There is no escrow, no custody of funds, and no operator in the settlement path.
Listing is a signature, not a transaction
To list, a maker signs an EIP-712 typed-data structure with their wallet. No transaction is sent, no gas is paid, and the token does not move. The signature is stored and served to buyers.
Consequences that follow directly:
- Listing is free. Signing costs nothing.
- The token stays in your wallet. You can still transfer it, or burn it, or list it elsewhere. It is not locked.
- The order index is not load-bearing for correctness. If it vanished, every signature already handed to a buyer would still be valid on chain, and every maker could still cancel on chain. LootFi makes orders discoverable; it does not make them real.
- You must approve the market once. The market moves the NFT at fill time and needs approval to do so. This is the standard tradeoff for gasless listings, and its consequences are stated on Trust & security.
The EIP-712 domain
name "LootMarket"
version "1"
chainId block.chainid (4663 — bound at fill time)
verifyingContract the LootMarket proxy addressThe domain binds a signature to this chain and this contract. A signature produced for LootMarket on chain 4663 cannot be replayed on another chain, and cannot be replayed against a different contract — the digest simply does not match, and recovery yields a different address.
chainId is read from block.chainid at fill time rather than being frozen at deployment, so a chain fork does not silently make old signatures valid on both sides.
The contract exposes domainSeparator() and hashOrder(Order) as views, so an integrator can check their off-chain construction against the contract's own answer rather than trusting a re-implementation.
Addresses are on Contract deployments.
The Order struct
Field order is part of the type hash. It must match exactly.
solidity
struct Order {
address maker; // seller; must be the signer and the token owner at fill
address collection; // must be an allow-listed collection
uint256 tokenId;
address currency; // address(0) = native ETH, else an allow-listed ERC-20
uint256 price; // in the currency's smallest unit (wei for ETH)
uint256 expiry; // unix seconds
uint256 nonce; // per-maker; scoped to the maker only
}The type hash, verbatim:
solidity
bytes32 private constant ORDER_TYPEHASH = keccak256(
"Order(address maker,address collection,uint256 tokenId,address currency,uint256 price,uint256 expiry,uint256 nonce)"
);And the matching typed-data payload for signing:
ts
const ORDER_TYPES = {
Order: [
{ name: "maker", type: "address" },
{ name: "collection", type: "address" },
{ name: "tokenId", type: "uint256" },
{ name: "currency", type: "address" },
{ name: "price", type: "uint256" },
{ name: "expiry", type: "uint256" },
{ name: "nonce", type: "uint256" },
],
} as const;
const domain = {
name: "LootMarket",
version: "1",
chainId: 4663,
verifyingContract: LOOT_MARKET_ADDRESS,
};Reorder a single field and the digest changes, recovery returns a different address, and the fill reverts with InvalidSignature. See Sign an order for a complete working example.
Fill-time validation, in the contract's actual order
fulfillOrder(Order calldata order, bytes calldata signature) is payable, nonReentrant, and whenNotPaused. Its checks run in this order:
1. collection is allow-listed → else CollectionNotAllowed(collection)
2. currency is ETH or allow-listed → else CurrencyNotAllowed(currency)
3. block.timestamp <= order.expiry → else OrderExpired()
4. nonce not used or cancelled → else NonceUsedOrCancelled()
5. nonce >= minimumNonce[maker] → else NonceBelowMinimum()
6. ECDSA.recover(digest, sig) == maker → else InvalidSignature()
7. collection.ownerOf(tokenId) == maker→ else MakerNotOwner()
8. market is approved for the token → else MarketNotApproved()The ordering matters for integrators reading revert reasons: cheap state reads come before signature recovery, and recovery comes before the two external calls into the collection. A revert tells you exactly which condition failed.
Notes on individual checks:
- Step 2 — native ETH is
address(0)and is always accepted. Any other currency must be explicitly allow-listed by the owner. - Step 3 — expiry is inclusive: an order is still fillable during the second it expires (
block.timestamp > order.expiryreverts, so equality passes). - Steps 4 and 5 are both nonce checks and both are per-maker. See Cancellation below.
- Step 7 — ownership is re-read from the collection at fill time. A maker who has transferred the token away cannot have their stale order filled.
- Step 8 — either a single-token approval or
setApprovalForAllto the market satisfies this.
Settlement: strict CEI
CHECKS all eight validations above
│
EFFECTS usedOrCancelled[maker][nonce] = true ◄── BEFORE any call out
│
INTERACTIONS compute royalty (ERC-2981) and platform fee
├─ royalty → royalty receiver
├─ platform fee → fee recipient
└─ remainder → maker (seller)
│
collection.safeTransferFrom(maker, buyer, tokenId) ◄── LAST
│
emit OrderFulfilled(...)Three properties, deliberately:
The nonce is consumed before any external call. Combined with the nonReentrant guard, an order cannot be filled twice — not by a reentrant callback from a payment transfer, not by an ERC-721 receiver hook, not by a malicious currency contract.
Money moves before the NFT. The transfer that hands over the asset is the final action, after every payment has succeeded. Any payment failure reverts the whole transaction and nothing moves.
Everything is atomic. Buyer pays and receives, or neither happens. There is no window in which the buyer has paid and does not own the token.
Payment split
royaltyAmount = ERC-2981 royaltyInfo(tokenId, price)
platformFee = price × platformFeeBps / 10_000
sellerProceeds = price − royaltyAmount − platformFeeroyaltyAmount + platformFee > pricereverts withFeeExceedsPrice. The seller can never receive a negative amount.platformFeeBpsis capped atMAX_PLATFORM_FEE_BPS = 1000— 10%, a hard-coded constant. The owner cannot set a fee above it;setPlatformFeereverts withFeeTooHigh.- The royalty lookup is defensive. A collection that does not implement ERC-2981, or whose
royaltyInforeverts, or which returns the zero address as receiver, simply yields no royalty rather than blocking the sale.
Current fee and royalty values are on Fees & royalties — and readable on chain via platformFeeBps() and royaltyInfo().
Paying in ETH
solidity
// currency == address(0)
if (msg.value < order.price) revert IncorrectPayment(order.price, msg.value);
// … royalty, fee, seller proceeds paid out …
// excess above order.price is refunded to msg.senderUnderpaying reverts. Overpaying is refunded in the same transaction — a failed refund reverts with RefundFailed. A failed payment to any recipient reverts with PaymentFailed.
Paying in an ERC-20
solidity
// currency != address(0)
if (msg.value != 0) revert NativeNotAccepted();
// three safeTransferFrom calls: royalty, fee, sellerThe buyer must have approved the market for at least price of that token first. Sending ETH alongside an ERC-20 order reverts rather than stranding it.
No escrow
The token is in the seller's wallet from the moment they sign until the moment somebody fills. LootMarket never holds an NFT and never holds funds — payment passes through in the same transaction that moves the token.
What this means in practice:
- A seller can transfer or burn a listed token. The listing then simply cannot fill (step 7 fails), and the indexer marks it invalid so it stops being shown.
- A seller can revoke the market's approval at any time. The listing then cannot fill (step 8 fails).
- There is no "stuck in escrow" state, because there is no escrow.
- Withdrawing a listed token is refused at preflight — a burn would leave a valid signature advertising a token that no longer exists, and a buyer could pay gas for a fill that can only revert. Cancel first. See Withdrawal lifecycle.
Cancellation
Two mechanisms, both maker-controlled, both on chain:
Single order
solidity
function cancel(uint256 nonce) external; // emits OrderCancelled(maker, nonce)Marks that one nonce used for msg.sender. Anyone can cancel their own order; nobody can cancel anyone else's — the mapping is keyed by msg.sender, so there is no parameter through which to target another maker.
Bulk watermark
solidity
function setMinimumNonce(uint256 nonce) external; // emits MinimumNonceUpdatedInvalidates every order of yours with nonce < minimumNonce, in one transaction. This is the panic button: one call retires an entire history of signatures, however many were handed out.
Both are permissionless for your own orders and cost only gas. Neither requires the marketplace backend to cooperate — the check is in the contract, and a fill of a cancelled order reverts regardless of what any off-chain service believes.
Nonces are scoped per maker. Your nonce 7 and someone else's nonce 7 are unrelated. There is no global nonce space and no way for another maker's activity to invalidate your orders.
EOA signatures only
Signature verification is ECDSA.recover. There is no ERC-1271 fallback.
solidity
address signer = ECDSA.recover(orderHash, signature);
if (signer != order.maker) revert InvalidSignature();A smart-contract wallet — a Safe, an account-abstraction account, any contract that validates signatures via isValidSignature — cannot be a maker. Its "signature" will not recover to its address and the fill reverts with InvalidSignature.
Smart-contract wallets can still:
- Buy.
fulfillOrderis called by the taker; no signature from the taker is involved. - Own and transfer tokens. LootSkins is an ordinary ERC-721.
- Burn and withdraw, subject to the same direct-ownership rule as everyone else.
They cannot list. If you hold tokens in a contract wallet and want to sell on LootMarket, transfer to an EOA first.
Events
| Event | Emitted by | Carries |
|---|---|---|
OrderFulfilled | fulfillOrder | order hash, maker, taker, collection, tokenId, currency, price, royaltyAmount, platformFee |
OrderCancelled | cancel | maker, nonce |
MinimumNonceUpdated | setMinimumNonce | maker, new minimum |
OrderFulfilled carries the actual settled royalty and fee amounts, so the split is auditable from logs alone without re-deriving it. See Indexing and Events & indexing.
Contract-level protections, summarised
| Protection | Mechanism |
|---|---|
| Reentrancy | nonReentrant + nonce consumed before any external call + NFT transferred last |
| Cross-chain replay | chainId in the EIP-712 domain, read at fill time |
| Cross-contract replay | verifyingContract in the domain |
| Order replay | Per-maker nonce, marked used at fill |
| Bulk invalidation | setMinimumNonce watermark |
| Arbitrary collections | Collection allow-list |
| Arbitrary payment tokens | Currency allow-list; ETH is address(0) |
| Fee inflation | MAX_PLATFORM_FEE_BPS = 1000 (10%), enforced on every set |
| Seller shortfall | FeeExceedsPrice check before any payout |
| Non-standard ERC-20s | SafeERC20 throughout |
| Emergency stop | whenNotPaused on fills; owner-controlled |
Related
- LootMarket — full interface and error list.
- Sign an order — working signing code.
- Fill an order — working fill code.
- Fees & royalties — current values.