Version: V.0.1.1 Solidity: 0.8.34 Framework: Foundry
A trustless NFT marketplace built on Solidity. Any ERC-721 token can be listed, bought, or delisted — the contract holds no funds or NFTs at any point. Payment flows directly from buyer to seller and the NFT transfers atomically in the same transaction.
The diagram shows the external actor (User), the three public entry points (listNft, buyNft, cancelListing), how the listings mapping is affected, and which events are emitted.
User ──► listNft() ── listings[nft][id] = Listing ──► NftListed(seller, nft, id, price)
User ──► buyNft() ── delete listing ──► safeTransferFrom ──► ETH to seller ──► NftSold(...)
User ──► cancelListing() ── delete listing ──────────────────────────────────────────► CanceledListing(nft, id)
-
List — The seller calls
listNft(nftAddress, tokenId, price). The contract verifies the caller owns the token and that the price is non-zero, then stores the listing. The seller must also callapprove(marketplace, tokenId)on the NFT contract so the marketplace can transfer the token when a buyer arrives. -
Buy — The buyer calls
buyNft(nftAddress, tokenId)sending exactly the listed price in ETH. The contract deletes the listing first (Checks-Effects-Interactions), transfers the NFT from seller to buyer viasafeTransferFrom, then forwards the ETH to the seller. -
Cancel — The original seller calls
cancelListing(nftAddress, tokenId)to remove their listing at any time. No other address can cancel someone else's listing.
# Install Foundry
curl -L https://foundry.paradigm.xyz | bash && foundryupCreate a .env file in the project root:
PRIVATE_KEY=0x... # deployer private keyThe deploy script reads
PRIVATE_KEYfrom the environment viavm.envUint. Without it the script will revert before broadcasting.
# Build
forge build
# Deploy to a local node
anvil
source .env
forge script script/DeployNFTCollection.s.sol --broadcast --rpc-url http://127.0.0.1:8545
# Deploy to testnet / mainnet
source .env
forge script script/DeployNFTCollection.s.sol --broadcast --rpc-url <RPC_URL># Approve the marketplace to transfer your NFT (run on the NFT contract)
cast send <NFT_ADDRESS> "approve(address,uint256)" <MARKETPLACE_ADDRESS> <TOKEN_ID> \
--rpc-url <RPC_URL> --private-key $PRIVATE_KEY
# List an NFT for 1 ETH
cast send <MARKETPLACE_ADDRESS> "listNft(address,uint256,uint256)" \
<NFT_ADDRESS> <TOKEN_ID> 1000000000000000000 \
--rpc-url <RPC_URL> --private-key $PRIVATE_KEY
# Buy a listed NFT (send exact price)
cast send <MARKETPLACE_ADDRESS> "buyNft(address,uint256)" \
<NFT_ADDRESS> <TOKEN_ID> --value 1000000000000000000 \
--rpc-url <RPC_URL> --private-key $PRIVATE_KEY
# Cancel a listing
cast send <MARKETPLACE_ADDRESS> "cancelListing(address,uint256)" \
<NFT_ADDRESS> <TOKEN_ID> \
--rpc-url <RPC_URL> --private-key $PRIVATE_KEY
# Read a listing
cast call <MARKETPLACE_ADDRESS> "listings(address,uint256)" <NFT_ADDRESS> <TOKEN_ID> \
--rpc-url <RPC_URL>forge test -vvvThe test suite uses a MockNFT (a minimal ERC-721 with a public mint) as a stand-in for any real collection. Four actors are used: deployer, seller, buyer, and randomUser.
| # | Test | Covers |
|---|---|---|
| 1 | testMintNft |
MockNFT ownership after mint |
| 2 | testShouldRevertNftOwnerListingFails |
Non-owner cannot list |
| 3 | testShouldRevertNftByOtherUserListingFails |
Owning a different token doesn't grant rights |
| 4 | testShouldRevertNotValidPriceListing |
Zero-price listing reverts |
| 5 | testShouldRevertNotValidTokenId |
Non-existent token ID reverts |
| 6 | testListingWorksProperly |
Listing creates correct storage entry |
| 7 | testShouldRevertIfNotOwner |
Non-seller cannot cancel |
| 8 | testCancelListingWorksProperly |
Cancellation removes listing from storage |
| 9 | testShouldRevertBuyNftIfNotListed |
Buying unlisted token reverts |
| 10 | testShouldRevertIfBuyPriceIsDifferentFormListed |
Wrong ETH amount reverts |
| 11 | testBuyShouldDeleteListingProperly |
Purchase removes listing from storage |
| 12 | testBuyShouldTransferNftProperly |
NFT ownership transfers to buyer |
| 13 | testBalanceShouldBeTransferProperly |
ETH debited from buyer, credited to seller |
forge coverage| File | % Lines | % Statements | % Branches | % Funcs |
|---|---|---|---|---|
src/NFTCollection.sol |
100% | 100% | 91.67% | 100% |
test/NFTCollection.t.sol |
100% | 100% | 100% | 100% |
script/DeployNFTCollection.s.sol |
0% | 0% | 100% | 0% |
| Total | 79.17% | 76.19% | 91.67% | 80% |
The one uncovered branch in
NFTCollection.sol(91.67%) is therequire(success, "transfer failed")failure path insidebuyNft— triggered only if the seller's address reverts on receiving ETH, which requires a dedicated malicious-receiver contract.
All three state-changing functions (listNft, buyNft, cancelListing) are guarded by OpenZeppelin's nonReentrant modifier. This prevents any re-entrant call from a malicious NFT contract or seller address from executing a second state-changing function while the first is still in progress.
buyNft deletes the listing from storage before making any external calls (token transfer and ETH payment). This means that even if a malicious seller receive() function attempts to call back into buyNft, the listing no longer exists and the call reverts — providing a second layer of protection on top of nonReentrant.
The contract deploys with the deployer set as owner via Ownable(msg.sender). Ownership is reserved for future administrative functions (e.g. fee collection). No owner-only logic is active in this version.
