Deploy an ERC-20 on chain 2800

Course Beginner to working contract About 45 minutes Last measured 2026-08-10

This is a complete walkthrough, from an empty folder to a token contract living on AERE Network that you can see in the explorer and read from any machine with curl. Nothing here is a simulation and nothing is a local testnet: every command points at the live chain.

Who this is for

Developers who have written a little Solidity, or none at all, but are comfortable in a terminal. If you have deployed on any EVM chain before, you can skip to step 4; the only thing that differs is the network settings.

What you will be able to do at the end

How to read the numbers on this page. Every figure comes with the call that produces it. Where a figure is a snapshot of a moving value, it says so, with the time it was taken. If you get a different number for a moving value, that is expected; if you get a different number for a fixed one, trust your terminal over this page and tell us.

What chain 2800 is, in plain terms

AERE Network is a public EVM Layer 1. Standard tooling works against it unmodified, which is the entire reason this course can be short. A few properties are worth knowing before you spend time here, stated plainly including the unflattering parts:

1Add the network

These are the network parameters. The chain ID and network ID below were both confirmed live against the two public endpoints while writing this page.

FieldValue
Network nameAERE Network
Chain ID2800 (hex 0xAF0)
Currency symbolAERE, 18 decimals
RPC endpointhttps://rpc.aere.network
Second RPC endpointhttps://rpc2.aere.network
Explorerhttps://explorer.aere.network
Fee modelEIP-1559, and EIP-155 replay protection

If your wallet supports EIP-3085, an app can request the network in one call. This is the exact object:

{
  "chainId": "0xAF0",
  "chainName": "AERE Network",
  "nativeCurrency": { "name": "AERE", "symbol": "AERE", "decimals": 18 },
  "rpcUrls": ["https://rpc.aere.network"],
  "blockExplorerUrls": ["https://explorer.aere.network"]
}

From a page, that becomes:

await window.ethereum.request({
  method: "wallet_addEthereumChain",
  params: [{
    chainId: "0xAF0",
    chainName: "AERE Network",
    nativeCurrency: { name: "AERE", symbol: "AERE", decimals: 18 },
    rpcUrls: ["https://rpc.aere.network"],
    blockExplorerUrls: ["https://explorer.aere.network"]
  }]
});
There are two public read endpoints, rpc and rpc2. Be aware of what that does and does not buy you: both run the same client build, confirmed by asking each for web3_clientVersion and getting an identical string. So they give you a second endpoint, not a second independent implementation. A second client exists and is being brought up, but it has no public read endpoint today, so every check on this page is a check against one implementation.

2Confirm the network is really the network

A wallet showing "AERE Network" proves only that someone typed that name. Ask the endpoint directly. Run these four calls before you trust anything else in this course.

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}'
# => {"jsonrpc":"2.0","id":1,"result":"0xaf0"}

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"net_version","params":[]}'
# => {"jsonrpc":"2.0","id":1,"result":"2800"}

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}'
# => a hex height that is higher every time you run it

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_syncing","params":[]}'
# => false, meaning the endpoint is at the head, not catching up

Snapshot, so you can sanity-check the order of magnitude. At 2026-08-10T11:16:16Z the head was block 13,212,316 (0xc99a9c). This is a photograph of a moving number: it climbs at about 1.8 blocks per second, so by the time you read this it is much larger. If your endpoint returns something far below this, you are not at the head.

Read the validator set yourself

The QBFT method is exposed publicly, so the claim "ten validators" is one you can check rather than take:

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"qbft_getValidatorsByBlockNumber","params":["latest"]}'

It returns an array of addresses. Count them: ten (nine when measured on 2026-08-10, ten since 2026-09-11). Quorum is seven of ten and f=3.

Measure the block interval yourself

