Posts
Building a Revealable PFP NFT Contract with ERC-721 and IPFS
A profile-picture NFT contract has one deceptively simple job: connect each token ID to the correct metadata. The contract in this post does that with a reveal workflow. Before reveal, every minted token returns the same …
In this article
A profile-picture NFT contract has one deceptively simple job: connect each token ID to the correct metadata.
The contract in this post does that with a reveal workflow. Before reveal, every minted token returns the same placeholder metadata. After reveal, token 42 returns the metadata assigned to token 42.
before reveal
token 42 -> ipfs://<placeholder-cid>/placeholder.json
after reveal
token 42 -> ipfs://<metadata-cid>/42.json
The useful part is not merely concatenating a number onto a string. We also need to enforce the supply cap, reject nonexistent token IDs, stop minting before disclosure, prove that the final metadata was chosen before deployment, and freeze that metadata after reveal.
Important: This is an educational, free-mint contract. It has no payments, allowlist, per-wallet limit, pause control, royalties, or withdrawal logic. It has not been professionally audited. Test it locally and on a public testnet before even thinking about real value.
What We Are Building
Our contract will:
- implement ERC-721 and its metadata interface
- mint token IDs from
0through a maximum of9,999 - return one placeholder URI before reveal
- keep the final metadata-directory CID off-chain until reveal
- stop minting before the metadata is disclosed
- verify the final URI against a commitment stored at deployment
- permanently freeze the URI during a one-way reveal
- emit the ERC-4906 metadata-refresh event
- use canonical
ipfs://URIs instead of depending on one HTTP gateway
It will not use ERC721Enumerable. Enumeration is optional, and OpenZeppelin notes that it adds substantial gas overhead. One counter can report how many tokens were minted, while an indexer can reconstruct ownership from standard Transfer events.
Pin the Toolchain
Smart-contract tutorials age badly when they say “install the latest version” and hope for the best. This walkthrough pins:
- Foundry
1.7.1 - Solidity
0.8.30 - OpenZeppelin Contracts
5.6.1
Install Foundry using its official instructions, then select the pinned release:
foundryup -i v1.7.1
forge --version
Create the project and install the exact OpenZeppelin release:
forge init revealable-pfp
cd revealable-pfp
forge install --no-git OpenZeppelin/openzeppelin-contracts@v5.6.1
Update foundry.toml so the compiler and import path are explicit:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.30"
optimizer = true
optimizer_runs = 200
remappings = [
"@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/"
]
Version pinning does not make a contract safe. It does make the build reproducible, which is the minimum price of admission.
Upload Images Without Creating a Walkable Directory
For the image layer, I use my Pinata IPFS scripts for NFT projects.
The upload-files.js script uploads each image as an individual Pinata item and records a filename-to-CID mapping:
{
"0.png": "<CID_FOR_IMAGE_0>",
"1.png": "<CID_FOR_IMAGE_1>"
}
Each metadata document then points directly to its image’s independent CID:
{
"name": "Example PFP #0",
"description": "An example profile-picture NFT.",
"image": "ipfs://<CID_FOR_IMAGE_0>",
"attributes": [
{
"trait_type": "Background",
"value": "Blue"
}
]
}
That matters. If every image lived at ipfs://<image-directory-cid>/0.png, someone who found one URL could try nearby filenames. Independent image CIDs do not expose a shared directory to walk.
The metadata files are intentionally different. They need predictable token-ID filenames so the contract can map a token to its JSON document:
metadata/
+-- 0.json
+-- 1.json
+-- 2.json
+-- ...
+-- 9999.json
Use upload-folder.js to pin that metadata directory. The final base URI has this shape:
ipfs://<METADATA_DIRECTORY_CID>/
The trailing slash matters because the contract appends 0.json, 1.json, and so on. The contract validates that slash before it accepts the URI.
The scripts repository also demonstrates extensionless metadata files such as 0. This tutorial deliberately uses 0.json. Keep the filenames and contract suffix consistent: if you choose extensionless files, remove ".json" from tokenURI() and its tests.
Create and pin a separate placeholder metadata document as well:
ipfs://<PLACEHOLDER_DIRECTORY_CID>/placeholder.json
There is one boundary worth saying plainly: once the metadata-directory CID is public, the metadata is walkable. Each numbered JSON document reveals its independently pinned image CID. That is why the contract does not store the final base URI before reveal.
IPFS recommends ipfs:// as the canonical URI because an HTTP gateway URL couples the token to one gateway operator. Applications can translate the URI into a gateway URL when they need ordinary browser access. The IPFS NFT data guide covers that distinction in detail.
A CID protects content integrity; it does not guarantee availability. Keep the data pinned through infrastructure you control or a provider you trust.
Commit to the Metadata Before Deployment
Keeping the CID hidden should not give the owner permission to choose a more favorable mapping after people mint. We need both secrecy and evidence that the decision was already made.
Compute a Keccak-256 commitment from the exact final base URI, including its trailing slash:
cast keccak "ipfs://<metadata-cid>/"
Save the resulting bytes32 value. The contract stores that commitment at deployment and later verifies that the URI supplied to reveal() hashes to the same value.
before mint:
publish keccak256(final base URI)
at reveal:
disclose final base URI
contract verifies its hash
Do not publish the retrievable metadata CID if the reveal is supposed to remain hidden. Publish the commitment. A commitment proves consistency without putting the actual directory address in the contract early.
The Complete Contract
Replace the generated src/Counter.sol with src/RevealablePFP.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {IERC165} from "@openzeppelin/contracts/interfaces/IERC165.sol";
import {IERC4906} from "@openzeppelin/contracts/interfaces/IERC4906.sol";
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Strings} from "@openzeppelin/contracts/utils/Strings.sol";
/// @title A capped-supply, revealable profile-picture NFT example
/// @notice Free minting is intentional for this educational contract
contract RevealablePFP is IERC4906, ERC721, Ownable {
using Strings for uint256;
bytes4 private constant ERC4906_INTERFACE_ID = 0x49064906;
uint256 public constant MAX_SUPPLY = 10_000;
bytes32 public immutable metadataCommitment;
string private _baseTokenURI;
string private _placeholderURI;
uint256 private _nextTokenId;
bool public mintingClosed;
bool public revealed;
event MintingClosed(uint256 finalSupply);
event CollectionRevealed(string baseURI);
error InvalidBaseURI();
error InvalidPlaceholderURI();
error MaxSupplyReached();
error MetadataAlreadyRevealed();
error MetadataCommitmentMismatch();
error MintingAlreadyClosed();
error MintingIsClosed();
error MintingStillOpen();
error NoTokensMinted();
error ZeroMetadataCommitment();
constructor(
string memory name,
string memory symbol,
string memory placeholderURI,
bytes32 expectedMetadataCommitment,
address initialOwner
) ERC721(name, symbol) Ownable(initialOwner) {
if (!_hasIPFSScheme(bytes(placeholderURI))) {
revert InvalidPlaceholderURI();
}
if (expectedMetadataCommitment == bytes32(0)) {
revert ZeroMetadataCommitment();
}
_placeholderURI = placeholderURI;
metadataCommitment = expectedMetadataCommitment;
}
/// @notice Mint the next token to the caller
function mint() external returns (uint256 tokenId) {
if (mintingClosed) {
revert MintingIsClosed();
}
tokenId = _nextTokenId;
if (tokenId >= MAX_SUPPLY) {
revert MaxSupplyReached();
}
// Update before _safeMint because _safeMint may call the recipient.
unchecked {
_nextTokenId = tokenId + 1;
}
_safeMint(msg.sender, tokenId);
}
/// @notice Permanently stop minting, even if the cap was not reached
function closeMinting() external onlyOwner {
if (mintingClosed) {
revert MintingAlreadyClosed();
}
mintingClosed = true;
emit MintingClosed(_nextTokenId);
}
/// @notice Disclose and permanently set the committed metadata URI
function reveal(string calldata finalBaseURI) external onlyOwner {
if (revealed) {
revert MetadataAlreadyRevealed();
}
if (!mintingClosed) {
revert MintingStillOpen();
}
if (_nextTokenId == 0) {
revert NoTokensMinted();
}
if (!_isValidBaseURI(bytes(finalBaseURI))) {
revert InvalidBaseURI();
}
if (keccak256(bytes(finalBaseURI)) != metadataCommitment) {
revert MetadataCommitmentMismatch();
}
_baseTokenURI = finalBaseURI;
revealed = true;
emit CollectionRevealed(finalBaseURI);
emit BatchMetadataUpdate(0, _nextTokenId - 1);
}
function tokenURI(
uint256 tokenId
) public view override returns (string memory) {
_requireOwned(tokenId);
if (!revealed) {
return _placeholderURI;
}
return string.concat(
_baseTokenURI,
tokenId.toString(),
".json"
);
}
function totalMinted() external view returns (uint256) {
return _nextTokenId;
}
function supportsInterface(
bytes4 interfaceId
) public view override(ERC721, IERC165) returns (bool) {
return
interfaceId == ERC4906_INTERFACE_ID ||
super.supportsInterface(interfaceId);
}
function _hasIPFSScheme(
bytes memory uri
) private pure returns (bool) {
return
uri.length > 7 &&
uri[0] == 0x69 && // i
uri[1] == 0x70 && // p
uri[2] == 0x66 && // f
uri[3] == 0x73 && // s
uri[4] == 0x3a && // :
uri[5] == 0x2f && // /
uri[6] == 0x2f; // /
}
function _isValidBaseURI(
bytes memory uri
) private pure returns (bool) {
return
uri.length >= 9 &&
_hasIPFSScheme(uri) &&
uri[uri.length - 1] == 0x2f;
}
}
Compile it before doing anything more interesting:
forge fmt
forge build
Why the Contract Is Shaped This Way
The Counter Owns Token-ID Assignment
_nextTokenId starts at 0, so the first mint receives token 0. The supply check allows IDs 0 through 9,999—exactly 10,000 possible tokens.
The counter increments before _safeMint. Safe minting may call code on a contract recipient, so state is updated before that external interaction. If minting fails, the whole transaction reverts, including the counter update.
Minting Closes Before Reveal
The owner must call closeMinting() before reveal(). Once closed, minting cannot resume.
This matters because sequential token metadata becomes inspectable at reveal. If minting remained open, someone could examine the next unminted token and decide whether it was worth minting. Closing first removes that particular bit of gamesmanship.
The reveal URI is visible in transaction calldata and may be visible in the public mempool before confirmation. That is acceptable here because no more tokens can be minted by then.
Reveal Sets and Freezes the URI in One Transaction
There is no pre-reveal setBaseURI() function, getter, or event. Calling reveal(finalBaseURI) is the first time the metadata-directory URI enters contract storage.
Solidity’s private keyword is not secrecy—blockchain storage remains observable. The design keeps the URI off-chain rather than pretending a private state variable can hide it.
The contract validates the ipfs:// scheme and trailing slash, checks the deployment-time commitment, stores the URI, and changes revealed to true. There is no function that can change it afterward.
ERC-4906 Tells Indexers to Refresh
Reveal changes the result of tokenURI() for every minted token. The standard BatchMetadataUpdate event tells compatible marketplaces and indexers to request those values again. The contract also reports support for the ERC-4906 interface ID.
That does not force every platform to refresh immediately, but it gives them the standard signal instead of making them understand a custom event.
Test the Contract
Delete the generated counter test and create test/RevealablePFP.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Test} from "forge-std/Test.sol";
import {RevealablePFP} from "../src/RevealablePFP.sol";
contract RevealablePFPTest is Test {
RevealablePFP private collection;
address private owner = makeAddr("owner");
address private alice = makeAddr("alice");
string private constant PLACEHOLDER_URI =
"ipfs://placeholder-cid/placeholder.json";
string private constant BASE_URI = "ipfs://metadata-cid/";
function setUp() public {
collection = new RevealablePFP(
"Example PFP",
"EPFP",
PLACEHOLDER_URI,
keccak256(bytes(BASE_URI)),
owner
);
}
function test_MintStartsAtZeroAndReturnsPlaceholder() public {
vm.prank(alice);
uint256 tokenId = collection.mint();
assertEq(tokenId, 0);
assertEq(collection.ownerOf(0), alice);
assertEq(collection.totalMinted(), 1);
assertEq(collection.tokenURI(0), PLACEHOLDER_URI);
}
function test_TokenURIRevertsForNonexistentToken() public {
vm.expectRevert(
abi.encodeWithSignature(
"ERC721NonexistentToken(uint256)",
42
)
);
collection.tokenURI(42);
}
function test_OnlyOwnerCanCloseMinting() public {
vm.expectRevert(
abi.encodeWithSignature(
"OwnableUnauthorizedAccount(address)",
alice
)
);
vm.prank(alice);
collection.closeMinting();
}
function test_RevealRequiresClosedMinting() public {
vm.prank(alice);
collection.mint();
vm.expectRevert(RevealablePFP.MintingStillOpen.selector);
vm.prank(owner);
collection.reveal(BASE_URI);
}
function test_ClosedMintingCannotResume() public {
vm.prank(owner);
collection.closeMinting();
vm.expectRevert(RevealablePFP.MintingIsClosed.selector);
vm.prank(alice);
collection.mint();
}
function test_OnlyOwnerCanReveal() public {
vm.prank(alice);
collection.mint();
vm.prank(owner);
collection.closeMinting();
vm.expectRevert(
abi.encodeWithSignature(
"OwnableUnauthorizedAccount(address)",
alice
)
);
vm.prank(alice);
collection.reveal(BASE_URI);
}
function test_RevealUsesCommittedMetadata() public {
vm.prank(alice);
collection.mint();
vm.startPrank(owner);
collection.closeMinting();
collection.reveal(BASE_URI);
vm.stopPrank();
assertTrue(collection.revealed());
assertEq(
collection.tokenURI(0),
"ipfs://metadata-cid/0.json"
);
assertTrue(collection.supportsInterface(0x49064906));
}
function test_RevealRejectsMissingTrailingSlash() public {
vm.prank(alice);
collection.mint();
vm.prank(owner);
collection.closeMinting();
vm.expectRevert(RevealablePFP.InvalidBaseURI.selector);
vm.prank(owner);
collection.reveal("ipfs://metadata-cid");
}
function test_RevealRejectsHttpURI() public {
vm.prank(alice);
collection.mint();
vm.prank(owner);
collection.closeMinting();
vm.expectRevert(RevealablePFP.InvalidBaseURI.selector);
vm.prank(owner);
collection.reveal("https://gateway.example/ipfs/cid/");
}
function test_RevealRequiresAtLeastOneToken() public {
vm.prank(owner);
collection.closeMinting();
vm.expectRevert(RevealablePFP.NoTokensMinted.selector);
vm.prank(owner);
collection.reveal(BASE_URI);
}
function test_ConstructorRejectsHttpPlaceholder() public {
vm.expectRevert(
RevealablePFP.InvalidPlaceholderURI.selector
);
new RevealablePFP(
"Example PFP",
"EPFP",
"https://gateway.example/placeholder.json",
keccak256(bytes(BASE_URI)),
owner
);
}
function test_RevealRejectsDifferentMetadata() public {
vm.prank(alice);
collection.mint();
vm.prank(owner);
collection.closeMinting();
vm.expectRevert(
RevealablePFP.MetadataCommitmentMismatch.selector
);
vm.prank(owner);
collection.reveal("ipfs://different-cid/");
}
function test_RevealCannotRunTwice() public {
vm.prank(alice);
collection.mint();
vm.startPrank(owner);
collection.closeMinting();
collection.reveal(BASE_URI);
vm.expectRevert(
RevealablePFP.MetadataAlreadyRevealed.selector
);
collection.reveal(BASE_URI);
vm.stopPrank();
}
function test_MintsExactlyMaxSupply() public {
vm.startPrank(alice);
for (uint256 i = 0; i < collection.MAX_SUPPLY(); ++i) {
collection.mint();
}
assertEq(collection.totalMinted(), 10_000);
assertEq(collection.ownerOf(9_999), alice);
vm.expectRevert(RevealablePFP.MaxSupplyReached.selector);
collection.mint();
vm.stopPrank();
}
}
Run the complete validation pass:
forge fmt --check
forge build
forge test -vvvv
forge lint
The 10,000-mint boundary test is intentionally heavier than the others. It proves the cap rather than asking us to trust arithmetic that merely looks friendly.
Exercise the Workflow Locally
Start Anvil in one terminal:
anvil
Anvil prints prefunded development addresses and private keys. Use those keys only on the local chain—they are public and catastrophically unsafe anywhere else.
Set OWNER_ADDRESS to Anvil’s first address. Set PLACEHOLDER_URI, METADATA_BASE_URI, and its matching METADATA_COMMITMENT, then deploy directly:
forge create src/RevealablePFP.sol:RevealablePFP \
--rpc-url http://127.0.0.1:8545 \
--private-key <ANVIL_PRIVATE_KEY> \
--broadcast \
--constructor-args \
"Example PFP" \
"EPFP" \
"$PLACEHOLDER_URI" \
"$METADATA_COMMITMENT" \
"$OWNER_ADDRESS"
Save the printed address as LOCAL_CONTRACT_ADDRESS, then exercise the state changes:
cast send "$LOCAL_CONTRACT_ADDRESS" "mint()" \
--rpc-url http://127.0.0.1:8545 \
--private-key <ANVIL_PRIVATE_KEY>
cast send "$LOCAL_CONTRACT_ADDRESS" "closeMinting()" \
--rpc-url http://127.0.0.1:8545 \
--private-key <ANVIL_PRIVATE_KEY>
cast send "$LOCAL_CONTRACT_ADDRESS" "reveal(string)" \
"$METADATA_BASE_URI" \
--rpc-url http://127.0.0.1:8545 \
--private-key <ANVIL_PRIVATE_KEY>
cast call "$LOCAL_CONTRACT_ADDRESS" \
"tokenURI(uint256)(string)" 0 \
--rpc-url http://127.0.0.1:8545
That gives the entire workflow a local rehearsal before you spend testnet ETH.
Deploy to Sepolia
Create script/DeployRevealablePFP.s.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;
import {Script} from "forge-std/Script.sol";
import {RevealablePFP} from "../src/RevealablePFP.sol";
contract DeployRevealablePFP is Script {
function run() external returns (RevealablePFP collection) {
address owner = vm.envAddress("OWNER_ADDRESS");
string memory placeholderURI = vm.envString(
"PLACEHOLDER_URI"
);
bytes32 commitment = vm.envBytes32(
"METADATA_COMMITMENT"
);
vm.startBroadcast();
collection = new RevealablePFP(
"Example PFP",
"EPFP",
placeholderURI,
commitment,
owner
);
vm.stopBroadcast();
}
}
Set these environment variables without committing them:
SEPOLIA_RPC_URL=https://<your-sepolia-rpc>
ETHERSCAN_API_KEY=<your-api-key>
OWNER_ADDRESS=0x<your-owner-address>
PLACEHOLDER_URI=ipfs://<placeholder-cid>/placeholder.json
METADATA_BASE_URI=ipfs://<metadata-cid>/
METADATA_COMMITMENT=0x<keccak256-of-the-final-base-uri>
Foundry recommends encrypted keystores instead of plaintext private-key variables. Import dedicated testnet accounts interactively:
cast wallet import deployer --interactive
cast wallet import minter --interactive
Fund both addresses with Sepolia ETH. The examples assume OWNER_ADDRESS belongs to deployer; a real project should normally use a properly secured multisignature wallet instead.
Simulate the deployment first:
forge script script/DeployRevealablePFP.s.sol:DeployRevealablePFP \
--rpc-url "$SEPOLIA_RPC_URL" \
--account deployer
Only after the simulation succeeds should you broadcast and verify it:
forge script script/DeployRevealablePFP.s.sol:DeployRevealablePFP \
--rpc-url "$SEPOLIA_RPC_URL" \
--account deployer \
--broadcast \
--verify \
--etherscan-api-key "$ETHERSCAN_API_KEY"
Save the deployed contract address as CONTRACT_ADDRESS.
Exercise the Reveal on Sepolia
Mint from the non-owner account:
cast send "$CONTRACT_ADDRESS" "mint()" \
--rpc-url "$SEPOLIA_RPC_URL" \
--account minter
Before reveal, token 0 should return the placeholder:
cast call "$CONTRACT_ADDRESS" "tokenURI(uint256)(string)" 0 \
--rpc-url "$SEPOLIA_RPC_URL"
Close minting permanently:
cast send "$CONTRACT_ADDRESS" "closeMinting()" \
--rpc-url "$SEPOLIA_RPC_URL" \
--account deployer
Now disclose and verify the committed URI in one transaction:
cast send "$CONTRACT_ADDRESS" "reveal(string)" \
"$METADATA_BASE_URI" \
--rpc-url "$SEPOLIA_RPC_URL" \
--account deployer
Read token 0 again. The result should now be:
ipfs://<metadata-cid>/0.json
Finally, try mint() and reveal() again. They should revert with MintingIsClosed and MetadataAlreadyRevealed. Failure paths are part of the workflow; immutable behavior is only useful when we verify it.
What This Contract Still Does Not Solve
This example is intentionally small. A production project still needs decisions and testing around:
- Mint access: anyone can mint repeatedly until the owner closes minting or the cap is reached.
- Pricing and funds: minting is free and the contract cannot collect a mint payment.
- Reentrancy and callbacks: state is updated before
_safeMint, but future payment or per-wallet logic changes the threat model. - Administrative security: the owner decides when to close and reveal. Use a multisignature wallet and publish the commitment before minting.
- Random assignment: independent image CIDs prevent URL walking, but sequential token IDs are not random allocation.
- Availability: a valid CID does not keep itself pinned.
- Marketplace behavior: ERC-4906 provides a refresh signal, not a guarantee that every platform reacts immediately.
- Royalties: ERC-2981 signaling is not included, and enforcement is a separate marketplace concern.
- Legal and operational work: a compiling contract is not a launch plan.
Adding all of that to one beginner contract would hide the metadata lesson under a pile of unrelated machinery. Small contracts are easier to explain, test, and distrust appropriately.
Quick Recap
The finished flow is:
- Pin each image individually and record its CID.
- Put those individual image URIs in
0.jsonthrough9999.json. - Pin the metadata directory and record its CID.
- Compute and publish a hash commitment to the final base URI.
- Deploy with the placeholder URI and commitment—but not the final URI.
- Test minting and
tokenURIlocally and on Sepolia. - Permanently close minting.
- Reveal the committed URI in one transaction.
- Verify the metadata refresh and confirm that minting and reveal cannot run again.
The smart contract is not the picture. It is the set of rules connecting ownership, token IDs, and metadata. Make those rules explicit, test the boundaries, and leave as little trust hiding between the lines as possible.
-Rob