A complete ERC-721 walkthrough on AERE Network: the contract, the metadata, deployment, minting, and what the block explorer actually shows afterwards.
To take you from an empty file to a live ERC-721 collection on AERE Network, chain ID 2800, and to leave you able to prove, from your own terminal, that what you deployed is really there.
Developers who have written or read a little Solidity. You do not need prior NFT experience. If you have never deployed anything, do the first-contract course first, then come back.
Every figure below was read from one block, number 13,211,311, hex 0xc996af. A block is permanent, so unlike a reading taken at latest these exact values are still reproducible months from now: ask for that block by number and you get the same answer. Each row names the call that returns it.
| What | Value at block 13,211,311 | How you reproduce it |
|---|---|---|
| Chain ID | 0xaf0 (2800) | eth_chainId |
| Block timestamp | 0x6a79b0d0 = 2026-08-10 11:06:56 UTC | eth_getBlockByNumber("0xc996af").timestamp |
| Base fee | 1 Gwei (0x3b9aca00) | eth_getBlockByNumber("0xc996af").baseFeePerGas |
| Validators | 9 | qbft_getValidatorsByBlockNumber("0xc996af") |
| Transactions in that block | 0 | eth_getBlockByNumber("0xc996af").transactions |
| Height when you read this | higher, and moving | eth_blockNumber |
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["0xc996af",false]}'
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"qbft_getValidatorsByBlockNumber","params":["0xc996af"]}'ERC-20 tracks a single number per address. ERC-721 tracks an owner per identifier. That one change drives everything else:
ownerOf(uint256) is the core read. It returns an address, and for an identifier that was never minted it reverts, it does not return the zero address.balanceOf(address) returns how many items the address holds, not a divisible amount.decimals(). Tooling that assumes ERC-20 will call it and get a revert.totalSupply() in the core standard either. It arrives only with the optional Enumerable extension.tokenURI(uint256) comes from the optional Metadata extension, and it is the string that points at the picture and the traits.ERC-20 and ERC-721 both emit an event called Transfer, and its topic 0 is identical in both, because the signature text is the same:
keccak256("Transfer(address,address,uint256)")
= 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3efThe difference is what is indexed. In ERC-20 the third argument is a value and is not indexed, so the log carries 3 topics and the amount sits in the data field. In ERC-721 the third argument is the token identifier and is indexed, so the log carries 4 topics and the data field is empty. An indexer written for ERC-20 will happily read an ERC-721 transfer and print a token id in the amount column. When you look at your own mint receipt in step 7, count the topics.
A collection announces its interfaces through supportsInterface(bytes4). An interface id is the XOR of the selectors of every function in that interface, and a selector is the first four bytes of the keccak-256 hash of the signature. The identifiers this page uses:
| Interface | id | XOR of these selectors |
|---|---|---|
| ERC-165 | 0x01ffc9a7 | supportsInterface(bytes4) |
| ERC-721 core | 0x80ac58cd | balanceOf(address), ownerOf(uint256), safeTransferFrom(address,address,uint256), safeTransferFrom(address,address,uint256,bytes), transferFrom(address,address,uint256), approve(address,uint256), setApprovalForAll(address,bool), getApproved(uint256), isApprovedForAll(address,address) |
| ERC-721 Metadata | 0x5b5e139f | name(), symbol(), tokenURI(uint256) |
| ERC-721 Enumerable | 0x780e9d63 | totalSupply(), tokenOfOwnerByIndex(address,uint256), tokenByIndex(uint256) |
Recomputed for this page by hashing the signature list in the third column and XOR-ing the results, not copied from memory. Do the same and you will land on the same four values.
0x01ffc9a7 appearing twice on this page is not a typo. ERC-165 has exactly one function, so the XOR of its selectors is that single selector: the interface id and the selector of supportsInterface(bytes4) are the same four bytes by construction.supportsInterface(0xffffffff) must return false. That is the check that separates a real ERC-165 implementation from a contract that returns true for everything, and it is the negative control used in the verification section at the end.0xDdBe942aD9eB0F3E7C541BdCF7CC2cfA29d35aE4, and on 2026-08-10 eth_getBalance returned 0x0 against 1,655 bytes of deployed code. Code present, balance empty.
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance",
"params":["0xDdBe942aD9eB0F3E7C541BdCF7CC2cfA29d35aE4","latest"]}'| Field | Value |
|---|---|
| Chain ID | 2800 (0xAF0) |
| Currency | AERE, 18 decimals |
| RPC | https://rpc.aere.network |
| Second RPC | https://rpc2.aere.network |
| Explorer | https://explorer.aere.network |
The block a wallet needs in order to add the network, ready to paste:
{
"chainId": "0xAF0",
"chainName": "AERE Network",
"nativeCurrency": { "name": "AERE", "symbol": "AERE", "decimals": 18 },
"rpcUrls": ["https://rpc.aere.network"],
"blockExplorerUrls": ["https://explorer.aere.network"]
}AERE is the native coin of the chain, in the same way ETH is native on Ethereum. It has no token contract, so if a form asks you for the AERE token address, the correct answer is that there is not one.
Nothing here is hand rolled. The collection inherits OpenZeppelin's audited ERC-721, adds per-token metadata storage, and restricts minting to the owner. Sequential ids start at 1, so that id 0 never exists and an off-by-one bug surfaces immediately instead of silently.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title AereCourseNFT
/// @notice A minimal ERC-721 collection for AERE Network, chain ID 2800.
contract AereCourseNFT is ERC721, ERC721URIStorage, Ownable {
uint256 private _nextTokenId = 1;
event Minted(address indexed to, uint256 indexed tokenId, string uri);
constructor(address initialOwner)
ERC721("Aere Course Collection", "ACC")
Ownable(initialOwner)
{}
function safeMint(address to, string calldata uri)
external
onlyOwner
returns (uint256 tokenId)
{
tokenId = _nextTokenId++;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
emit Minted(to, tokenId, uri);
}
function nextTokenId() external view returns (uint256) {
return _nextTokenId;
}
function totalMinted() external view returns (uint256) {
return _nextTokenId - 1;
}
function tokenURI(uint256 tokenId)
public
view
override(ERC721, ERC721URIStorage)
returns (string memory)
{
return super.tokenURI(tokenId);
}
function supportsInterface(bytes4 interfaceId)
public
view
override(ERC721, ERC721URIStorage)
returns (bool)
{
return super.supportsInterface(interfaceId);
}
}0.8.26+commit.8a97fa7a against OpenZeppelin Contracts 5.3.0, optimizer on at 200 runs, giving 0 errors and 0 warnings, creation bytecode 6,006 bytes and runtime bytecode 5,304 bytes.
Ownable takes no constructor argument.ERC721URIStorage lets every item carry its own URI. If your art is numbered 1.json, 2.json and so on under one folder, you can drop this and override _baseURI() instead, which is cheaper.Ownable gates minting. Without a gate, anyone can mint your collection, and that is not a theoretical risk._safeMint refuses to send to a contract that does not implement onERC721Received, selector 0x150b7a02. It is the difference between a token that arrives and a token that is stuck forever.override blocks are required, not decoration. Both parents define those functions, and Solidity will not guess which one wins.totalMinted() is our own helper. The core standard has no supply counter, so do not expect totalSupply() to answer unless you add the Enumerable extension._safeMint runs before _setTokenURI, and _safeMint calls back into a contract recipient. So a contract receiving the item sees an empty tokenURI during its own onERC721Received. That is harmless for a wallet, and it matters if you ever write a recipient that reads the metadata on arrival.ERC721 and ERC721URIStorage, not ERC721Enumerable. So when you reach step 9 and ask your own contract about 0x780e9d63, the honest answer is false, and nothing is broken. It also has no totalSupply(), which is why totalMinted() exists. Add ERC721Enumerable if you need on-chain listing of every token, and pay for it in gas on every mint and every transfer.
0x49064906, ERC-4906, which ERC721URIStorage brings with it. That is the metadata-update signal, and it is there because the extension emits it.These came out of the same compiler run as the byte counts above, so they are what the deployed bytecode will actually answer to. Each one is the first four bytes of the keccak-256 hash of the signature in the left column, which means you can recompute every row yourself without a compiler, for example with ethers.id("safeMint(address,string)").slice(0,10):
| Function | Selector |
|---|---|
| safeMint(address,string) | 0xd204c45e |
| ownerOf(uint256) | 0x6352211e |
| tokenURI(uint256) | 0xc87b56dd |
| balanceOf(address) | 0x70a08231 |
| name() | 0x06fdde03 |
| symbol() | 0x95d89b41 |
| supportsInterface(bytes4) | 0x01ffc9a7 |
| nextTokenId() | 0x75794a3c |
| totalMinted() | 0xa2309ff8 |
The chain stores a pointer, not a picture. tokenURI(id) returns a string, and everything a marketplace or wallet displays is fetched from wherever that string points. Get this wrong and your collection is a list of numbers.
The document at the end of that URI follows the ERC-721 metadata schema. A minimal, valid example:
{
"name": "Aere Course Collection #1",
"description": "Minted while following the ERC-721 course on chain 2800.",
"image": "ipfs://bafy.../1.png",
"attributes": [
{ "trait_type": "Course", "value": "ERC-721" },
{ "trait_type": "Chain", "value": "2800" }
]
}https:// URI means the owner of that domain can rewrite the artwork after the sale.data:application/json;base64, string from tokenURI and nothing external is needed. It costs far more storage, and for generative SVG work it is the only approach that survives everything else going offline.A live example from a collection already deployed on chain 2800, read back in step 9, returns this string:
tokenURI(1) = "ipfs://aere-tokenbound-demo"It resolves to nothing, and that is exactly the failure this step is here to prevent. The contract is valid ERC-721, the read succeeds, the item has an owner, and there is still no picture, because the pointer was never backed by pinned content. A chain can guarantee the pointer. It cannot guarantee what is at the other end.
AereCourseNFT.sol, paste the contract from step 3.0.8.20 or newer. Remix fetches the OpenZeppelin imports from npm on its own.initialOwner. Paste the address that will be allowed to mint. If you paste the wrong address here, you cannot mint your own collection, and the fix is a redeploy.eth_getBlockByNumber.eth_getCode on the address returns bytecode instead of 0x. That call is in the verification section, and it is the first thing to run.Call safeMint(to, uri) from the owner account: the recipient address, and the URI of the metadata document from step 4.
safeMint("0xYourRecipientAddress", "ipfs://bafy.../1.json")The first mint returns token id 1. Each further call increments. Read nextTokenId() if you want to know which id the next call will produce, or totalMinted() for how many exist.
Three logs, in this order. The middle one surprises people, so count carefully:
Transfer from the zero address to the recipient. That is what a mint is: a transfer out of nowhere. Four topics, empty data. Topic 0 is 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef, the hash shown earlier.MetadataUpdate(uint256), topic 0 0xf8e1a15aba9398e019f0b49df1a4fde98ee17ae345cb5f6b5e2c27f5033e8ce7. You did not write this event and it is not a bug. ERC721URIStorage._setTokenURI emits it, because ERC-4906 asks contracts to announce that an item's metadata changed. Its argument is not indexed, so the log carries one topic and the token id sits in the data field.Minted event, topic 0 0xe7cd4ce7f2a465edc730269a1305e8a48bad821e8fb7e152ec413829c01a53c4. Three topics, since to and tokenId are indexed, and the URI rides in the data field because a string cannot be indexed usefully.Every topic 0 above is the keccak-256 hash of the event signature as text, so you can regenerate all three yourself and confirm the page is not making them up:
keccak256("Transfer(address,address,uint256)")
= 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef
keccak256("MetadataUpdate(uint256)")
= 0xf8e1a15aba9398e019f0b49df1a4fde98ee17ae345cb5f6b5e2c27f5033e8ce7
keccak256("Minted(address,uint256,string)")
= 0xe7cd4ce7f2a465edc730269a1305e8a48bad821e8fb7e152ec413829c01a53c4safeMint reverts, read the error selector rather than guessing. An error selector is built exactly like a function selector, the first four bytes of the keccak-256 hash of the signature, so each of these is checkable in one line.
0x118cdaa7, from OwnableUnauthorizedAccount(address): you are minting from an account that is not the owner.0x64a0ae92, from ERC721InvalidReceiver(address): the recipient is a contract that cannot hold an ERC-721, and the safe path just stopped your item from being stranded there forever.The explorer follows the common URL layout, so both of these resolve for your collection:
https://explorer.aere.network/address/0xYourContractAddress
https://explorer.aere.network/token/0xYourContractAddress
https://explorer.aere.network/tx/0xYourMintTransactionHashSet your expectations correctly, because this is where courses usually oversell. The token view reads the ERC-20 field set: name, symbol, decimals, totalSupply. An ERC-721 has no decimals(), so that call reverts and the page says so in as many words. Opening the token view of the example collection on 2026-08-10 gave, verbatim: "Partial ERC-20: decimals did not return a value. Showing every field that resolved." Name, symbol and supply came through; the address view alongside it reported 7,217 bytes of contract code, matching the eth_getCode reading in the next section.
Three limits you will meet, and we would rather you met them here than at midnight:
totalSupply(). The contract in step 3 does not have that function, so for your own collection that field will not resolve either. Use totalMinted() over RPC instead.tokenURI, fetches the metadata and renders the item is a thing other explorers do and we do not, and saying otherwise would only waste your afternoon looking for a tab that is not there.What you do reliably get is the contract, its code, its balance and its transaction list. Until the rest exists, the RPC calls in the next section are the authoritative view of your collection, and they are the stronger evidence anyway, because they come from the chain rather than from a page rendered by us.
Replace 0xYourContractAddress with your own. Each check below has a control that is supposed to fail, because a check that cannot come back negative is not a check.
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode",
"params":["0xYourContractAddress","latest"]}'A long hex string means deployed code. 0x means nothing is there, whatever your wallet told you.
Positive control, ERC-721 core interface 0x80ac58cd:
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
"to":"0xYourContractAddress",
"data":"0x01ffc9a780ac58cd00000000000000000000000000000000000000000000000000000000"},
"latest"]}'Expected: a word ending in ...0001. Now the negative control, the reserved identifier 0xffffffff, which the standard requires to be false:
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
"to":"0xYourContractAddress",
"data":"0x01ffc9a7ffffffff00000000000000000000000000000000000000000000000000000000"},
"latest"]}'Expected: a word ending in ...0000. If both calls return 1, your contract answers true to everything and the first result proved nothing.
0x3f9A9D9CAB005327869396C69bE226ef98039f1c, every one of these was measured for this page on 2026-08-10: 0x80ac58cd returned 1, 0x5b5e139f returned 1, 0x780e9d63 returned 1, and 0xffffffff returned 0. Its runtime code is 7,217 bytes. Use it to check that your command line is right before you blame your own contract.
0x780e9d63 answers 1 for it. The contract you built in step 3 does not, so it will answer 0 to the same call. Same command, different contracts, and both answers are correct. If your contract returned 1 there, it would be claiming a totalSupply() it does not have.curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
"to":"0x3f9A9D9CAB005327869396C69bE226ef98039f1c",
"data":"0x06fdde03"},"latest"]}'The result is ABI encoded: a 32-byte offset, a 32-byte length, then the bytes. For the collection above the length is 0x08 and the bytes decode to AERE NFT; symbol(), selector 0x95d89b41, decodes to AERENFT. Decode with any library, for example ethers.AbiCoder.defaultAbiCoder().decode(["string"], result).
ownerOf(1), selector 0x6352211e followed by the id padded to 32 bytes:
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
"to":"0x3f9A9D9CAB005327869396C69bE226ef98039f1c",
"data":"0x6352211e0000000000000000000000000000000000000000000000000000000000000001"},
"latest"]}'Measured on 2026-08-10, that returns owner 0xbeb33d20dfbbd49ec7ac1f617667f1f02dfd6465. Now ask for an id that was never minted, id 2, by changing the last digit to 2. The node answers with an error, not an address:
{"code":3,"message":"Execution reverted (ERC721: invalid token ID)"}The revert payload that came back begins 0x08c379a0, the selector of the built-in Error(string), with the sentence encoded after it. That is the old style, and the wording is the one OpenZeppelin 4 used. Your contract from step 3 is on OpenZeppelin 5, which instead reverts with the custom error ERC721NonexistentToken(uint256), selector 0x7e273289, and carries no sentence at all. Different shapes, same meaning, and both selectors are the first four bytes of the keccak-256 hash of their signature if you want to confirm them. What matters is that a nonexistent item reverts instead of quietly reporting the zero address, which is what an ERC-20 habit would expect.
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
"to":"0x3f9A9D9CAB005327869396C69bE226ef98039f1c",
"data":"0xc87b56dd0000000000000000000000000000000000000000000000000000000000000001"},
"latest"]}'Decodes to ipfs://aere-tokenbound-demo, 27 bytes, as read on 2026-08-10. Then fetch what your own URI points at and confirm it is really there. The chain will not do that for you.
Fetch the receipt of your mint transaction. Expect three logs, and count the topics on the Transfer one: four topics means ERC-721, three means you are looking at an ERC-20. That single count is the whole difference described in step 1, visible in your own receipt.
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt",
"params":["0xYourMintTransactionHash"]}'To list every mint of your collection, ask for logs where topic 1, the sender, is the zero address. One important limit, measured on 2026-08-10: a query may span at most 5,000 blocks, that is toBlock minus fromBlock no greater than 5000. A span of 5,000 was accepted and 5,001 was rejected with:
{"code":-32005,"message":"Requested range exceeds maximum RPC range limit"}So walk the chain in windows rather than asking for its whole history at once:
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_getLogs","params":[{
"address":"0xYourContractAddress",
"fromBlock":"0xc98000","toBlock":"0xc99388",
"topics":[
"0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef",
"0x0000000000000000000000000000000000000000000000000000000000000000"]}]}'Both block bounds are hex, and the window above is under the limit. Widen it past 5,000 blocks and the node will tell you so.
curl -s -X POST https://rpc.aere.network \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}'Expected: 0xaf0. Anything else and every result above belongs to a different chain.
Measured on 2026-08-10 against https://rpc.aere.network. The chain figures were read from block 13,211,311, hex 0xc996af, timestamp 11:06:56 UTC, and that block is permanent, so asking for it by number returns the same values today as it will next year. Anything read at latest, including the current height, has moved since; re-run those calls. The compilation figures come from solc 0.8.26+commit.8a97fa7a with OpenZeppelin Contracts 5.3.0, optimizer on at 200 runs, and will differ on a different toolchain. Selectors, interface ids and event topics are keccak-256 hashes, so they never move and you can recompute every one of them. Figures we could not reproduce ourselves are not printed here.