Take two headers 20,000 blocks apart and divide the timestamp difference by 20,000. Measured on 2026-08-10 over five disjoint windows of 20,000 blocks ending at height 13,211,684, every window landed between 0.550 and 0.557 seconds per block, mean 0.557 s. That is the realised interval, which is the only one you can check from outside; it is not a configured target, and it moves a little from window to window. Reproduce it with any two heights you like:

RPC=https://rpc.aere.network
rpc () { curl -s -X POST $RPC -H "Content-Type: application/json" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$1\",\"params\":$2}"; }

H=$(rpc eth_blockNumber '[]' | sed 's/.*"result":"//;s/".*//')
NOW=$((H)); OLD=$((NOW - 20000))

ts () { rpc eth_getBlockByNumber "[\"$(printf '0x%x' $1)\",false]" \
        | sed 's/.*"timestamp":"//;s/".*//'; }

T1=$(ts $OLD); T2=$(ts $NOW)
awk -v a=$((T1)) -v b=$((T2)) \
  'BEGIN{printf "%.4f s per block\n",(b-a)/20000}'
# => 0.5507 s per block, on the window this was written against

The division is done with awk rather than bc on purpose: bc is missing from several common shells, including Git Bash on Windows, where it fails with an empty result rather than an error.

Count how busy the chain is

This is the loop behind the "close to empty" statement above. It counts transactions over the last 200 blocks, and it reuses the rpc helper defined in the previous block, so run that one first in the same shell:

H=$(rpc eth_blockNumber '[]' | sed 's/.*"result":"//;s/".*//'); N=$((H))
TX=0; EMPTY=0
for i in $(seq 0 199); do
  R=$(rpc eth_getBlockByNumber "[\"$(printf '0x%x' $((N-i)))\",false]")
  case "$R" in
    *'"transactions":[]'*) EMPTY=$((EMPTY+1)) ;;
    *'"transactions":['*)
      TX=$((TX + $(printf '%s\n' "$R" | sed 's/.*"transactions":\[//;s/\].*//' \
                   | tr ',' '\n' | wc -l))) ;;
    *) echo "not a block at height $((N-i)), stopping rather than guessing:"
       printf '%s\n' "$R" | head -c 200; exit 1 ;;
  esac
done
echo "$TX transactions, $EMPTY empty blocks, out of 200"
# => 3 transactions, 197 empty blocks, out of 200
#    (2026-08-10, head 13,213,542)
Why this loop is written so defensively, and it is the lesson of the whole page. The obvious version tests whether the transaction list came back empty, and treats "empty" as "no transactions". But an error reply contains no transaction list either, so a rate limit, a typo in the method name, or an endpoint having a bad minute all look exactly like a quiet chain, and the loop happily prints a confident number it did not measure. This version names the two shapes it understands and stops on anything else. Two smaller traps are in here too, both of which we hit while writing this page: printf '%s' without a trailing newline makes wc -l return 0 for a block with one transaction, silently undercounting; and inside a case pattern the brackets of [] must be quoted or the shell reads them as a character class.

Check that producing a block does not create new coin

Blocks are nearly all empty, so a producer earns no fees from them. If it also earns no block reward, its balance does not move at all. Pick any validator address the previous call returned and read its balance at two heights a few hundred blocks apart. Stay within a few hundred blocks: the public endpoint serves recent state, and a request far behind the head fails rather than answering.

V=0x...            # any address from the validator list above
H=$(rpc eth_blockNumber '[]' | sed 's/.*"result":"//;s/".*//'); N=$((H))
for B in $((N-450)) $((N-3)); do
  rpc eth_getBalance "[\"$V\",\"$(printf '0x%x' $B)\"]"; echo
done
# => the same value twice. Measured 2026-08-10 on three of the nine
#    validators across 447 blocks: not one wei of change on any of them.

This shows that no new coin was paid to those producers over that window. It is not a proof about total supply, which no single RPC call returns, and it cannot see coin created anywhere other than the addresses you check.

3The account you will deploy from

Deployment is a transaction, so it needs an account with a balance. Read yours with:

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBalance","params":["0xYourAddress","latest"]}'

Fees are paid in AERE under EIP-1559. The base fee measured on 2026-08-10 was exactly 1 Gwei, which you can read for yourself from any header:

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getBlockByNumber","params":["latest",false]}'
# look at the baseFeePerGas field: 0x3b9aca00 = 1,000,000,000 wei = 1 Gwei
Being straight with you about funding. There is no public faucet on chain 2800 today, so this course cannot hand you a funded key and we will not send you to a page that fails. If you do not hold AERE, you can still complete steps 1, 2, 4 and 9 in full, and every read command in steps 7 and 8, against the live chain, because reads cost nothing. Only the two deployment steps need a funded key.
Key handling. Use a key created for this exercise and nothing else. Pass it through an environment variable, never a command line argument that lands in your shell history, and never commit it. Add .env to .gitignore before you write the key into it. Treat any key that has ever been pasted into a chat, an issue, or a screenshot as public forever.
# .env  -  never commit this file
PRIVATE_KEY=0x...
RPC_URL=https://rpc.aere.network

4The contract

Below is a complete ERC-20, written out rather than imported, so nothing about it is hidden behind a dependency. It implements the full standard interface: the three metadata getters, totalSupply, balanceOf, transfer, approve, allowance, transferFrom, and both events. Save it as CourseToken.sol.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title CourseToken
/// @notice A minimal, complete ERC-20. The entire supply is minted to the
///         deployer in the constructor, and there is no mint function
///         afterwards, so the supply is fixed the moment it is deployed.
contract CourseToken {
    string public name;
    string public symbol;
    uint8 public constant decimals = 18;

    uint256 public totalSupply;

    // Public mappings generate the getters the standard requires:
    // balanceOf(address) and allowance(address,address).
    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    event Transfer(address indexed from, address indexed to, uint256 value);
    event Approval(address indexed owner, address indexed spender, uint256 value);

    error InsufficientBalance(uint256 available, uint256 required);
    error InsufficientAllowance(uint256 available, uint256 required);
    error ZeroAddress();

    /// @param _initialSupply whole tokens, not wei. 1000000 means one million
    ///        tokens, and the constructor scales it by 10**18 for you.
    constructor(string memory _name, string memory _symbol, uint256 _initialSupply) {
        name = _name;
        symbol = _symbol;
        totalSupply = _initialSupply * 10 ** decimals;
        balanceOf[msg.sender] = totalSupply;
        // Minting is a transfer from the zero address. Indexers rely on this
        // event to see the supply appear; a token that skips it looks empty.
        emit Transfer(address(0), msg.sender, totalSupply);
    }

    function transfer(address to, uint256 value) external returns (bool) {
        _transfer(msg.sender, to, value);
        return true;
    }

    function approve(address spender, uint256 value) external returns (bool) {
        if (spender == address(0)) revert ZeroAddress();
        allowance[msg.sender][spender] = value;
        emit Approval(msg.sender, spender, value);
        return true;
    }

    function transferFrom(address from, address to, uint256 value) external returns (bool) {
        uint256 allowed = allowance[from][msg.sender];
        // An allowance of max uint256 is treated as infinite and is not
        // decremented. This is the common convention and it saves a storage
        // write on every pull payment.
        if (allowed != type(uint256).max) {
            if (allowed < value) revert InsufficientAllowance(allowed, value);
            unchecked { allowance[from][msg.sender] = allowed - value; }
        }
        _transfer(from, to, value);
        return true;
    }

    function _transfer(address from, address to, uint256 value) internal {
        if (to == address(0)) revert ZeroAddress();
        uint256 bal = balanceOf[from];
        if (bal < value) revert InsufficientBalance(bal, value);
        // Safe to skip the overflow checks: the subtraction is guarded above,
        // and no balance can exceed totalSupply, which is fixed.
        unchecked {
            balanceOf[from] = bal - value;
            balanceOf[to] += value;
        }
        emit Transfer(from, to, value);
    }
}
Two things people get wrong with a first token. First, decimals is display metadata only. The chain stores integers; a balance of one token with 18 decimals is stored as 1000000000000000000. Second, the well-known approve race is a property of the standard itself, not of this implementation: setting a non-zero allowance over another non-zero allowance lets a watching spender use both. If that matters for your use, have callers set the allowance to zero first.

