Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/NFT.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
99 changes: 99 additions & 0 deletions test/NFT.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading