← Back to list

One Wrong Bit: How a Single Coin-Type Encoding Broke 37 Tests in Our ENS-Style Name Service

*A debugging story about ENSIP-11, SLIP-44, and why “it compiles” is the most dangerous kind of green.*

Roy in DEXignation · 2026-06-13 05:20 · 0 claps · 5.3 min read
#web3 #solidity #en #dex #blockchain
Open on Medium ↗
Wiki topics: CRY · Crypto & Web3 💻 · Programming 👗 · Fashion

One Wrong Bit: How a Single Coin-Type Encoding Broke 37 Tests in Our ENS-Style Name Service

A debugging story about ENSIP-11, SLIP-44, and why “it compiles” is the most dangerous kind of green.

— -

When I ran the test suite for our .dex name-service contracts, the terminal told a brutal story: 45 passing, 37 failing. Every one of the 37 died with the same four words.

reverted with reason string ‘Unsupported coin type’

Thirty-seven failures, one error message. That uniformity is a gift — it means there’s almost certainly a single root cause, not thirty-seven of them. This is the story of chasing that cause down, and the two more subtle bugs hiding behind it.

— -

The clue in the stack trace

The first thing I noticed: the 45 passing tests never registered a name. They tested commitments, reservations, discount calculations, ERC-165 support — everything except the core register() path. The instant a test actually registered a .dex name, it reverted.

Every failing trace bottomed out in the same chain:

register → _executeRegister → DXResolver.setAddr → revert “Unsupported coin type”

So registration itself was sound. The death happened one step deeper, when the registrar tried to write the new name’s initial address record into the resolver. Our registrar does this atomically during registration so a freshly bought name resolves to its owner immediately — a nice UX touch that turned out to be the tripwire.

— -

Two dialects of the same standard

Here’s the line that mattered, in the registrar:

uint256 constant COIN_TYPE_POLYGON = COIN_TYPE_DEFAULT | CHAIN_ID_POLYGON;
IDXResolver(resolver).setAddr(subnode, COIN_TYPE_POLYGON, abi.encodePacked(owner));

COIN_TYPE_DEFAULT is 0x80000000. So COIN_TYPE_POLYGON is not 137 — it’s 0x80000089, the integer 2147483785. The registrar was speaking ENSIP-11.

And here’s how the resolver validated it:

supportedCoins[137] = “Polygon”; // plain 137
// …
require(bytes(supportedCoins[coinType]).length > 0, “Unsupported coin type”);

The resolver was speaking SLIP-44. It looked up supportedCoins[0x80000089], found nothing, and reverted. Both sides believed they were implementing the same multi-chain address standard. They were implementing two different layers of it.

— -

Why this trap is so easy to fall into

ENS, over its history, used two schemes for identifying chains in address records:

  • SLIP-44 gives each chain a flat number. Ethereum is 60.
  • ENSIP-11 encodes EVM chains as 0x80000000 | chainId. The high bit means “this is an EVM chain,” and the remaining bits hold the chain id.

The cruel part is Polygon. Its chain id is 137. In a SLIP-44-flavored table you might also write 137. They look identical, so a developer eyeballing the two files would see “137 here, 137 there” and move on. But the ENSIP-11 value carries the high bit, so it’s really 0x80000089. And the moment you test Ethereum — chain id 1, SLIP-44 60 — the illusion of interchangeability shatters.

The bug compiled cleanly. It passed half the suite. It only exposed itself on the one path that crossed the boundary between the two contracts. That’s the most expensive kind of bug: the one that looks fine until it’s in front of a real user buying a real name.

— -

The fix that was already half-written

The satisfying twist: the project already had a correct implementation. A small library, EVMCoinUtils, encoded the standard properly:

uint256 constant COIN_TYPE_DEFAULT = 1 << 31; // 0x80000000

function isEVMCoinType(uint256 coinType) internal pure returns (bool) {
 return coinType == COIN_TYPE_DEFAULT || chainFromCoinType(coinType) > 0;
}

The registrar imported it. The resolver didn’t. The fix was to make the resolver drink from the same well:

  1. Register EVM chains under their ENSIP-11 keys, keep non-EVM chains (Bitcoin, Solana) on SLIP-44, and keep a legacy SLIP-44 60 entry for Ethereum so old records still resolve.
  2. Validate EVM addresses through the shared library instead of a hand-maintained list of chain numbers:
