From d8749a8448f8258d9745bfb9b50990bff4e1a5c5 Mon Sep 17 00:00:00 2001 From: Oliver Anyanwu Date: Sun, 17 May 2026 12:28:55 +0100 Subject: [PATCH] feat: add minimal ERC721-style NFT contract with tests No metadata or safeTransfer hooks - tight surface for unit + fuzz tests. Covers happy path, double-mint revert, approval cleared on transfer, operator vs per-token approval, and zero-address guards. Closes #3 --- src/NFT.sol | 74 +++++++++++++++++++++++++++++++++++++ test/NFT.t.sol | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/NFT.sol create mode 100644 test/NFT.t.sol diff --git a/src/NFT.sol b/src/NFT.sol new file mode 100644 index 0000000..76e6eab --- /dev/null +++ b/src/NFT.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +/// Minimal ERC721-style NFT - no metadata, no enumerable, no safeTransfer hooks. +/// Mint is open so tests don't have to deal with roles. +contract NFT { + string public name; + string public symbol; + + mapping(uint256 => address) internal _owners; + mapping(address => uint256) internal _balances; + mapping(uint256 => address) public getApproved; + mapping(address => mapping(address => bool)) public isApprovedForAll; + + event Transfer(address indexed from, address indexed to, uint256 indexed tokenId); + event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId); + event ApprovalForAll(address indexed owner, address indexed operator, bool approved); + + constructor(string memory name_, string memory symbol_) { + name = name_; + symbol = symbol_; + } + + function ownerOf(uint256 tokenId) public view returns (address owner) { + owner = _owners[tokenId]; + require(owner != address(0), "NFT: nonexistent token"); + } + + function balanceOf(address owner) external view returns (uint256) { + require(owner != address(0), "NFT: zero address"); + return _balances[owner]; + } + + function mint(address to, uint256 tokenId) external { + require(to != address(0), "NFT: mint to zero"); + require(_owners[tokenId] == address(0), "NFT: already minted"); + _owners[tokenId] = to; + unchecked { _balances[to] += 1; } + emit Transfer(address(0), to, tokenId); + } + + function approve(address to, uint256 tokenId) external { + address owner = ownerOf(tokenId); + require(msg.sender == owner || isApprovedForAll[owner][msg.sender], "NFT: not authorized"); + getApproved[tokenId] = to; + emit Approval(owner, to, tokenId); + } + + function setApprovalForAll(address operator, bool approved) external { + isApprovedForAll[msg.sender][operator] = approved; + emit ApprovalForAll(msg.sender, operator, approved); + } + + function transferFrom(address from, address to, uint256 tokenId) external { + require(to != address(0), "NFT: transfer to zero"); + address owner = ownerOf(tokenId); + require(owner == from, "NFT: from is not owner"); + require( + msg.sender == owner || + isApprovedForAll[owner][msg.sender] || + getApproved[tokenId] == msg.sender, + "NFT: not authorized" + ); + + // Clear per-token approval on transfer (matches ERC721 spec) + delete getApproved[tokenId]; + unchecked { + _balances[from] -= 1; + _balances[to] += 1; + } + _owners[tokenId] = to; + emit Transfer(from, to, tokenId); + } +} diff --git a/test/NFT.t.sol b/test/NFT.t.sol new file mode 100644 index 0000000..fc229f4 --- /dev/null +++ b/test/NFT.t.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {Test} from "forge-std/Test.sol"; +import {NFT} from "../src/NFT.sol"; + +contract NFTTest is Test { + NFT public nft; + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address carol = makeAddr("carol"); + + function setUp() public { + nft = new NFT("TestNFT", "TNFT"); + } + + function test_MintAndOwnership() public { + nft.mint(alice, 1); + assertEq(nft.ownerOf(1), alice); + assertEq(nft.balanceOf(alice), 1); + } + + function test_Mint_RevertOnDoubleMint() public { + nft.mint(alice, 1); + vm.expectRevert("NFT: already minted"); + nft.mint(bob, 1); + } + + function test_Mint_RevertOnZeroAddress() public { + vm.expectRevert("NFT: mint to zero"); + nft.mint(address(0), 1); + } + + function test_TransferFrom_ByOwner() public { + nft.mint(alice, 1); + vm.prank(alice); + nft.transferFrom(alice, bob, 1); + assertEq(nft.ownerOf(1), bob); + assertEq(nft.balanceOf(alice), 0); + assertEq(nft.balanceOf(bob), 1); + } + + function test_Transfer_ClearsApproval() public { + nft.mint(alice, 1); + vm.prank(alice); + nft.approve(carol, 1); + assertEq(nft.getApproved(1), carol); + + vm.prank(alice); + nft.transferFrom(alice, bob, 1); + assertEq(nft.getApproved(1), address(0)); + } + + function test_ApprovedSpenderCanTransfer() public { + nft.mint(alice, 1); + vm.prank(alice); + nft.approve(carol, 1); + + vm.prank(carol); + nft.transferFrom(alice, bob, 1); + assertEq(nft.ownerOf(1), bob); + } + + function test_OperatorCanTransfer() public { + nft.mint(alice, 1); + vm.prank(alice); + nft.setApprovalForAll(carol, true); + + vm.prank(carol); + nft.transferFrom(alice, bob, 1); + assertEq(nft.ownerOf(1), bob); + } + + function test_TransferFrom_RevertWhenUnauthorized() public { + nft.mint(alice, 1); + vm.expectRevert("NFT: not authorized"); + vm.prank(bob); + nft.transferFrom(alice, bob, 1); + } + + function test_TransferFrom_RevertOnZeroAddress() public { + nft.mint(alice, 1); + vm.expectRevert("NFT: transfer to zero"); + vm.prank(alice); + nft.transferFrom(alice, address(0), 1); + } + + function test_OwnerOf_RevertOnNonexistent() public { + vm.expectRevert("NFT: nonexistent token"); + nft.ownerOf(999); + } + + function testFuzz_Mint_AnyOwnerAndTokenId(address to, uint256 tokenId) public { + vm.assume(to != address(0)); + nft.mint(to, tokenId); + assertEq(nft.ownerOf(tokenId), to); + assertEq(nft.balanceOf(to), 1); + } +}