From 759ad4f70eada09597493f4f1ed779b7b361e536 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Sun, 28 Dec 2025 12:54:51 -0400 Subject: [PATCH 1/2] feat: add BatchMintNFT contract with upgradeable functionality and deployment scripts --- foundry.toml | 5 + remappings.txt | 3 +- script/DeployBatchMintNFT.s.sol | 63 ++++ script/UpgradeBatchMintNFT.s.sol | 48 +++ src/contentsign/BatchMintNFT.sol | 205 ++++++++++ subquery/mainnet-complete.ts | 28 ++ subquery/src/utils/const.ts | 1 + test/contentsign/BatchMintNFT.t.sol | 560 ++++++++++++++++++++++++++++ 8 files changed, 912 insertions(+), 1 deletion(-) create mode 100644 script/DeployBatchMintNFT.s.sol create mode 100644 script/UpgradeBatchMintNFT.s.sol create mode 100644 src/contentsign/BatchMintNFT.sol create mode 100644 test/contentsign/BatchMintNFT.t.sol diff --git a/foundry.toml b/foundry.toml index b05ff1b..9f4cec7 100644 --- a/foundry.toml +++ b/foundry.toml @@ -8,3 +8,8 @@ solc = "0.8.26" # necessary as some of the zksync contracts are big via_ir = true + +remappings = [ + "@openzeppelin/contracts-upgradeable/=lib/era-contracts/lib/openzeppelin-contracts-upgradeable-v4/contracts/", + "@openzeppelin/=lib/openzeppelin-contracts/" +] diff --git a/remappings.txt b/remappings.txt index 1e95077..b2db305 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1 +1,2 @@ -@openzeppelin=lib/openzeppelin-contracts/ \ No newline at end of file +@openzeppelin=lib/openzeppelin-contracts/ +@openzeppelin/contracts-upgradeable/=lib/era-contracts/lib/openzeppelin-contracts-upgradeable-v4/contracts/ \ No newline at end of file diff --git a/script/DeployBatchMintNFT.s.sol b/script/DeployBatchMintNFT.s.sol new file mode 100644 index 0000000..8d2f2b9 --- /dev/null +++ b/script/DeployBatchMintNFT.s.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; +import {BatchMintNFT} from "../src/contentsign/BatchMintNFT.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +/// @notice Deployment script for BatchMintNFT upgradeable contract +/// @dev Deploys implementation and proxy, then initializes the contract +contract DeployBatchMintNFT is Script { + string internal name; + string internal symbol; + address internal admin; + + function setUp() public { + name = vm.envString("BATCH_MINT_NFT_NAME"); + symbol = vm.envString("BATCH_MINT_NFT_SYMBOL"); + admin = vm.envAddress("BATCH_MINT_NFT_ADMIN"); + + vm.label(admin, "ADMIN"); + } + + function run() public { + vm.startBroadcast(); + + // Deploy implementation + console.log("Deploying BatchMintNFT implementation..."); + BatchMintNFT implementation = new BatchMintNFT(); + console.log("Implementation deployed at:", address(implementation)); + + // Encode initialize function call + bytes memory initData = abi.encodeWithSelector( + BatchMintNFT.initialize.selector, + name, + symbol, + admin + ); + + // Deploy proxy with initialization + console.log("Deploying ERC1967Proxy..."); + ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData); + console.log("Proxy deployed at:", address(proxy)); + + // Attach to proxy to get the initialized contract + BatchMintNFT nft = BatchMintNFT(address(proxy)); + + vm.stopBroadcast(); + + // Verify deployment + console.log("\n=== Deployment Summary ==="); + console.log("Implementation address:", address(implementation)); + console.log("Proxy address:", address(proxy)); + console.log("Contract name:", nft.name()); + console.log("Contract symbol:", nft.symbol()); + console.log("Admin address:", admin); + console.log("Next token ID:", nft.nextTokenId()); + console.log("Max batch size:", nft.MAX_BATCH_SIZE()); + console.log("\nContract is ready for use!"); + console.log("Users can now call safeMint() and batchSafeMint() publicly."); + console.log("Only admin can upgrade the contract using upgradeTo()."); + } +} diff --git a/script/UpgradeBatchMintNFT.s.sol b/script/UpgradeBatchMintNFT.s.sol new file mode 100644 index 0000000..5c51d60 --- /dev/null +++ b/script/UpgradeBatchMintNFT.s.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; +import {BatchMintNFT} from "../src/contentsign/BatchMintNFT.sol"; + +/// @notice Script to upgrade BatchMintNFT proxy to a new implementation +/// @dev Only admin can execute this upgrade +contract UpgradeBatchMintNFT is Script { + address internal proxy; + address internal newImplementation; + + function setUp() public { + proxy = vm.envAddress("BATCH_MINT_NFT_PROXY"); + newImplementation = vm.envAddress("BATCH_MINT_NFT_NEW_IMPL"); + } + + function run() public { + vm.startBroadcast(); + + console.log("Current proxy address:", proxy); + console.log("New implementation address:", newImplementation); + + // Attach to proxy + BatchMintNFT nft = BatchMintNFT(proxy); + + // Verify current state before upgrade + console.log("Current nextTokenId:", nft.nextTokenId()); + console.log("Current mintingEnabled:", nft.mintingEnabled()); + + // Perform upgrade + console.log("\nPerforming upgrade..."); + nft.upgradeTo(newImplementation); + + vm.stopBroadcast(); + + // Verify upgrade + console.log("\n=== Upgrade Summary ==="); + console.log("Proxy address:", proxy); + console.log("New implementation:", newImplementation); + console.log("Upgrade completed successfully!"); + console.log("\nVerify that:"); + console.log("1. Data is preserved (tokens, balances, etc.)"); + console.log("2. New methods are available"); + console.log("3. Existing methods still work"); + } +} diff --git a/src/contentsign/BatchMintNFT.sol b/src/contentsign/BatchMintNFT.sol new file mode 100644 index 0000000..7523de6 --- /dev/null +++ b/src/contentsign/BatchMintNFT.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {ERC721Upgradeable} from "openzeppelin-contracts-upgradeable-v4/contracts/token/ERC721/ERC721Upgradeable.sol"; +import {ERC721URIStorageUpgradeable} from "openzeppelin-contracts-upgradeable-v4/contracts/token/ERC721/extensions/ERC721URIStorageUpgradeable.sol"; +import {ERC721BurnableUpgradeable} from "openzeppelin-contracts-upgradeable-v4/contracts/token/ERC721/extensions/ERC721BurnableUpgradeable.sol"; +import {AccessControlUpgradeable} from "openzeppelin-contracts-upgradeable-v4/contracts/access/AccessControlUpgradeable.sol"; +import {UUPSUpgradeable} from "openzeppelin-contracts-upgradeable-v4/contracts/proxy/utils/UUPSUpgradeable.sol"; + +/// @notice An upgradeable ERC-721 contract that allows public batch minting +/// @dev Uses AccessControl for administrative functions and UUPS for upgradeability +/// @dev Unlike BaseContentSign which uses whitelist-based minting, this contract allows +/// anyone to mint tokens publicly. AccessControl is only used for upgrade authorization. +contract BatchMintNFT is + ERC721Upgradeable, + ERC721URIStorageUpgradeable, + ERC721BurnableUpgradeable, + AccessControlUpgradeable, + UUPSUpgradeable +{ + /// @notice The next token ID to be minted + uint256 public nextTokenId; + + /// @notice Maximum batch size to prevent DoS attacks + uint256 public constant MAX_BATCH_SIZE = 100; + + /// @notice Whether minting is currently enabled + bool public mintingEnabled; + + /// @notice Role identifier for minters (reserved for future use if minting restrictions are needed) + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + + /// @notice Error thrown when arrays length mismatch in batch operations + error UnequalLengths(); + /// @notice Error thrown when zero address is provided + error ZeroAddress(); + /// @notice Error thrown when URI is empty + error EmptyURI(); + /// @notice Error thrown when batch size exceeds maximum + error BatchTooLarge(); + /// @notice Error thrown when minting is disabled + error MintingDisabled(); + + /// @notice Emitted when multiple tokens are minted in a batch + /// @param recipients Array of addresses that received tokens + /// @param tokenIds Array of token IDs that were minted + event BatchMinted(address[] recipients, uint256[] tokenIds); + + /// @notice Emitted when minting is enabled or disabled + /// @param enabled Whether minting is enabled + event MintingEnabledChanged(bool indexed enabled); + + /// @custom:oz-upgrades-unsafe-allow constructor + constructor() { + _disableInitializers(); + } + + /// @notice Initialize the contract + /// @param name The name of the NFT collection + /// @param symbol The symbol of the NFT collection + /// @param admin The address that will have DEFAULT_ADMIN_ROLE + function initialize(string memory name, string memory symbol, address admin) public initializer { + if (admin == address(0)) { + revert ZeroAddress(); + } + + __ERC721_init(name, symbol); + __ERC721URIStorage_init(); + __ERC721Burnable_init(); + __AccessControl_init(); + __UUPSUpgradeable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + mintingEnabled = true; + } + + /// @notice Mint a single NFT to an address + /// @param to The address to mint the NFT to + /// @param uri The URI for the token metadata + function safeMint(address to, string memory uri) public { + if (!mintingEnabled) { + revert MintingDisabled(); + } + if (to == address(0)) { + revert ZeroAddress(); + } + if (bytes(uri).length == 0) { + revert EmptyURI(); + } + + uint256 tokenId = nextTokenId; + unchecked { + ++nextTokenId; + } + _safeMint(to, tokenId); + _setTokenURI(tokenId, uri); + } + + /// @notice Batch mint NFTs to multiple addresses + /// @param recipients Array of addresses to mint NFTs to + /// @param uris Array of URIs for token metadata (must match recipients length) + function batchSafeMint(address[] calldata recipients, string[] calldata uris) public { + if (!mintingEnabled) { + revert MintingDisabled(); + } + if (recipients.length != uris.length) { + revert UnequalLengths(); + } + if (recipients.length > MAX_BATCH_SIZE) { + revert BatchTooLarge(); + } + + uint256 currentTokenId = nextTokenId; + uint256 length = recipients.length; + + // Pre-allocate arrays for event emission + uint256[] memory tokenIds = new uint256[](length); + + for (uint256 i = 0; i < length; ) { + if (recipients[i] == address(0)) { + revert ZeroAddress(); + } + if (bytes(uris[i]).length == 0) { + revert EmptyURI(); + } + + _safeMint(recipients[i], currentTokenId); + _setTokenURI(currentTokenId, uris[i]); + tokenIds[i] = currentTokenId; + unchecked { + ++currentTokenId; + ++i; + } + } + + nextTokenId = currentTokenId; + + // Emit batch event + emit BatchMinted(recipients, tokenIds); + } + + /// @notice Get the URI for a specific token + /// @param tokenId The token ID to query + /// @return The URI string for the token metadata + function tokenURI(uint256 tokenId) + public + view + override(ERC721Upgradeable, ERC721URIStorageUpgradeable) + returns (string memory) + { + return super.tokenURI(tokenId); + } + + /// @notice Check interface support + /// @param interfaceId The interface identifier to check + /// @return Whether the contract supports the interface + function supportsInterface(bytes4 interfaceId) + public + view + override(ERC721Upgradeable, ERC721URIStorageUpgradeable, AccessControlUpgradeable) + returns (bool) + { + return + ERC721Upgradeable.supportsInterface(interfaceId) || + ERC721URIStorageUpgradeable.supportsInterface(interfaceId) || + AccessControlUpgradeable.supportsInterface(interfaceId); + } + + /// @notice Enable or disable minting (only admin can change) + /// @param enabled Whether to enable minting + function setMintingEnabled(bool enabled) public onlyRole(DEFAULT_ADMIN_ROLE) { + mintingEnabled = enabled; + emit MintingEnabledChanged(enabled); + } + + /// @notice Authorize upgrades (only admin can upgrade) + /// @param newImplementation The address of the new implementation contract + function _authorizeUpgrade(address newImplementation) internal override onlyRole(DEFAULT_ADMIN_ROLE) { + // Access control is handled by the onlyRole modifier + // This function intentionally left empty as the authorization is done via modifier + newImplementation; // Silence unused parameter warning + } + + /// @notice Burn a token (only owner or approved operator can burn) + /// @param tokenId The token ID to burn + /// @dev The caller must own the token or be an approved operator + function burn(uint256 tokenId) public override(ERC721BurnableUpgradeable) { + super.burn(tokenId); + } + + /// @notice Internal burn function (required override for ERC721URIStorage) + /// @param tokenId The token ID to burn + function _burn(uint256 tokenId) internal override(ERC721Upgradeable, ERC721URIStorageUpgradeable) { + super._burn(tokenId); + } + + /** + * @dev This empty reserved space is put in place to allow future versions to add new + * variables without shifting down storage in the inheritance chain. + * See https://docs.openzeppelin.com/contracts/4.x/upgradeable#storage_gaps + * Note: Reduced by 1 slot to account for mintingEnabled (bool) + */ + uint256[49] private __gap; +} diff --git a/subquery/mainnet-complete.ts b/subquery/mainnet-complete.ts index f68ba66..80c5ba9 100644 --- a/subquery/mainnet-complete.ts +++ b/subquery/mainnet-complete.ts @@ -383,6 +383,34 @@ const project: EthereumProject = { ], }, }, + { + kind: EthereumDatasourceKind.Runtime, + startBlock: 67286803, // This is the block that the contract was deployed on + options: { + abi: "ClickContentSign", + address: "0xaF4D027599D1d74844505d1Cb029be0e8EEd31bF", + }, + assets: new Map([ + [ + "ClickContentSign", + { + file: "./abis/ClickContentSign.abi.json", + }, + ], + ]), + mapping: { + file: "./dist/index.js", + handlers: [ + { + kind: EthereumHandlerKind.Event, + handler: "handleTransfer", + filter: { + topics: ["Transfer(address from,address to,uint256 tokenId)"], + }, + }, + ], + }, + }, { kind: EthereumDatasourceKind.Runtime, startBlock: 51533910, // This is the block that the contract was deployed on diff --git a/subquery/src/utils/const.ts b/subquery/src/utils/const.ts index b81590a..1400dcb 100644 --- a/subquery/src/utils/const.ts +++ b/subquery/src/utils/const.ts @@ -93,6 +93,7 @@ export const nodleContracts = [ "0x9Fed2d216DBE36928613812400Fd1B812f118438", "0x999368030Ba79898E83EaAE0E49E89B7f6410940", "0x6FE81f2fDE5775355962B7F3CC9b0E1c83970E15", // vivendi + "0xaF4D027599D1d74844505d1Cb029be0e8EEd31bF", // BatchMintNFT proxy ].map((address) => address.toLowerCase()); export const contractForSnapshot = [ diff --git a/test/contentsign/BatchMintNFT.t.sol b/test/contentsign/BatchMintNFT.t.sol new file mode 100644 index 0000000..5fd5741 --- /dev/null +++ b/test/contentsign/BatchMintNFT.t.sol @@ -0,0 +1,560 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Test} from "forge-std/Test.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {BatchMintNFT} from "../../src/contentsign/BatchMintNFT.sol"; +import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +contract BatchMintNFTTest is Test { + BatchMintNFT private implementation; + BatchMintNFT private nft; + ERC1967Proxy private proxy; + + address internal admin = vm.addr(1); + address internal alice = vm.addr(2); + address internal bob = vm.addr(3); + address internal charlie = vm.addr(4); + + function setUp() public { + // Deploy implementation + implementation = new BatchMintNFT(); + + // Encode initialize function call + bytes memory initData = abi.encodeWithSelector( + BatchMintNFT.initialize.selector, + "BatchMintNFT", + "BMNFT", + admin + ); + + // Deploy proxy with initialization + proxy = new ERC1967Proxy(address(implementation), initData); + + // Attach to proxy + nft = BatchMintNFT(address(proxy)); + } + + function test_initialization() public view { + assertEq(nft.name(), "BatchMintNFT"); + assertEq(nft.symbol(), "BMNFT"); + assertEq(nft.nextTokenId(), 0); + assertTrue(nft.hasRole(nft.DEFAULT_ADMIN_ROLE(), admin)); + } + + function test_publicMint() public { + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + assertEq(nft.ownerOf(0), alice); + assertEq(nft.tokenURI(0), "ipfs://uri1"); + assertEq(nft.nextTokenId(), 1); + } + + function test_publicMintMultiple() public { + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + vm.prank(bob); + nft.safeMint(bob, "ipfs://uri2"); + + assertEq(nft.ownerOf(0), alice); + assertEq(nft.ownerOf(1), bob); + assertEq(nft.tokenURI(0), "ipfs://uri1"); + assertEq(nft.tokenURI(1), "ipfs://uri2"); + assertEq(nft.nextTokenId(), 2); + } + + function test_batchMint() public { + address[] memory recipients = new address[](3); + recipients[0] = alice; + recipients[1] = bob; + recipients[2] = charlie; + + string[] memory uris = new string[](3); + uris[0] = "ipfs://uri1"; + uris[1] = "ipfs://uri2"; + uris[2] = "ipfs://uri3"; + + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + + assertEq(nft.ownerOf(0), alice); + assertEq(nft.ownerOf(1), bob); + assertEq(nft.ownerOf(2), charlie); + assertEq(nft.tokenURI(0), "ipfs://uri1"); + assertEq(nft.tokenURI(1), "ipfs://uri2"); + assertEq(nft.tokenURI(2), "ipfs://uri3"); + assertEq(nft.nextTokenId(), 3); + } + + function test_batchMintUnequalLengths() public { + address[] memory recipients = new address[](2); + recipients[0] = alice; + recipients[1] = bob; + + string[] memory uris = new string[](3); + uris[0] = "ipfs://uri1"; + uris[1] = "ipfs://uri2"; + uris[2] = "ipfs://uri3"; + + vm.expectRevert(BatchMintNFT.UnequalLengths.selector); + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + } + + function test_batchMintEmptyArrays() public { + address[] memory recipients = new address[](0); + string[] memory uris = new string[](0); + + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + + assertEq(nft.nextTokenId(), 0); + } + + function test_mixedMintAndBatch() public { + // First mint individually + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + // Then batch mint + address[] memory recipients = new address[](2); + recipients[0] = bob; + recipients[1] = charlie; + + string[] memory uris = new string[](2); + uris[0] = "ipfs://uri2"; + uris[1] = "ipfs://uri3"; + + vm.prank(bob); + nft.batchSafeMint(recipients, uris); + + assertEq(nft.ownerOf(0), alice); + assertEq(nft.ownerOf(1), bob); + assertEq(nft.ownerOf(2), charlie); + assertEq(nft.nextTokenId(), 3); + } + + function test_adminCanUpgrade() public { + // Deploy new implementation + BatchMintNFT newImplementation = new BatchMintNFT(); + + // Admin can upgrade using upgradeTo (no call data needed) + vm.prank(admin); + nft.upgradeTo(address(newImplementation)); + + // Verify the contract still works after upgrade + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + assertEq(nft.ownerOf(0), alice); + } + + function test_nonAdminCannotUpgrade() public { + BatchMintNFT newImplementation = new BatchMintNFT(); + + // AccessControl v4.9.0 emits a string error, not a custom error + vm.expectRevert(); + vm.prank(alice); + nft.upgradeTo(address(newImplementation)); + } + + function test_supportsInterface() public view { + // ERC721 interface + assertTrue(nft.supportsInterface(0x80ac58cd)); + // ERC721Metadata interface + assertTrue(nft.supportsInterface(0x5b5e139f)); + // AccessControl interface + assertTrue(nft.supportsInterface(0x7965db0b)); + } + + function test_balanceOf() public { + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri2"); + + assertEq(nft.balanceOf(alice), 2); + assertEq(nft.balanceOf(bob), 0); + } + + function test_batchMintToSameAddress() public { + address[] memory recipients = new address[](3); + recipients[0] = alice; + recipients[1] = alice; + recipients[2] = alice; + + string[] memory uris = new string[](3); + uris[0] = "ipfs://uri1"; + uris[1] = "ipfs://uri2"; + uris[2] = "ipfs://uri3"; + + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + + assertEq(nft.balanceOf(alice), 3); + assertEq(nft.ownerOf(0), alice); + assertEq(nft.ownerOf(1), alice); + assertEq(nft.ownerOf(2), alice); + } + + // ============================================ + // Security Tests - Zero Address Validation + // ============================================ + + function test_initializeRevertsWithZeroAddress() public { + BatchMintNFT newImpl = new BatchMintNFT(); + bytes memory initData = abi.encodeWithSelector( + BatchMintNFT.initialize.selector, + "Test", + "TEST", + address(0) + ); + + vm.expectRevert(BatchMintNFT.ZeroAddress.selector); + new ERC1967Proxy(address(newImpl), initData); + } + + function test_safeMintRevertsWithZeroAddress() public { + vm.expectRevert(BatchMintNFT.ZeroAddress.selector); + vm.prank(alice); + nft.safeMint(address(0), "ipfs://uri1"); + } + + function test_batchSafeMintRevertsWithZeroAddress() public { + address[] memory recipients = new address[](2); + recipients[0] = alice; + recipients[1] = address(0); // Zero address + + string[] memory uris = new string[](2); + uris[0] = "ipfs://uri1"; + uris[1] = "ipfs://uri2"; + + vm.expectRevert(BatchMintNFT.ZeroAddress.selector); + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + } + + // ============================================ + // Security Tests - URI Validation + // ============================================ + + function test_safeMintRevertsWithEmptyURI() public { + vm.expectRevert(BatchMintNFT.EmptyURI.selector); + vm.prank(alice); + nft.safeMint(alice, ""); + } + + function test_batchSafeMintRevertsWithEmptyURI() public { + address[] memory recipients = new address[](2); + recipients[0] = alice; + recipients[1] = bob; + + string[] memory uris = new string[](2); + uris[0] = "ipfs://uri1"; + uris[1] = ""; // Empty URI + + vm.expectRevert(BatchMintNFT.EmptyURI.selector); + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + } + + // ============================================ + // Security Tests - Batch Size Limit + // ============================================ + + function test_batchSafeMintRevertsWhenExceedingMaxBatchSize() public { + uint256 maxSize = nft.MAX_BATCH_SIZE(); + address[] memory recipients = new address[](maxSize + 1); + string[] memory uris = new string[](maxSize + 1); + + for (uint256 i = 0; i < maxSize + 1; ) { + recipients[i] = vm.addr(i + 10); + uris[i] = "ipfs://uri"; + unchecked { + ++i; + } + } + + vm.expectRevert(BatchMintNFT.BatchTooLarge.selector); + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + } + + function test_batchSafeMintSucceedsAtMaxBatchSize() public { + uint256 maxSize = nft.MAX_BATCH_SIZE(); + address[] memory recipients = new address[](maxSize); + string[] memory uris = new string[](maxSize); + + for (uint256 i = 0; i < maxSize; ) { + recipients[i] = vm.addr(i + 10); + uris[i] = "ipfs://uri"; + unchecked { + ++i; + } + } + + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + + assertEq(nft.nextTokenId(), maxSize); + } + + // ============================================ + // Security Tests - Re-initialization + // ============================================ + + function test_initializeCannotBeCalledTwice() public { + vm.expectRevert(); + nft.initialize("NewName", "NEW", admin); + } + + // ============================================ + // Upgrade Tests - State Preservation + // ============================================ + + function test_upgradePreservesState() public { + // Mint some tokens before upgrade + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + vm.prank(bob); + nft.safeMint(bob, "ipfs://uri2"); + + address[] memory recipients = new address[](2); + recipients[0] = charlie; + recipients[1] = admin; + + string[] memory uris = new string[](2); + uris[0] = "ipfs://uri3"; + uris[1] = "ipfs://uri4"; + + vm.prank(charlie); + nft.batchSafeMint(recipients, uris); + + uint256 tokenCountBefore = nft.nextTokenId(); + assertEq(tokenCountBefore, 4); + + // Perform upgrade + BatchMintNFT newImplementation = new BatchMintNFT(); + vm.prank(admin); + nft.upgradeTo(address(newImplementation)); + + // Verify state is preserved + assertEq(nft.nextTokenId(), tokenCountBefore); + assertEq(nft.ownerOf(0), alice); + assertEq(nft.ownerOf(1), bob); + assertEq(nft.ownerOf(2), charlie); + assertEq(nft.ownerOf(3), admin); + assertEq(nft.tokenURI(0), "ipfs://uri1"); + assertEq(nft.tokenURI(1), "ipfs://uri2"); + assertEq(nft.tokenURI(2), "ipfs://uri3"); + assertEq(nft.tokenURI(3), "ipfs://uri4"); + assertEq(nft.balanceOf(alice), 1); + assertEq(nft.balanceOf(bob), 1); + assertEq(nft.balanceOf(charlie), 1); + assertEq(nft.balanceOf(admin), 1); + + // Verify contract still works after upgrade + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri5"); + assertEq(nft.ownerOf(4), alice); + assertEq(nft.nextTokenId(), 5); + } + + // ============================================ + // Event Tests + // ============================================ + + function test_batchSafeMintEmitsEvent() public { + address[] memory recipients = new address[](2); + recipients[0] = alice; + recipients[1] = bob; + + string[] memory uris = new string[](2); + uris[0] = "ipfs://uri1"; + uris[1] = "ipfs://uri2"; + + uint256[] memory expectedTokenIds = new uint256[](2); + expectedTokenIds[0] = 0; + expectedTokenIds[1] = 1; + + vm.expectEmit(true, true, true, true); + emit BatchMintNFT.BatchMinted(recipients, expectedTokenIds); + + vm.prank(alice); + nft.batchSafeMint(recipients, uris); + } + + // ============================================ + // Burn Tests - Owner Only + // ============================================ + + function test_ownerCanBurnOwnToken() public { + // Mint token to alice + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + assertEq(nft.ownerOf(0), alice); + assertEq(nft.balanceOf(alice), 1); + + // Alice can burn her own token + vm.prank(alice); + nft.burn(0); + + // Verify token is burned + vm.expectRevert(); + nft.ownerOf(0); + assertEq(nft.balanceOf(alice), 0); + } + + function test_nonOwnerCannotBurnToken() public { + // Mint token to alice + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + assertEq(nft.ownerOf(0), alice); + + // Bob cannot burn alice's token + vm.expectRevert(); + vm.prank(bob); + nft.burn(0); + + // Token should still exist + assertEq(nft.ownerOf(0), alice); + } + + function test_approvedOperatorCanBurnToken() public { + // Mint token to alice + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + // Alice approves bob as operator + vm.prank(alice); + nft.setApprovalForAll(bob, true); + + // Bob can now burn alice's token + vm.prank(bob); + nft.burn(0); + + // Verify token is burned + vm.expectRevert(); + nft.ownerOf(0); + } + + function test_cannotBurnNonExistentToken() public { + vm.expectRevert(); + vm.prank(alice); + nft.burn(999); + } + + function test_burnEmitsTransferEvent() public { + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + vm.expectEmit(true, true, true, true); + emit IERC721.Transfer(alice, address(0), 0); + + vm.prank(alice); + nft.burn(0); + } + + function test_burnClearsTokenURI() public { + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + assertEq(nft.tokenURI(0), "ipfs://uri1"); + + vm.prank(alice); + nft.burn(0); + + // Token URI should be cleared after burn + vm.expectRevert(); + nft.tokenURI(0); + } + + // ============ Minting Enabled/Disabled Tests ============ + + function test_mintingEnabledByDefault() public view { + assertTrue(nft.mintingEnabled()); + } + + function test_adminCanDisableMinting() public { + vm.prank(admin); + nft.setMintingEnabled(false); + + assertFalse(nft.mintingEnabled()); + } + + function test_adminCanEnableMinting() public { + // Disable first + vm.prank(admin); + nft.setMintingEnabled(false); + + // Then enable + vm.prank(admin); + nft.setMintingEnabled(true); + + assertTrue(nft.mintingEnabled()); + } + + function test_nonAdminCannotSetMintingEnabled() public { + vm.prank(alice); + vm.expectRevert(); + nft.setMintingEnabled(false); + } + + function test_safeMintRevertsWhenMintingDisabled() public { + // Disable minting + vm.prank(admin); + nft.setMintingEnabled(false); + + // Try to mint + vm.prank(alice); + vm.expectRevert(BatchMintNFT.MintingDisabled.selector); + nft.safeMint(alice, "ipfs://uri1"); + } + + function test_batchSafeMintRevertsWhenMintingDisabled() public { + // Disable minting + vm.prank(admin); + nft.setMintingEnabled(false); + + // Try to batch mint + address[] memory recipients = new address[](1); + recipients[0] = alice; + string[] memory uris = new string[](1); + uris[0] = "ipfs://uri1"; + + vm.prank(alice); + vm.expectRevert(BatchMintNFT.MintingDisabled.selector); + nft.batchSafeMint(recipients, uris); + } + + function test_setMintingEnabledEmitsEvent() public { + vm.expectEmit(true, true, true, true); + emit BatchMintNFT.MintingEnabledChanged(false); + + vm.prank(admin); + nft.setMintingEnabled(false); + } + + function test_mintingWorksAfterReenabling() public { + // Disable minting + vm.prank(admin); + nft.setMintingEnabled(false); + + // Re-enable minting + vm.prank(admin); + nft.setMintingEnabled(true); + + // Minting should work again + vm.prank(alice); + nft.safeMint(alice, "ipfs://uri1"); + + assertEq(nft.ownerOf(0), alice); + assertEq(nft.tokenURI(0), "ipfs://uri1"); + } +} From e432624bdae3c5e173977de00120297ccf3d7d1f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Sun, 28 Dec 2025 13:08:52 -0400 Subject: [PATCH 2/2] chore: update cspell configuration to include additional reserved names --- .cspell.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index c990957..ea69013 100644 --- a/.cspell.json +++ b/.cspell.json @@ -60,6 +60,8 @@ "Frontends", "testuser", "testhandle", - "douglasacost" + "douglasacost", + "UUPS", + "BMNFT" ] }