if (EVMCoinUtils.isEVMCoinType(coinType)) {
 require(addrBytes.length == 20, “EVM address must be 20 bytes”);
 return;
}
  1. Import the constant rather than redefine it. I was tempted to drop a COIN_TYPE_DEFAULT into the resolver, but that risks the exact drift we just fixed. One definition, imported everywhere.

I ran the suite again. 37 → 10. Progress — and a surprise.

— -

Behind the first bug, a second

The remaining 10 failures had nothing to do with coin types. Fixing the obvious bug had simply let the suite run far enough to expose what was hiding behind it.

The pattern was consistent: the resolver’s interface (IDXResolver) already declared a richer contract than the implementation delivered. The interface had error types like TextKeyTooLong, ContenthashTooLong. It had setApprovalForAll. It documented that reads should return empty data after expiry. The implementation had none of it.

When an interface is ahead of its implementation, the interface is usually the spec — it’s the promise the rest of the system was built against. So I finished the resolver to match:

  • Length limits on text keys (64 bytes), values (1024 bytes), and content hashes (128 bytes), reverting with the interface’s own error types.
  • Expiry-aware reads, so a name that has lapsed stops leaking its old records to whoever holds it next:
function text(bytes32 node, string calldata key) external view returns (string memory) {
 if (registry.isExpired(node)) return “”;
 return textRecords[node][key];
}
  • Operator approval (setApprovalForAll / isApprovedForAll), so an owner can delegate record management — table stakes for any serious name service.

10 → 4.

— -

Behind the second bug, a test that lied

The final four were burn tests: a domain should become burnable once it’s been expired past its grace period. They reverted with NotYetBurnable, which carries a burnableAt timestamp in the error.

So I did the arithmetic. I took burnableAt, subtracted the registration duration, subtracted the grace period, and checked whether the result matched the test’s register time. With a 70-day grace period, it matched exactly. The contract’s math was flawless.

The test, on the other hand, had defined:

const GRACE_PERIOD = 30n * 24n * 60n * 60n; // 30 days

and fast-forwarded only expiry + 30 days + 60 seconds. Against a contract whose grace period is 70 days — a deliberate product decision, documented right there in the contract header — that timestamp is still inside the grace window. The contract was correctly refusing to burn a name that wasn’t burnable yet.

This was the inverse of the previous two stages. The first two times, the contract was wrong and the tests were right. This time the test was stale and the contract was correct. The fix was a one-line constant change in the test file:

const GRACE_PERIOD = 70n * 24n * 60n * 60n;

4 → 0. Eighty-two passing.

— -

What I’d tell my past self

A shared standard is not a shared implementation. Both contracts “implemented ENSIP-11.” Only one imported the code that actually did. If a value encodes a protocol rule, it lives in exactly one place and everyone imports it. The cost of a redefined constant is a bug that compiles, passes half your tests, and waits for production.

Let the interface lead. When IDXResolver promised errors and functions the implementation didn’t have, the failing tests weren’t noise — they were the interface calling in its debts. An interface ahead of its implementation is a to-do list, not a discrepancy to paper over.

Before you trust a test, find out who’s wrong. It’s tempting to treat every red test as a contract bug. Two-thirds of mine were. The last third were the test lying. The only way to know is to compute the on-chain truth — that burnableAt value told me, unambiguously, that the contract was right.

Green arrives in layers. One opaque revert masked two more bugs underneath it. Each fix peeled back the next. If you’ve ever fixed “the bug” and watched a new wave of failures appear, that’s not regression — that’s your suite finally running deep enough to tell you the rest of the truth.

— -

The contracts are part of DEXignation, a .dex blockchain name service. If you’re building anything that touches ENS-style multi-chain address records, internalize the ENSIP-11 vs SLIP-44 distinction before you write your first setAddr — it will save you a terminal full of red.


메타데이터
post_id
0c7f89ec85f9
slug
one-wrong-bit-how-a-single-coin-type-encoding-broke-37-tests-in-our-ens-style-name-service-0c7f89ec85f9
url
https://medium.com/dexignation/one-wrong-bit-how-a-single-coin-type-encoding-broke-37-tests-in-our-ens-style-name-service-0c7f89ec85f9
canonical_url
https://medium.com/dexignation/one-wrong-bit-how-a-single-coin-type-encoding-broke-37-tests-in-our-ens-style-name-service-0c7f89ec85f9
author_url
https://medium.com/@punditcode
status
ok
fetched_at
2026-06-17 08:20:12