An ERC-721 collection implementing EIP-4907 — the "rentable NFT" standard that adds a time-limited user role alongside the owner — plus a self-contained rental marketplace that lets an owner rent out USE of a token per second without ever handing over ownership.
| Contract | Purpose | Runtime size |
|---|---|---|
src/RentableNft.sol |
ERC-721 + EIP-4907 rentable NFT | 4,329 bytes* |
src/RentalMarket.sol |
Escrow marketplace over any EIP-4907 collection | 4,652 bytes |
src/IERC4907.sol |
The canonical EIP-4907 interface | — |
* Both are far under the EIP-170 24,576-byte limit.
A rentable NFT carries two distinct roles:
- Owner — holds the token, can transfer / sell / burn it, and is the only party (with an approved
operator) allowed to grant the user role via
setUser. - User — an address granted the time-limited right to USE the token until an
expirestimestamp. The user may drive whatever off-chain or on-chain utility gates onuserOf(game access, metadata, membership) but never owns the token and can never transfer, sell, or re-delegate it.
RentableNft implements the standard surface:
setUser(uint256 tokenId, address user, uint64 expires)— owner/approved only.userOf(uint256) returns (address)— the current user, oraddress(0)once expired.userExpires(uint256) returns (uint256)— the raw expiry timestamp.UpdateUser(tokenId, user, expires)event on every change.
Expiry boundary decision. userOf returns the user while expires >= block.timestamp — the token
is still usable at exactly block.timestamp == expires, and lapses one second later. This matches the
EIP-4907 reference implementation and is fuzzed at the boundary.
Transfer clears the rental. Per the spec, selling a token ends any active rental. _update (the
OZ v5 transfer hook) wipes the UserInfo and emits UpdateUser(id, 0, 0) on every ownership change,
so a buyer never inherits someone else's user right. Mints are exempt (no prior user to clear).
The market works with any IERC4907 + IERC721 collection and settles rent in a single ERC-20
payment token fixed at construction.
owner ──approve──▶ list(collection, tokenId, pricePerSecond, minDuration, maxDuration)
└─ NFT is escrowed in the market; owner recorded as `lister`
renter ─pay ppS·d─▶ rent(collection, tokenId, duration)
├─ pulls pricePerSecond·duration from the renter
├─ retains a bounded protocol fee, forwards the rest to the lister
└─ sets the renter as EIP-4907 user until block.timestamp + duration
lister ──────────▶ delist(collection, tokenId) (only when no active rental)
└─ NFT returned to the lister
Views: listing(collection, tokenId), isRented(collection, tokenId), quote(collection, tokenId, duration) → (cost, fee, payout).
Escrow-the-NFT. To list, the owner deposits the token into the market, which becomes the on-chain
owner while it is listed. This is exactly what lets the market grant EIP-4907 user rights — only the
owner/approved may call setUser, and escrow makes the market that owner. It is the simplest and
safest model for a self-contained demo: there is no standing setUser approval dangling on the
owner's wallet, and the market's authority is scoped to tokens actually deposited with it. The
original owner is recorded as the lister and is the only party who can reclaim the token
(delist), and only while no rental is active.
Rent is settled atomically inside one nonReentrant call: pull the renter's payment, retain the
fee, forward the remainder to the lister — all in the same transaction. The market never parks renter
funds; the only balance it ever accrues is protocol fees.
- The renter buys USE, never ownership. On
rentthe market sets the renter as the token's EIP-4907 user; the NFT itself is never transferred to the renter. - The renter has no path to transfer, sell, re-list, or re-delegate the token —
setUserand transfers both require owner/approval, which the renter never holds. (Tested directly.) - An escrowed NFT is only ever returnable to its recorded
lister, never to a renter. - A raw
safeTransferFrominto the market (not vialist) is rejected, so no NFT can be stranded without a listing (onERC721Receivedaccepts only market-initiated deposits). - The owner cannot yank an NFT out from under an active renter:
delistreverts whileuserOf != 0.
Each rent pays a protocol fee of feeBps / 10_000, capped at MAX_FEE_BPS = 1000 (10%) — the owner
can never set a higher fee, at construction or via setFeeBps. Fees accrue in the contract and are
withdrawable only by the feeRecipient (withdrawFees). Conservation holds exactly:
Σ rent payments == Σ lister payouts + fees held + fees withdrawn.
67 tests, all green (forge test): 21 EIP-4907 unit/fuzz tests, 42 market unit/fuzz tests, and 4
stateful invariants.
The invariant suite runs 12,800 calls with 0 reverts per invariant under
fail_on_revert = true (pinned inline per invariant, proving the run is non-hollow):
invariant_feeConservation—Σ rent payments == Σ payouts + fees held + fees withdrawn.invariant_marketSolvency— the market's payment-token balance equals exactly the accrued (not yet withdrawn) fees; renter funds are never parked.invariant_nftCustody— every listed token is escrowed by the market; no tracked token is ever owned by a renter.invariant_activeRentalOwnedByMarket— a token with a live rental is always owned by the market, never its renter; user and owner stay strictly separated.
Reentrancy is covered from both hostile directions: a malicious payment token that re-enters rent
during the pull, and a malicious EIP-4907 collection that re-enters rent from inside setUser —
both blocked by nonReentrant (SafeERC20 + IERC721Receiver on the market).
Payment assumes a standard (non-fee-on-transfer) ERC-20; fee-on-transfer payment tokens are out of scope, as documented in the contract.
A second adversarial pass targeted the renter/owner boundary, custody stranding, fee accounting, and reentrancy. No exploitable bug was found — the market holds. The audit confirmed and added tests for:
- Renter can never own or control. A renter gets only the EIP-4907 user role; they cannot
transfer the NFT, re-list it, delist it, or
setUserthemselves (all covered). - Owner can't yank a rented NFT, and can always reclaim afterwards.
delistreverts during an active rental and only ever returns the token to the recordedlister. While escrowed, even the original lister cannotsetUser(can't kick the renter) nor transfer the NFT out — the market is the sole authority (test_ListerCannotSetUserWhileEscrowed,test_ListerCannotTransferEscrowedNft). - Boundary re-rent. A token is not re-rentable at the exact expiry second (
userOfvalid whileexpires >= now), only strictly past it (test_Rent_BlockedAtExactExpiryBoundary). - Reentrancy from three hostile directions — malicious payment token (pull), malicious EIP-4907
collection (
setUser), and a contract lister re-enteringdelistfromonERC721Received— all blocked bynonReentrant(test_Delist_ReentrantListerBlocked). - Fee-on-transfer stance made concrete. A FoT payment token breaks the
balance == accruedFeessolvency identity;test_FeeOnTransferBreaksSolvencyStancepins this so integrators know not to configure one.
forge build --sizes # compile, check EIP-170
forge test # 67 tests
forge fmt --check # formatting
forge snapshot # gas snapshot (.gas-snapshot)Deploy with script/Deploy.s.sol (reads config from env; see the script header).
MIT