5Deploy with Hardhat

mkdir course-erc20 && cd course-erc20
npm init -y
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox dotenv
npx hardhat init        # choose a JavaScript project

This walkthrough is written against Hardhat 2.x with hardhat-toolbox, and the config below is the JavaScript CommonJS form that version expects. If npx hardhat init offers you a different set of choices, you have a newer major version, whose config format and plugin names differ; either pin hardhat@2 or follow that version's own guide. The Foundry route in step 6 does not have this problem.

Put CourseToken.sol in contracts/, then write hardhat.config.js:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: {
    version: "0.8.24",
    settings: { optimizer: { enabled: true, runs: 200 } }
  },
  networks: {
    aere: {
      url: process.env.RPC_URL || "https://rpc.aere.network",
      chainId: 2800,
      accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : []
    }
  }
};

Then scripts/deploy.js:

const { ethers } = require("hardhat");

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("deployer :", deployer.address);
  console.log("balance  :", ethers.formatEther(
    await ethers.provider.getBalance(deployer.address)), "AERE");

  const token = await ethers.deployContract(
    "CourseToken", ["Course Token", "CRS", 1000000n]);
  await token.waitForDeployment();

  console.log("address  :", await token.getAddress());
  console.log("tx hash  :", token.deploymentTransaction().hash);
}

main().catch((e) => { console.error(e); process.exit(1); });

Compile and deploy:

npx hardhat compile
npx hardhat run scripts/deploy.js --network aere

Keep the printed address and transaction hash. You need both in steps 7 and 8.

6Deploy with Foundry instead

Same contract, same chain, no Node.js involved. If you already did step 5, this is optional; running both simply gives you two tokens.

curl -L https://foundry.paradigm.xyz | bash
foundryup

forge init course-erc20-forge && cd course-erc20-forge
# put CourseToken.sol in src/, remove the sample Counter files
forge build

Add the endpoint to foundry.toml so you can refer to it by name:

[profile.default]
src = "src"
out = "out"
optimizer = true
optimizer_runs = 200
solc_version = "0.8.24"

[rpc_endpoints]
aere = "https://rpc.aere.network"

Deploy. Constructor arguments are positional, in declaration order, and the supply is in whole tokens:

source .env

forge create src/CourseToken.sol:CourseToken \
  --rpc-url aere \
  --private-key $PRIVATE_KEY \
  --constructor-args "Course Token" "CRS" 1000000 \
  --broadcast

On Foundry versions before the --broadcast flag existed, drop it; forge create broadcast by default there. Check with forge create --help if you are unsure which you have.

Foundry prints Deployer, Deployed to and Transaction hash. Move a few tokens and read the result back:

# send 25 tokens (25 * 10**18 base units) to another address
cast send $TOKEN "transfer(address,uint256)" $RECIPIENT 25000000000000000000 \
  --rpc-url aere --private-key $PRIVATE_KEY

# read the recipient balance back
cast call $TOKEN "balanceOf(address)(uint256)" $RECIPIENT --rpc-url aere

7See it in the explorer

The explorer follows the EIP-3091 URL scheme, so the links are predictable and you can build them yourself:

WhatURL
Your contracthttps://explorer.aere.network/address/<contract address>
The deployment transactionhttps://explorer.aere.network/tx/<tx hash>
The block it landed inhttps://explorer.aere.network/block/<block number>

Before you open a browser, confirm from the terminal that code is genuinely stored at the address. An address with no contract returns 0x:

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getCode","params":["0xYourContract","latest"]}'
# => a long hex string. If it is exactly "0x", nothing is deployed there.

Also pull the receipt, which is where the deployment is proven rather than assumed. Check status is 0x1 and that contractAddress matches what your tool printed:

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_getTransactionReceipt","params":["0xYourTxHash"]}'

8Verify the contract

Verification means one specific thing: proving that the bytecode running at an address is what the source you are showing people actually compiles to. Be clear about what is available here, because it differs from what you may be used to.

There is no hosted "paste your source and click verify" service on chain 2800 today. What exists instead is a reproducible check that you run yourself, and that anyone else can rerun independently without asking us for anything: compile the source, then compare the compiler's runtime bytecode with what eth_getCode returns. This is the same method used for the core contracts published at verified-contracts.json, under the schema name aere-source-verification/1.

A solc output ends with a CBOR metadata blob whose last two bytes hold its own length. Splitting that off separates executable code from metadata, which is what lets you tell a full match from a partial one. Save this as verify.js and run it with Node 18 or newer:

// verify.js  -  compare on-chain runtime code against your local build
const fs = require("fs");

const RPC = "https://rpc.aere.network";
const ARTIFACT = "artifacts/contracts/CourseToken.sol/CourseToken.json";
const address = process.argv[2];
if (!address) { console.error("usage: node verify.js 0xContract"); process.exit(1); }

const strip = (h) => (h || "").toLowerCase().replace(/^0x/, "");

// Last 2 bytes = length of the trailing CBOR metadata section.
function split(hex) {
  if (hex.length < 8) return { code: hex, meta: "" };
  const metaLen = parseInt(hex.slice(-4), 16) * 2 + 4;
  if (metaLen > hex.length) return { code: hex, meta: "" };
  return { code: hex.slice(0, hex.length - metaLen), meta: hex.slice(-metaLen) };
}

(async () => {
  const r = await fetch(RPC, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ jsonrpc: "2.0", id: 1,
      method: "eth_getCode", params: [address, "latest"] })
  }).then((x) => x.json());

  if (!r.result || r.result === "0x") { console.log("NOTHING DEPLOYED"); return; }

  const onchain = split(strip(r.result));
  const local = split(strip(JSON.parse(fs.readFileSync(ARTIFACT, "utf8")).deployedBytecode));

  if (onchain.code === local.code && onchain.meta === local.meta) {
    console.log("FULL MATCH, executable code and metadata hash are identical");
  } else if (onchain.code === local.code) {
    console.log("PARTIAL MATCH, code identical, metadata differs " +
                "(usually a different comment, path, or compiler patch build)");
  } else {
    console.log("NO MATCH, this is not the source of that contract");
  }
})();
node verify.js 0xYourContract
# => FULL MATCH, executable code and metadata hash are identical

Now make the check fail on purpose

A check that has never returned a negative result is not evidence, because you cannot tell it apart from a check that always says yes. Do both of these before you believe the green line above:

  1. Point it at a different contract, for example the wrapped-coin contract at 0x7e84d7d66d5da4cfE46Da67CDEeB05B323e1f5e8. It must print NO MATCH.
  2. Change one character inside a string literal in your source, recompile with npx hardhat compile, and rerun against your deployed address. It must stop printing FULL MATCH.

If either of those still prints FULL MATCH, your script is comparing nothing and the earlier result meant nothing either.

A partial match is not a failure. It means the executable code is byte-identical and only the metadata hash differs, which happens when a comment, a file path, or the exact compiler patch version changed. The code that runs is the same code.

Know where this script stops working. It compares raw bytes, so it is only correct for contracts that have no immutable variables and no linked libraries. Those get their values written into the runtime code at construction time, so the on-chain bytes legitimately differ from the compiler's output and this script calls a correct source NO MATCH. The published schema handles that by masking those positions before comparing; the script above deliberately does not, because masking is the part that is easy to get wrong in a way that turns every answer green. CourseToken has neither, which is why it is safe to learn on.

