Back to AERE Academy

Mint an NFT collection on chain 2800

A complete ERC-721 walkthrough on AERE Network: the contract, the metadata, deployment, minting, and what the block explorer actually shows afterwards.

Intermediate About 90 minutes 9 steps Last measured 2026-08-10, 11:07 UTC

What this page is for

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.

Who it is for

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.

What you will know at the end

Chain snapshot, and how to reproduce it

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.

WhatValue at block 13,211,311How you reproduce it
Chain ID0xaf0 (2800)eth_chainId
Block timestamp0x6a79b0d0 = 2026-08-10 11:06:56 UTCeth_getBlockByNumber("0xc996af").timestamp
Base fee1 Gwei (0x3b9aca00)eth_getBlockByNumber("0xc996af").baseFeePerGas
Validators9qbft_getValidatorsByBlockNumber("0xc996af")
Transactions in that block0eth_getBlockByNumber("0xc996af").transactions
Height when you read thishigher, and movingeth_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"]}'
Read this before you plan your afternoon. Chain 2800 is nearly empty today: the blocks sampled for this page carried zero transactions. The ten validators are all operated by the Foundation (ten validators since 2026-09-11), and we say so rather than implying otherwise. Nothing on this page depends on traffic, but do not read activity into the low numbers you will see.

Step 1. What an ERC-721 really is

ERC-20 tracks a single number per address. ERC-721 tracks an owner per identifier. That one change drives everything else:

The trap that catches indexers

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)")
= 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef

The 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.

ERC-165, and how a contract declares what it is

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:

InterfaceidXOR of these selectors
ERC-1650x01ffc9a7supportsInterface(bytes4)
ERC-721 core0x80ac58cdbalanceOf(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 Metadata0x5b5e139fname(), symbol(), tokenURI(uint256)
ERC-721 Enumerable0x780e9d63totalSupply(), 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.
The standard also demands a negative answer: 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.

Step 2. What you need, and one honest limitation

There is no public way to obtain AERE today. The faucet contract is deployed but holds nothing, so it cannot pay out, and we are not going to invent a route that does not exist. Do not take that on trust, check it: the faucet is at 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"]}'
Steps 1 to 5 and step 9 work with no funds at all. Steps 6 and 7 write to the chain and need an already funded address. If you do not have one, do the whole course against the in-browser Remix VM first, which is free and behaves the same, then repeat the two write steps on chain 2800 when you are funded.

Network parameters

FieldValue
Chain ID2800 (0xAF0)
CurrencyAERE, 18 decimals
RPChttps://rpc.aere.network
Second RPChttps://rpc2.aere.network
Explorerhttps://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.

Step 3. The contract

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);
    }
}
This exact text compiles. The source above was lifted straight out of this page and fed to a compiler: solc 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.

Treat those two byte counts as an example, not a target. Bytecode size moves with the compiler version, the optimizer settings and the OpenZeppelin version, so a different combination will legitimately give you a different number. What should match on any 0.8.20-or-newer compiler with OpenZeppelin 5.x is the zero errors and the selector table below. If you paste it and get an error instead, the usual cause is an OpenZeppelin 4.x installation, where Ownable takes no constructor argument.

Why each piece is there

This contract is not Enumerable, on purpose. It inherits 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.

In the other direction, it answers true to one identifier no table above mentions: 0x49064906, ERC-4906, which ERC721URIStorage brings with it. That is the metadata-update signal, and it is there because the extension emits it.

The selectors your contract will expose

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):

FunctionSelector
safeMint(address,string)0xd204c45e
ownerOf(uint256)0x6352211e
tokenURI(uint256)0xc87b56dd
balanceOf(address)0x70a08231
name()0x06fdde03
symbol()0x95d89b41
supportsInterface(bytes4)0x01ffc9a7
nextTokenId()0x75794a3c
totalMinted()0xa2309ff8

Step 4. The metadata

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" }
  ]
}

Where to put it

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.

Step 5. Compile

5.1 Open Remix, create AereCourseNFT.sol, paste the contract from step 3.
5.2 In the Solidity compiler panel select version 0.8.20 or newer. Remix fetches the OpenZeppelin imports from npm on its own.
5.3 Enable the optimizer at 200 runs. This matters later: bytecode verification only matches if the settings match.
5.4 Compile. Write down the compiler version and settings now, while you still remember them. You will need both to verify the source afterwards.

Step 6. Deploy to chain 2800

6.1 Add AERE Network to your wallet using the parameters in step 2, and switch to it. Confirm the wallet says chain 2800 before you sign anything.
6.2 In Remix, set Environment to Injected Provider so it uses your wallet. Check that the network shown is 2800 and the account is the one you intend to be the owner.
6.3 The constructor takes one argument, 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.
6.4 Deploy, and confirm in the wallet. Fees are paid in AERE. The base fee observed for this page was 1 Gwei, and you can read it yourself with eth_getBlockByNumber.
6.5 Copy the contract address out of the receipt. Everything from here on is addressed to it.
Deployment is not confirmed because Remix turned green. It is confirmed when 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.

Step 7. Mint

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.

What the receipt should contain

Three logs, in this order. The middle one surprises people, so count carefully:

  1. 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.
  2. 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.
  3. Your own 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)")
= 0xe7cd4ce7f2a465edc730269a1305e8a48bad821e8fb7e152ec413829c01a53c4
If safeMint 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.

Step 8. What the explorer shows

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/0xYourMintTransactionHash

Set 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:

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.

Step 9. Verify it yourself

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.

9.1 The contract exists

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.

9.2 It declares itself an ERC-721, and denies what it is not

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.

Run against a collection that is already live on chain 2800, address 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.

One difference to expect. That older collection does implement Enumerable, which is why 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.

9.3 Name and symbol

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).

9.4 Ownership, with the revert that proves the read is real

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.

9.5 The metadata pointer

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.

9.6 Your mint, in the logs

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.

9.7 Confirm you are on the chain you think you are

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.

What you now hold

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.

Back to AERE Academy