Web3 & AI Jun 2025
DePunks
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.
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).
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.
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.