This repository contains an upgradeable ERC721 NFT contract using Foundry and OpenZeppelin's UUPS Proxy.
Ensure you have the following installed:
- Foundry (Installation Guide)
- Anvil (included with Foundry)
- A funded wallet if deploying on a testnet or mainnet
Anvil provides a local Ethereum blockchain for testing. Start it with:
anvil
By default, it runs on http://127.0.0.1:8545
and pre-funds test accounts with 10,000 ETH.
To simplify deployment, set up environment variables:
export PRIVATE_KEY=0xYourPrivateKeyHere # Or the private key given by anvil if this is a local testing
export RPC_URL=http://127.0.0.1:8545
Alternatively, create a .env
file and load it:
source .env
Run all tests:
forge test
Run a specific test:
forge test --match-test testFunctionName
Enable detailed traces:
forge test -vvv
The deployment script initializes both the contract implementation and the proxy:
export PRIVATE_KEY=0xYourPrivateKeyHere
forge script scripts/Deploy.s.sol --broadcast --rpc-url $RPC_URL
This script will:
- Deploy the implementation contract (
TokenFactoryImplem.sol
). - Deploy a UUPS Proxy that points to the implementation.
- Call the
initialize()
function on the implementation through the proxy.
After deployment you can store the proxy address and for further interaction you can do the following:
export PROXY_ADDRESS=0xLoggedAddressAfterDeployment
Verify the contract is working:
cast call $PROXY_ADDRESS "getDistributorName()" --rpc-url $RPC_URL
If you want to upgrade the contract, modify the logic in _contracts/TokenFactoryImplem.sol
.
Ensure you do not change the contract's storage layout.
For more details on upgradeable contracts and storage compatibility, see OpenZeppelin's guide:
https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies#upgrading
Deploy the new implementation and upgrade the proxy to point to it:
export PROXY_ADDRESS=0xLoggedAddressAfterDeployment
export PRIVATE_KEY=0xYourPrivateKeyHere
forge script scripts/Upgrade.s.sol --broadcast --rpc-url $RPC_URL
This will:
- Deploy the updated contract (
TokenFactoryImplem.sol
). NOTE: You can directly modify the TokenFactoryImplem in_contracts/
and then run the script to upgrade it - Upgrade the proxy to use the new implementation.
After performing an upgrade, you can confirm that the new implementation is active by checking the contract version. The contract includes a version()
function that increments automatically with each upgrade.
cast call $PROXY_ADDRESS "version()" --rpc-url $RPC_URL
Additionally, you can verify that the new logic is in use by calling a function that was modified in the new implementation, this is an example with getDistributorName
:
cast call $PROXY_ADDRESS "getDistributorName()" --rpc-url $RPC_URL
If the upgrade was successful, version()
should return an incremented value, and getDistributorName()
should reflect any changes made in the upgraded contract.
Instead of running Anvil, you can deploy to Polygon mainnet using a public RPC:
export RPC_URL=https://polygon-rpc.com
forge script scripts/Deploy.s.sol --broadcast --rpc-url $RPC_URL
after some changes in _contracts/TokenFactoryImplem.sol
forge script scripts/Upgrade.s.sol --broadcast --rpc-url $RPC_URL
To mint a new NFT, call the mintNFT
function from the contract using the proxy address. This function requires the recipient’s address and the metadata URI of the token:
cast send $PROXY_ADDRESS "mintNFT(address,string)" 0xRecipientAddress "ipfs://your-metadata-uri" --rpc-url $RPC_URL --private-key $PRIVATE_KEY
Only the contract owner can execute this function. After minting, you can verify the token's metadata with:
cast call $PROXY_ADDRESS "tokenURI(uint256)" 0 --rpc-url $RPC_URL
Replace 0
with the correct token ID if multiple NFTs exist.
To check which NFTs are owned by a specific address, use the tokensOfOwner
function. This function returns an array of token IDs belonging to the specified address.
cast call $PROXY_ADDRESS "tokensOfOwner(address)" 0xUserAddress --rpc-url $RPC_URL
This will return an array of token IDs owned by 0xUserAddress
. If the user owns multiple NFTs, you will get something like:
[1, 3, 7, 10]
If the array is empty ([]
), the user does not own any NFTs.
Once you have the token ID, you can check its metadata using:
cast call $PROXY_ADDRESS "tokenURI(uint256)" 1 --rpc-url $RPC_URL
Replace 1
with the actual token ID from the previous step.
To verify the owner of a specific NFT:
cast call $PROXY_ADDRESS "ownerOf(uint256)" 1 --rpc-url $RPC_URL
This will return the wallet address that owns token ID 1
.
These commands allow you to track which tokens belong to a specific address and verify their metadata.
Task | Command |
---|---|
Start local blockchain | anvil |
Run tests | forge test |
Deploy contract locally | export RPC_URL=<RPC_URL> && export PRIVATE_KEY=0xYourPrivateKeyHere && forge script scripts/Deploy.s.sol --broadcast --rpc-url $RPC_URL |
Upgrade contract | export RPC_URL=<RPC_URL> && export PROXY_ADDRESS=0xLoggedAddressAfterDeployment && export PRIVATE_KEY=0xYourPrivateKeyHere && forge script scripts/Upgrade.s.sol --broadcast --rpc-url $RPC_URL |
Check implementation version | cast call $PROXY_ADDRESS "version()" --rpc-url $RPC_URL |
Mint an NFT | cast send $PROXY_ADDRESS "mintNFT(address,string)" 0xRecipientAddress "ipfs://your-metadata-uri" --rpc-url $RPC_URL --private-key $PRIVATE_KEY |
Check token metadata | cast call $PROXY_ADDRESS "tokenURI(uint256)" <TokenID> --rpc-url $RPC_URL |
Check all NFTs owned by an address | cast call $PROXY_ADDRESS "tokensOfOwner(address)" 0xUserAddress --rpc-url $RPC_URL |
Check the owner of a specific NFT | cast call $PROXY_ADDRESS "ownerOf(uint256)" <TokenID> --rpc-url $RPC_URL |
Burn an NFT | cast send $PROXY_ADDRESS "burn(uint256)" <TokenID> --rpc-url $RPC_URL --private-key $PRIVATE_KEY |
Although some testing has been conducted, it's important to note that this contract is relatively small and straightforward, leveraging OpenZeppelin's battle-tested libraries for ERC721 functionality and upgradeability. Given that most of the core logic (minting, burning, ownership tracking) is inherited from OpenZeppelin's well-audited contracts, the likelihood of vulnerabilities is minimal.
However, the tests includes static analysis, fuzzing, and unit tests. It has been performed to ensure robustness and correctness, even if it may seem overkill for such a simple implementation.
No problematic pattern found.
Slither: slither . --json slither_out.json --filter-paths "lib/*"
. analyzed (30 contracts with 94 detectors), 0 result(s) found
{
"success": true,
"error": null,
"results": {}
}
No problematic behavior found.
Foundry: forge test --fuzz-runs 1000
Ran 12 tests for tests/TokenFactoryImplemTest.t.sol:TokenFactoryImplemTest
[PASS] testBurnByNonOwner() (gas: 149784)
[PASS] testBurnByNonOwnerDoesNotAffectTrueOwner() (gas: 156876)
[PASS] testBurnNFT() (gas: 120035)
[PASS] testBurnNonExistentToken() (gas: 15647)
[PASS] testInitializeCanOnlyBeCalledOnce() (gas: 15624)
[PASS] testMintFailByNonOwner() (gas: 18672)
[PASS] testMintNFT() (gas: 149116)
[PASS] testOwner() (gas: 17741)
[PASS] testTokensOfOwnerAfterBurn() (gas: 118983)
[PASS] testTokensOfOwnerAfterMint() (gas: 143745)
[PASS] testUpgrade() (gas: 1603011)
[PASS] testUpgradeFail() (gas: 1593014)
Suite result: ok. 12 passed; 0 failed; 0 skipped; finished in 1.79ms (3.35ms CPU time)
Ran 1 test for tests/TokenFactoryImplemFuzz.t.sol:TokenFactoryImplemFuzz
[PASS] testMassMintBurn(uint256,uint256) (runs: 1001, μ: 93898751, ~: 88014325)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 74.87s (74.87s CPU time)
Ran 2 test suites in 74.87s (74.87s CPU time): 13 tests passed, 0 failed, 0 skipped (13 total tests)