Building Aeons: A Full Stack Digital Art Project
Aeons is an Internet Art Experience on the Base blockchain. With fully onchain art, metadata and gamification, the project can exist without any external pieces. But even with fully onchain ideals, these offchain pieces add greatly to the experience.
This article is a technical description of the code I wrote for Aeons. The first half is the contract suite on Base. It holds the art, the metadata and the rules, and it needs nothing else. The second half is the services that sit outside the chain.
16,584 Aeons were minted.
Each token is a fully onchain animation with a limited life. A token starts with four days of life. If the holder does nothing, the token dies and the system burns it. To add life to a token, the holder must merge it with other tokens. That one rule makes a token change its state with time only, and it gives real work to every layer of the stack.
How the project works
The rules of the collection control every technical decision that comes after them. So it is best to know the rules first.
Each token moves through six stages. If a token has more days of life, its stage is higher:
- Nebula — 0 to 15 days. A token starts here, with 4 days.
- Plasma — 16 to 63 days.
- Gamma — 64 to 255 days.
- Solstice — 256 to 1023 days.
- Neutra — 1024 to 2047 days.
- Zenith — 2048 days or more. This is 5.6 years of life.
A holder can do three operations with a token:
- Collide — merge one token into a second token. The second token survives and receives the days of life of the first token. It moves up through the stages. The system burns the first token.
- Simulate — see the result of a collision before you do it.
- Immortalize — remove a token from death permanently. The token keeps its current stage.
If the holder does nothing, the days of life of a token decrease. If a token goes above a stage limit, its stage increases. If a token goes below a stage limit, its stage decreases. When the days of life become zero, the token is dead.
The state of the collection is never constant. A smart contract has no scheduler, and it has no idea of “later”. So the primary problem is this: a token must become older and die. The contract can do the former. The offchain services fulfill the latter with a burn call.
The stack
Aeons has four repositories:
- aeons-web-app — Next.js. UI and front end.
- aeons-contracts — Solidity, Hardhat, viem.
- aeons-indexer — Ponder.
- aeons-listener — Node, BullMQ, Puppeteer.
The contract is the source of truth, and it can run alone. Every other layer watches it and keeps the outside world in agreement with it. None of those layers can change the rules of the game.
The description below starts at the chain and moves out.
The smart contracts
This is the half that stands alone. The art, the metadata and the rules are all in the contract suite.
The token is an ERC-721, but it is not a standard one. Aeons.sol uses ERC721A for cheap batch mints and sequential IDs. It uses the ERC721AC variant from Limit Break’s creator-token-contracts, which adds onchain royalty control and a transfer validator. For ownership, the contract uses Solady’s Ownable, which uses less gas. The royalties use the standard IERC2981. The contract is verified on Base, thus the full suite is public.
The most important design decision is this: the contract stores no dynamic token data.
The stage, the traits, the colours, the days of life and the alive/dead status of a token are not fields in a struct. The contract calculates them at each read, from two facts: the time when the token dies, and the random value of the epoch of the mint.
Time, and the six stages
Each token stores a deathTime. This is an absolute Unix timestamp, not a countdown. The contract calculates the stage from the number of days before that time:
function getStageIndex(
AeonsStorage.Aeon memory aeon
) public view returns (uint256) {
// If the Aeon is in stasis, return the stored stage index
if (aeon.inStasis) {
return aeon.stageIndex;
}
uint256 deathTime = aeon.deathTime;
uint256 blockTime = block.timestamp;
if (deathTime <= blockTime) {
return 0; // Nebula (Death variant)
}
uint256 daysUntilDeath = AeonsUtils.day(blockTime, deathTime);
if (daysUntilDeath < 16) {
return 0; // Nebula
} else if (daysUntilDeath < 64) {
return 1; // Plasma
} else if (daysUntilDeath < 256) {
return 2; // Gamma
} else if (daysUntilDeath < 1024) {
return 3; // Solstice
} else if (daysUntilDeath < 2048) {
return 4; // Neutra
}
return 5; // Zenith
}The full state of the game comes from deathTime and block.timestamp. A token can move up, move down and die with no transaction. Time does the work.
Collide
A collision is the only way to stay alive. It moves the life from one token to a second token, then it burns the first token:
aeons.all[tokenId].collisions.push(burnTokenId);
// We know original deathTimes are derived from mintEndTime + 4 days
uint256 burnTokenDaysUntilDeath = AeonsUtils.day(
block.timestamp,
aeons.all[burnTokenId].deathTime
);
aeons.all[tokenId].deathTime += burnTokenDaysUntilDeath * 24 hours;
// Set the death time to the current block timestamp
// This is to ensure that the token is dead immediately after collision
aeons.all[burnTokenId].deathTime = block.timestamp;
aeons.collisions += 1;
aeons.burned += 1;
_burn(burnTokenId);The token that survives keeps its own traits and receives all the days of life of the other token. A holder can transfer only the days that a token still has. So an early collision banks the most life. collideMany does this operation in a batch. collideSim and collideManySim are the view equivalents that supply the simulate function. They give the result of a collision, but they change no data and they spend nothing. These view functions are used on the app to see how a collision would affect an Aeon before cementing with a write call.
Immortalize
Even a Zenith token, with 5.6 years of life, will die in the end. Only an immortalization stops this. stasis() is a one-way payable function that removes a token from death permanently. The fee is the interesting part:
uint256 stageMultiplier = 1; // Default to stage 0 multiplier
if (stageIndex == 1) {
stageMultiplier = 4;
} else if (stageIndex == 2) {
stageMultiplier = 8;
} else if (stageIndex == 3) {
stageMultiplier = 16;
} else if (stageIndex == 4) {
stageMultiplier = 32;
} else if (stageIndex == 5) {
stageMultiplier = 64;
}
uint256 scarcityMultiplier = 1 ether;
if (activeSupply > 0) {
uint256 stasisPercentage = (aeons.stasis * 1 ether) / activeSupply;
uint256 scarcityFactor = (9 * stasisPercentage * stasisPercentage) /
1 ether;
scarcityMultiplier = 1 ether + scarcityFactor;
}
return (stasisBaseFee * stageMultiplier * scarcityMultiplier) / 1 ether;The fee is the base fee multiplied by the stage multiplier and the scarcity multiplier. The base fee is 0.001 ETH. The stage multiplier increases linearly with the rarity of the stage. The scarcity multiplier increases quadratically with the part of the supply that is already immortal. The 9 * pct² term goes from 1x, when no token is immortal, to 10x, when all tokens are immortal.
So it is cheap to keep a token early. It gets more expensive to keep a late-stage token after other holders start to lock in theirs:
| Stage | Multiplier | @ 0% immortalized | @ 50% | @ 100% |
|---|---|---|---|---|
| 1 — Nebula | ×1 | 0.001 ETH | 0.00325 ETH | 0.01 ETH |
| 2 — Plasma | ×4 | 0.004 ETH | 0.013 ETH | 0.04 ETH |
| 3 — Gamma | ×8 | 0.008 ETH | 0.026 ETH | 0.08 ETH |
| 4 — Solstice | ×16 | 0.016 ETH | 0.052 ETH | 0.16 ETH |
| 5 — Neutra | ×32 | 0.032 ETH | 0.104 ETH | 0.32 ETH |
| 6 — Zenith | ×64 | 0.064 ETH | 0.208 ETH | 0.64 ETH |
Supply is a result, not a constant
Most NFT collections have a fixed supply. Aeons does not have one. The demand set the quantity at the mint, and from the moment the mint stopped, the quantity only falls. Each collision and each death burns a token. Each immortalization locks a token into the group that survives. The supply is the running total of every operation the holders do.
This is why the stasis fee changes with time. The fee uses the ratio of the supply that is immortal, and that ratio increases from two directions. The quantity of tokens that can burn decreases as tokens collide. The quantity of immortal tokens increases as holders lock theirs in. So the cost of immortality increases on its own.
The graph below shows the effect for some demand conditions. It includes the 16,584 tokens that the holders minted:
The mint quantities are examples, but the result is not what you expect. The fee uses the ratio and not the quantity. So the same scarcity effect appears if 5,000 tokens mint or if 40,000 tokens mint. The demand sets the initial size of the collection. The holders set the cost to stay in it.
To see the current data of the collection, use the observatory. It shows how many tokens survive, how many tokens are immortal, and how much life they have.
Death
A token is dead when its clock becomes zero:
function isDead(uint256 tokenId) public view returns (bool) {
AeonsStorage.Aeon memory aeon = aeons.all[tokenId];
return !aeon.inStasis && aeon.deathTime <= block.timestamp;
}A dead token stays in the supply until an account burns it. The contract cannot burn a token by itself when the death time passes, because a contract cannot start a transaction. Some external account must send that transaction. So burnDead is permissionless, and any account can call it on any dead token. If an account burns the token of a different owner, the contract records it:
function burnDead(uint256 tokenId) public {
if (!isDead(tokenId)) {
revert AeonNotDead();
}
address owner = ownerOf(tokenId);
address burner = msg.sender;
// If called by non-owner of the token, increment total killed
if (owner != burner) {
aeons.killed += 1;
}
// All calls are token burns, so increment total burned
aeons.burned += 1;
_burn(tokenId);
emit Death(tokenId, owner, burner);
}This makes the removal of dead tokens an open task, and it gives other holders a reason to do it. _beforeTokenTransfers also blocks transfers of dead tokens. So a holder cannot sell a dead token, and an account can only burn it. To call burnDead for thousands of tokens is a task for the offchain services. The section below shows how.
Onchain randomness
The contract assigns the traits (Prism, Energy, Velocity and the colours) with a commit-reveal procedure. This procedure comes from Checks. The random value applies to each epoch, not to each token, and no user can predict it at the mint.
Each mint calls resolveEpochIfNecessary(). If the current epoch has no commit, the function selects a block approximately 50 blocks in the future. That block is the source of the random value. When the chain goes past that block, the next mint reveals the value:
} else if (block.number > currentEpoch.revealBlock) {
// Epoch has been committed and is within range to be revealed
// Set its randomness to the target block hash
currentEpoch.randomness = uint128(
uint256(
keccak256(
abi.encodePacked(
blockhash(currentEpoch.revealBlock),
block.prevrandao
)
)
) % (2 ** 128 - 1)
);
currentEpoch.revealed = true;
emit CommitReveal.NewEpoch(aeons.epoch, currentEpoch.revealBlock);
metadataUpdateAll();
++aeons.epoch;
resolveEpochIfNecessary();
}Each token calculates its own seed from the random value of the epoch: keccak256(randomness, tokenId). The contract then compares the seed with fixed cumulative probability tables to select the traits. It writes no data, and it calculates the traits at each read. The rarity of the Energy trait is an example:
/**
* @dev Get the cumulative probabilities of an energy being selected
* @return The cumulative probabilities of the energies traits
*/
function energiesProbabilities() public pure returns (uint8[7] memory) {
// 1%, 7%, 13%, 15%, 18%, 23%, 23%
return [1, 8, 21, 36, 54, 77, 100];
}Ivora is the rarest Energy, and it lands 1% of the time. A token is “revealed” when the random value of its epoch is more than zero. Before that time, the token shows a white dormant form.
Fully onchain art
The art is also onchain. tokenURI always assembles the JSON metadata onchain as a base64 data:application/json string. If no external URL is set, AeonsRenderer makes a complete HTML page for the animation. It uses scripty.sol and ethfs to read a gzipped three.js, fflate and the animation script of the project from onchain storage. Then it adds the data of the token.
A static image is the difficult part of a fully onchain project. The contract has a manual onchain SVG fallback for each stage. It also has a dormant form for a token before its epoch reveals:
The renderer is behind an interface, and you can replace it (ERC-165 controls this). So you can upgrade or move the art, and you never touch the token contract. The SVG fallback keeps the project complete with no external help. But it is not the real animation. To put the three.js piece on a marketplace card is a job for the offchain services.
Minting
The mint continued for 24 hours, and the demand set the supply. Each token started with the same quantity of life:
// Give every token a starting life time of 4 days from the mint end
aeons.all[tokenId].deathTime = mintEndTime + 4 days;Each token had four days on the clock from the moment the mint stopped. Then the collection started to become older, and the layers outside the chain started to follow it.
The indexer
The contract knows everything, but it answers one question at a time. A profile page, a status filter and an activity list all need the same thing: the onchain data in a database. The indexer puts it there. It adds nothing to the rules of the game. It makes the collection easy to look at.
The indexer uses Ponder, and it monitors the contract on Base. The procedure is the same for each event: read the full state from the contract, then copy it into Postgres. The Birth handler is an example:
ponder.on("Aeons:Birth", async ({ event, context }) => {
const { client } = context;
const enrichedAeon = await client.readContract({
abi: CONTRACT_ABI,
address: CONTRACT_ADDRESS,
functionName: CONTRACT_ENRICHED_AEON_FUNCTION_NAME,
args: [event.args.tokenId],
});
await context.db.insert(aeons).values({
id: Number(event.args.tokenId),
owner: event.args.minter,
deathTime: enrichedAeon.deathTime,
stageIndex: enrichedAeon.stageIndex,
// ...all traits, colors and status, mirrored from the chain
image: `${IMAGE_BASE_URI}${event.args.tokenId}/dead.png`,
});
// ...append to the activity feed
});The indexer processes Birth, Collision, Death, Stasis and NewEpoch with the same procedure. The contract stays the source of truth, and Ponder makes it easy to query.
The event model cannot do one thing: the stage of a token changes with time only, and time sends no event. So there is also a block handler. It operates at an interval, and it calculates the ages and the stages again:
blocks: {
DailyUpdate: {
chain: "base",
startBlock: Number(startBlock),
// 24 hours * 60 minutes * 60 seconds / 2 seconds per block
interval: (24 * 60 * 60) / 2, // = 43200 blocks
}
}A small Hono REST API operates above the database. It has purpose-built endpoints for the tokens of a holder, the activity feed, the dead token ids and the collection stats. The stats endpoint adds the live floor price and the live volume from OpenSea, and it supplies the data for the observatory. Ponder has a GraphQL layer, but a specific JSON API is better here, because the front end can use it directly. This API keeps the profile pages in agreement with the chain.
The offchain services
The listener is the offchain engine of the project. It does two tasks that the chain will not do for itself. The collection is still correct without the listener, but it is much better to use with it.
aeons-listener is a Node service. It uses BullMQ with Redis. It monitors the contract with two methods: a getLogs backfill in fixed windows to get the old data, then a watchEvent poll to stay current. Each log that agrees with the filter becomes a job.
A static image of a live animation
Marketplaces need a PNG. An Aeon is a three.js particle animation, and the contract makes it onchain. To connect the two, the service must run the art in a browser and record one frame.
A worker starts Puppeteer and loads the token page. It waits until the animation signals that it is ready, then it records the image:
const html = `<body style="margin:0;">${item}</body>`
const dataUrl = `data:text/html;base64;charset=UTF-8,${Buffer.from(html).toString('base64')}`
await page.goto(dataUrl)
try {
await page.waitForFunction("RENDERED === true", {
timeout: isStage5 ? 15000 : 5000,
})
} catch (e) {}
const image = await page.screenshot();The heavy stages need more time to become stable. A Zenith has many particles, thus its timeout is longer. The worker sends the image to Vercel Blob at a fixed path. Then it tells OpenSea to read the image again.
The result: the moment a token collides, dies or becomes immortal, the service makes its image again and sends the update signal to marketplaces automatically.
Burning the dead tokens
burnDead is permissionless, but an account must still call it. The listener does this on a schedule.
Burns go out in batches, behind a distributed lock:
const currentNonce = await publicClient.getTransactionCount({
address,
blockTag: 'pending',
});
let nextNonce = Number(currentNonce);
for (const batch of batches) {
await walletClient.writeContract({
address: AEONS_CONTRACT_ADDRESS,
abi,
functionName: 'burnDeadMany',
args: [batch],
account: walletClient.account,
nonce: nextNonce,
});
nextNonce++;
}Balance checks ensure the service never sends a transaction that would fail.
Bringing it together
Aeons works because each layer has one task, and the boundaries between the layers are clean. The contract owns the art and the rules, and it is the single source of truth. The indexer copies that data into a database and supplies it as an API. The listener monitors the same events and does the two tasks that the chain will not do for itself: it makes an image of the onchain animation for the marketplaces, and it calls burnDead to remove the tokens that are dead. The front end reads the indexer and writes to the contract.
The contract is Aeon’s source of truth. So most of the engineering work is the machinery that keeps the app, the indexer and the marketplaces in agreement with it. That machinery is not the art, and the art does not need it. But it’s what turns the art into a complete experience.
The collection lives here. The observatory shows its state in real time. The contract is verified on Base.
Aeons credits: