← Back to list

The Bug That Doesn’t Throw: Making NFT Name Transfers Safe on DEXignation

There’s a category of smart-contract bug that never reverts, never logs an error, and never trips a test you didn’t think to write. It just…

Roy in DEXignation · 2026-06-18 09:15 · 0 claps · 8.2 min read
#web3 #blockchain #solidity #polygon #dex
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3

The Bug That Doesn’t Throw: Making NFT Name Transfers Safe on DEXignation

There’s a category of smart-contract bug that never reverts, never logs an error, and never trips a test you didn’t think to write. It just quietly sends someone’s money to the wrong person. We found one in our own naming service before launch — and the story of fixing it is really a story about what it means for a name to belong to someone on-chain.

What a name is supposed to do

DEXignation issues .dex names as ERC-721 NFTs on Polygon. A name like roy.dex isn’t decorative — it’s a routing target. You send funds to roy.dex, an agent pays roy.dex, a profile resolves from roy.dex. The entire value of a name is that it points to the address its current owner controls.

So a name has two halves that must always agree: the NFT (who owns it) and the resolution (where it points). The moment those two drift apart, the name lies.

The drift

Here’s the scenario that should keep any naming-service author up at night. Alice owns roy.dex. She sells the NFT to Bob — a normal ERC-721 transfer, the kind every marketplace does. Bob now holds the token. Someone sends funds to roy.dex.

In our v1 contracts, those funds went to Alice.

The NFT moved. The resolution didn’t. roy.dex kept pointing at Alice’s address because nothing in the system connected “the token changed hands” to “the name’s records should change too.” No revert. No event. No failing test. Just value flowing to someone who no longer owned the name.

It’s worth sitting with how ordinary the triggering action is. This isn’t an exotic attack that requires a malicious contract or a carefully crafted transaction. It’s a sale. The single most common thing anyone does with an NFT is the thing that breaks the invariant. A bug that only fires under adversarial conditions is bad; a bug that fires under the intended happy path is a different order of problem.

Why it drifted

When we traced it through the code, the gap wasn’t one broken line — it was a missing connection between three contracts that each worked fine on their own.

The registrar (the ERC-721) had no transfer hook, so a transfer changed ownerOf and nothing else. The registry kept its own ownership record — the one that actually gates who can edit a name’s resolution — and it only updated when someone manually called a reclaim function. A plain NFT sale never did. And the resolver, which stored the actual records, had no concept of “these belonged to a previous owner,” so every stale record survived the transfer fully intact.

Three contracts, three reasonable designs, and a fund-loss bug living in the space between them. That’s the uncomfortable lesson: the dangerous bugs often aren’t in a component, they’re in the assumptions about how components relate. Each contract had a clear, defensible contract with the world. What no one owned was the cross-contract guarantee — the promise that ownership, control, and resolution move together as one unit. Guarantees that span components tend to be nobody’s job until they fail.

The fix: clear the records, but keep the history

Two decisions mattered.

First, when to clear a name’s records. The answer is the obvious one — on every genuine transfer of ownership. The interesting part was hooking into the ERC-721 transfer lifecycle itself, so that the act of moving the token is the act of moving control. No separate call to remember, no way to forget. In OpenZeppelin’s ERC-721, every mint, transfer, and burn funnels through a single internal _update function. Override that one method and you have a chokepoint that no transfer path can route around — transferFrom, safeTransferFrom, and operator-initiated transfers all pass through it. That property mattered later, when we wanted to be sure no marketplace flow could sneak past the hook.

Second, how to clear them. The naive approach is to delete each record. But Solidity mappings can’t be enumerated — to delete every record you’d have to already know every key ever written, across addresses, text entries, contenthashes, profiles, ABIs, and agent records. That’s expensive and, worse, it’s the kind of thing that silently misses a key one day. You’d add a seventh record type a year from now, forget to add it to the deletion routine, and reintroduce the exact bug you thought you’d killed.

Instead we version the records. Every record lives under a version number, and a transfer just increments it. One cheap operation makes every record kind from the old owner unreachable at once — nothing to enumerate, nothing to miss. Each mapping went from being keyed by node to being keyed by node and the node’s current version; every read and write threads through the live version. Bump the version and the entire previous namespace falls out of view in a single storage write.

This also quietly fixes the future-proofing problem. A new record type added later is automatically covered, as long as it follows the same [node][version] convention — there’s no separate list to keep in sync. The invalidation isn’t a routine that has to know about every record kind; it’s a property of how records are addressed.

And there’s a bonus that fit our priorities exactly: the old records don’t get destroyed, they get superseded. They remain on chain under the previous version. So the name still has a complete, auditable history of who controlled it and where it pointed at each stage — which, for a service where names route money, is something we actively wanted rather than something to discard. (If this sounds familiar, it’s the same versioning approach ENS’s resolver has used in production for years.)

We added one more constraint while we were here: only the registrar can trigger an invalidation. The version bump is gated so that no external account — not even the contract owner — can reach in and reset someone else’s records. The only thing allowed to invalidate a name is the act of transferring it. That closes a griefing vector where an attacker might otherwise force-clear a name they don’t own.

The subtlety: registration is also a transfer

The cleanest bugs to fix are the ones whose fix introduces a new bug, and we hit ours immediately. After wiring up the transfer hook, a test that had passed for months started failing: freshly registered names had no address record.

The cause was a nice piece of irony. Our registration flow delivers the NFT by having the controller mint the token to itself, set the initial address record, then transfer the token to the buyer. That final transfer is — correctly — detected by our new hook, which dutifully invalidated the address we’d just set. The safety mechanism was working perfectly; it just couldn’t tell “delivery to a new owner” apart from “resale to a new owner.”

The fix was to teach it the difference. Transfers originating from a registration controller are deliveries, not resales, so they skip invalidation. Every other user-to-user transfer invalidates. One condition — checking whether the sender is a known controller — and the two cases that look identical to ERC-721 become distinct to us.

I want to dwell on this for a second, because the failing test is the hero of the story. Nothing about the new feature was wrong. The hook did exactly what it was designed to do. The only reason we didn’t ship a broken registration flow is that a regression test from months earlier asserted something concrete — “a freshly registered name resolves to its owner” — and that assertion turned a subtle interaction into a red line in the terminal. Tests that encode the boring, happy-path invariants earn their keep precisely when you change something unrelated.

Proving it

A fix to a never-throws bug is only as trustworthy as its tests, so we leaned hard on them. The suite grew to 155 passing tests, including a battery that registers a name, populates all six record kinds, transfers it, and asserts that control moved to the new owner, every record reads empty, the old owner can no longer write, the new owner can, resolution resumes once they do, and the version counter advanced while the old records persisted on chain. A separate edge-case set checks the corners: safeTransferFrom, approved-operator transfers, sequential transfers that each bump the version, and that nobody but the registrar can trigger an invalidation — not a random account, not even the contract owner.

The shape of those tests matters as much as the count. For a silent bug, the assertion you care about is the one about absence — that something is empty, unreachable, no longer writable. Happy-path tests confirm presence; safety here required confirming that the right things disappear at the right moment, and that the wrong things don’t.

Then we left the lab. We deployed to Polygon’s Amoy testnet and ran the whole thing on a live network — register, set records, transfer to a second wallet, and read the chain back. Version incremented, control moved, records cleared. The behavior held outside the deterministic comfort of a local test runner.

What the testnet taught us that the local tests couldn’t

The local suite is a controlled world: time advances when you tell it to, the price oracle says whatever you mocked, and gas is free. The live network is none of those things, and that’s the point of going there.

The Amoy run surfaced a string of issues that simply can’t exist locally. The testnet’s price feed was effectively dead — reads reverted — so we couldn’t even quote a registration price until we deployed a mock feed in its place. The mock itself then ran into our own staleness guard: the oracle refuses prices older than a fixed window, a deliberate defense against a frozen feed, which meant the mock’s timestamp had to be refreshed before each run. Then there was the gap between a mock price and a sane registration cost, which we had to tune so a test wallet could actually afford a name.

None of these were bugs in the transfer-safety work. They were reminders that “it passes locally” and “it works on a network” are different claims, and the distance between them is exactly where deployment surprises live.

A deployment ordering bug, caught on mainnet

The sharpest lesson came at the very end. Our deployment wires the contracts together in a sequence of post-deploy calls — grant the top-level domain to the registrar, connect the resolver, and so on. One of those calls requires the registrar to already own the domain node; otherwise it isn’t authorized to make the connection.

On Amoy, this worked. On mainnet, it reverted with an authorization error.

The difference was ordering. The deployment tool doesn’t guarantee execution order within a batch unless you declare the dependency explicitly, and on Amoy the calls happened to land in a workable order by luck. Mainnet’s batching arranged them differently, ran the dependent call first, and failed. The fix was to state the dependency outright — this call runs after that one — so the order is guaranteed rather than incidental.

It’s a humbling kind of bug: the code was identical on both networks, the tests were green, and the only variable was an ordering we’d left implicit. “It worked on the testnet” had quietly meant “it worked in one particular order that we never actually chose.”

Only after fixing that did we redeploy cleanly, verify the source on PolygonScan, confirm the registrar and resolver were wired to each other on-chain, and register the first name on the new contracts.

What we took away

The bugs that scare me most are the polite ones — the ones that don’t crash, don’t alert, and look like correct behavior right up until someone loses money. Defending against them isn’t really about clever code. It’s about noticing the assumptions sitting in the gaps between your components, and writing the test that turns a silent failure into a loud one.

A few things I’ll carry forward:

  • Invariants that span contracts need an owner. The moment a guarantee depends on two contracts agreeing, write down whose job it is to keep them in sync — and enforce it in code, not convention.
  • Prefer mechanisms that can’t forget. Versioning beats enumerated deletion not because it’s clever but because it has no maintenance burden; new record types are covered for free.
  • Let the boring tests guard the exciting changes. The happy-path assertion is what caught our regression. Don’t delete it because it “obviously passes.”
  • The testnet is not a smaller mainnet. It’s a different environment that agrees with mainnet most of the time, which is the most dangerous amount.

A name should never outlive its owner. Now, on DEXignation, it doesn’t.


메타데이터
post_id
a75bf99d70e8
slug
the-bug-that-doesnt-throw-making-nft-name-transfers-safe-on-dexignation-a75bf99d70e8
url
https://medium.com/dexignation/the-bug-that-doesnt-throw-making-nft-name-transfers-safe-on-dexignation-a75bf99d70e8
canonical_url
https://medium.com/dexignation/the-bug-that-doesnt-throw-making-nft-name-transfers-safe-on-dexignation-a75bf99d70e8
author_url
https://medium.com/@punditcode
status
ok
fetched_at
2026-06-22 17:31:34