9Read balances with eth_call

Every library eventually sends the same JSON-RPC request. Doing it by hand once means you can debug any of them later. A call is the 4-byte function selector, which is the first four bytes of the keccak-256 hash of the signature, followed by 32-byte arguments.

FunctionSelectorReturns
name()0x06fdde03ABI-encoded string
symbol()0x95d89b41ABI-encoded string
decimals()0x313ce567uint8
totalSupply()0x18160ddduint256
balanceOf(address)0x70a08231uint256
allowance(address,address)0xdd62ed3euint256

You can practise on an ERC-20 that is already deployed, before you own one. The wrapped-coin contract works for this; it is used here only as a target that exists.

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
        "to":"0x7e84d7d66d5da4cfE46Da67CDEeB05B323e1f5e8",
        "data":"0x313ce567"},"latest"]}'

# => 0x0000000000000000000000000000000000000000000000000000000000000012
#    0x12 = 18 decimals

Now name(), which returns a dynamic string and so looks more complicated than it is:

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
        "to":"0x7e84d7d66d5da4cfE46Da67CDEeB05B323e1f5e8",
        "data":"0x06fdde03"},"latest"]}'

# => the answer is three 32-byte words, run together on one line:
#    ...0020  offset, the string data starts 32 bytes in
#    ...000c  length, 12 bytes
#    577261707065642041455245 then zero padding to 32 bytes
#
#    57 72 61 70 70 65 64 = ASCII "Wrapped", 20 = a space,
#    41 45 52 45 = "AERE", so the 12 bytes decode to "Wrapped AERE"

balanceOf on your own token

Concatenate the selector with the address left-padded to 32 bytes: drop the 0x, then prefix 24 zeros to the 40 hex characters of the address. Put your own address in below; the 24 zeros are the padding, not part of it.

SEL=0x70a08231
ADDR=000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
#    <--- 24 zeros ---><------ your 40 hex characters ------>

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":[{
        \"to\":\"0xYourContract\",\"data\":\"${SEL}${ADDR}\"},\"latest\"]}"

The 32-byte answer is base units. Divide by 1018 for display:

# decode a uint256 answer in the shell
python3 -c "print(int('0x1cba1e085d0f0000', 16) / 10**18)"

The equivalents in the two toolchains, for when you no longer want to do it by hand:

# Foundry
cast call $TOKEN "balanceOf(address)(uint256)" $ADDR --rpc-url https://rpc.aere.network

# ethers v6
const t = new ethers.Contract($TOKEN, ["function balanceOf(address) view returns (uint256)"],
  new ethers.JsonRpcProvider("https://rpc.aere.network"));
console.log(ethers.formatUnits(await t.balanceOf($ADDR), 18));

One thing you can only do here

The same eth_call mechanism reaches the native post-quantum precompiles. Be precise about what the example below is and is not: it is SHAKE256, a hash, one of the building blocks the signature schemes are built on. It is not a signature verification, and running it proves nothing about signatures. It is the one we start with because it is the one you can check independently, against a published test vector, without holding any key.

SHAKE256 is an extendable-output function, so you ask it for a length. The precompile lives at 0x0AE5, and its input is the requested output length as a 32-byte big-endian number followed by the data. Here that is 32 bytes of output over the three bytes 616263, which is ASCII abc:

curl -s -X POST https://rpc.aere.network \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
        "to":"0x0000000000000000000000000000000000000ae5",
        "data":"0x0000000000000000000000000000000000000000000000000000000000000020616263"},"latest"]}'

# => 0x483366601360a8771c6863080cc4114d8db44530f8f1e1ee4f94ea37e78b5739

That value is the FIPS-202 known-answer test for SHAKE256 of "abc". Check it against your own machine rather than against us, in one line:

python3 -c "import hashlib; print(hashlib.shake_256(b'abc').hexdigest(32))"
# => 483366601360a8771c6863080cc4114d8db44530f8f1e1ee4f94ea37e78b5739
#    byte for byte what the chain returned

