foogy

Web3 & AI Jun 2025

DePunks

The DePunks.club window in its light theme, tilted slightly on a pale grey background. A Collections sidebar lists DePunks, Attributes, DeBots (selected), Agents and Skills. The main area shows 'Newest / Oldest · 3,500 DEBOTS' above a grid of numbered DeBot tiles, ten per row from #1870 to #1821, with tile #1838 outlined in a green ring.
DePunks.club: the DeBots grid, opened from a DePunk's key link.

A pixel-punk club on Ethereum, where the website is a copy of the chain, so I decided where that copy may be trusted and where it may not.

Client
Private project
Role
Design lead and in-house builder, with a team of AI agents — design system, smart contracts, app, and tooling
Year
2025–2026

The website is a copy of the chain, and copies fall behind.

DePunks.club reads its pages from a database that copies Ethereum, which works fine for showing a list. I had two things the copy could not answer well: which DePunk had been used as the key to reveal which DeBot, and who holds a punk at the moment someone acts in its name.

"Just ask the chain" and "just trust the copy" both broke.

Asking the chain on every page view meant reading about 290,000 blocks of history. The free RPC plan refuses wide log ranges, so one page view would have needed hundreds of small requests. Trusting the copy for who holds a punk left a gap: after a punk changed hands, the previous holder still counted as the holder until the indexer caught up, and for good if the cached row was missing.

History gets copied once. Decisions ask the chain every time.

History, meaning which DePunk unlocked which DeBot, is copied into its own table from finalized blocks only, so no row can come from a block that may still be dropped. Decisions about who holds a punk read ownerOf on chain at that moment, and they refuse if the chain cannot be read. I rejected two other options: live log reads per page (hundreds of small requests, because the free RPC plan refuses wide ranges) and reading the newest block (it can still be reorganised away).

Diagram on a dark card. 'Reveal contract on Ethereum: one log per DeBot a DePunk unlocked' points down, labelled 'finalized blocks only', to 'debot_key_use: cursor = contract + start block'. Below: '770 rows in the table = 770 reveals on chain. As of 22 Oct 2026.'
Copied once, from finalized blocks, and checked against the contract.
Diagram on a dark card. 'Someone acts in a punk's name' leads to 'ownerOf(punk): read on chain, now, never the copy'. One branch reads 'the holder matches: allowed' in green, the other 'error, revert, timeout: refused' in pink. Footer: 'a planted fail-open bug turns tests red'.
Who holds a punk is asked of the chain, and doubt means no.

The sync looked finished, and it could fail without a sound.

A review found two quiet failure modes. If the reveal contract's address ever changed, the cursor would pick up the old position and skip the new contract's history while still reporting success. And a key used twice would hit a unique index and block every later run at the same spot. We fixed both: the cursor is now keyed by contract and start block, and the later reveal wins. Each fix came with a test, and we check those tests by planting the old bug back and watching them go red.

Every DePunk shows the DeBot it unlocked, one click away.

The DePunks list and each DePunk's page now show "Used as a key for DeBot #N". The link opens the DeBots grid with that tile ringed. The first version of that link rendered every tile from the top down to the target. The server now sends only the page that holds it.

A DePunk page in the dark theme: a pixel-art punk with blonde hair on a grey banner, the title 'DePunk 4376', the line 'Used as a key for DeBot #1838' with the DeBot as a pink link, and trait chips: Type Female, Skin Dark, Status Upgraded, Attribute Count 2, Ear Earring, Head Blonde Bob.
Each DePunk shows the DeBot it unlocked.
The DeBots grid in the dark theme: 'Newest / Oldest · 3,500 DEBOTS' above four rows of numbered tiles from #1870 to #1835. Tile #1838 has a lime ring.
The link sends one page, not all 3,500 tiles.

The table and the chain agree, row for row.

Numbers, as of 22 Oct 2026, at depunks commit 6e1350c:

770= 770

Read together on 22 Oct 2026, the key-use table holds 770 rows, and the reveal contract's own counter reads 770.

about3.3MB

the HTML of the deep link to the oldest DeBot, before the fix

about60KB

the same link, after the fix

69of 69

tests pass for the live-owner check, the key-use sync and the deep link

3of 3

planted bugs turn those tests red: an owner check that fails open, a cursor that forgets its contract, and a sync that reads unfinalized blocks

The copy is honest, but it is still a copy.

A reveal shows up on the site only once its block is finalized, not the moment it happens. Test and production share one database on purpose, because chain state can't be faithfully mocked. So every schema change goes to that shared database before either side deploys, and every reader of the new table has to cope with the table not being there yet. The page layout itself still has no automated render tests (React can't be loaded in the app's test setup). It was checked by hand.