Then change the requested length from 0x20 to 0x11, that is from 32 to 17. You get 0x483366601360a8771c6863080cc4114d8d, exactly the first 17 bytes of the same answer, which is what an extendable-output function should do and shows the length field is genuinely driving the output rather than being ignored.

Proving a precompile is there at all

eth_getCode is the wrong tool here: it returns 0x for every precompile, because they live in the client rather than in state, and an address with nothing at it returns 0x too. So the empty answer tells you nothing, and presence has to be shown by behaviour, with a control that can fail. Malformed input is enough to see the difference:

# a precompile: it parses the input, rejects it, and answers a 32-byte zero
curl -s -X POST https://rpc.aere.network -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
        "to":"0x0000000000000000000000000000000000000ae1",
        "data":"0xdeadbeef"},"latest"]}'
# => 0x0000000000000000000000000000000000000000000000000000000000000000

# NEGATIVE CONTROL, an address with no precompile behind it: nothing at all
curl -s -X POST https://rpc.aere.network -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_call","params":[{
        "to":"0x0000000000000000000000000000000000000ae6",
        "data":"0xdeadbeef"},"latest"]}'
# => 0x

Measured 2026-08-10. Two different answers to the same malformed input is the whole point: without the second call, the first one proves nothing, because you would have no idea what a miss looks like. Note also that the height at which these were switched on is not something you can re-derive from the public endpoint, because it does not serve historical state that far back, so we leave the number off this page rather than print one you cannot check.

Verify it yourself

Run this block from any machine. It confirms the chain identity, the validator set, and your own contract, without trusting anything written above.

RPC=https://rpc.aere.network
TOKEN=0xYourContract
ME=0xYourAddress

call () { curl -s -X POST $RPC -H "Content-Type: application/json" \
  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"$1\",\"params\":$2}"; echo; }

# 1. the chain is 2800, on both public endpoints
call eth_chainId '[]'                       # 0xaf0
call net_version '[]'                       # "2800"

# 2. the endpoint is at the head, not replaying an old view
call eth_syncing '[]'                       # false
call eth_blockNumber '[]'                   # above 0xc99a9c (13,212,316)

# 3. ten validators, counted rather than claimed
call qbft_getValidatorsByBlockNumber '["latest"]'

# 4. your contract has code, and its receipt succeeded
call eth_getCode "[\"$TOKEN\",\"latest\"]"  # not "0x"

# 5. your token answers the standard interface
call eth_call "[{\"to\":\"$TOKEN\",\"data\":\"0x95d89b41\"},\"latest\"]"   # symbol()
call eth_call "[{\"to\":\"$TOKEN\",\"data\":\"0x313ce567\"},\"latest\"]"   # decimals() = 0x12
call eth_call "[{\"to\":\"$TOKEN\",\"data\":\"0x18160ddd\"},\"latest\"]"   # totalSupply()

# 6. your balance inside your own token
call eth_call "[{\"to\":\"$TOKEN\",\"data\":\"0x70a08231000000000000000000000000${ME#0x}\"},\"latest\"]"

# 7. and the same answers from the second public endpoint
RPC=https://rpc2.aere.network
call eth_call "[{\"to\":\"$TOKEN\",\"data\":\"0x18160ddd\"},\"latest\"]"

What you should have now


Measured 2026-08-10 against https://rpc.aere.network. Fixed values on this page, the chain ID, the function selectors, the precompile address and its known-answer test, do not move. Moving values, the height, the base fee, the block interval and the transaction count, are labelled as snapshots with the call that reproduces them. The validator set was nine on that date, counted from qbft_getValidatorsByBlockNumber, and all of them are operated by the Foundation. The contract in step 4 was compiled with solc 0.8.26 and executed against the live chain, including its failure cases, before this page was published. If a command here disagrees with your terminal, your terminal is the record, and we would like to hear about it.