← Back to blog
Security

Audit summary: Spearbit on Username NFT registry

Spearbit reviewed the .sudo registry contracts in March 2026. Two medium findings, both fixed and re-reviewed before mainnet rollout. Full report inside, plus the engineering changes we made in response.

Sudo Security@security.sudo··5 min read
Security

In March 2026 we engaged Spearbit to audit the .sudo username NFT registry — the contracts that mint, transfer, expire and resolve every .sudo subname across our supported chains. This post summarises the findings and the fixes that shipped.

The full PDF is published on our public audits page. This post is the engineering-team plain-English version.

What was in scope

Spearbit reviewed:

  • UsernameRegistry.sol — the canonical registry that issues .sudo NFTs
  • RegistryResolver.sol — the wallet/address resolution adapter
  • RenewalPool.sol — the renewal-fee pool that subsidises expiring names for early adopters
  • EnsBridge.sol — the bridge that mirrors .sudo state to ENS for compatibility
  • MerkleClaim.sol — the bulk-claim contract used during the genesis airdrop

Total: 2,847 lines of Solidity across 5 contracts. Audit window: 12 calendar days, 2 senior Spearbit auditors plus 1 reviewing partner.

Findings

Spearbit returned 9 findings: 0 critical, 2 medium, 4 low, 3 informational.

M-01: Renewal grace period silently extended on transfer

The renewal contract granted a 30-day grace period after expiry during which a name's previous owner could renew before it became publicly mintable again. The audit found that transferring an expired name within the grace period would reset the grace clock for the new owner — effectively giving an attacker an unbounded grace period by transferring the name to a new wallet just before it expired.

Fix: The grace period is now tied to the name, not the owner. Transfers within grace inherit the remaining time; they don't reset it. The fix added 23 lines and was re-reviewed in 3 days.

Impact if unfixed: A determined attacker could have indefinitely squatted on an expired premium name by chain-transferring it through 1-day windows. Real-world likelihood: low (attack requires ongoing operator attention) but the asymmetric advantage was unacceptable, so we treated it as a medium.

M-02: tokenURI panic for legacy claims

MerkleClaim.sol issues names from the genesis airdrop. The migration of those names to the canonical registry preserved a special bit in the NFT's metadata struct. tokenURI() did not handle the case where this bit was set but the rest of the legacy metadata was zero — the function would revert with an underflow on the SVG generation path.

This wouldn't lose any funds. It would make those tokens unreadable in OpenSea, Etherscan and our own client until the on-chain metadata was rewritten.

Fix: Defensive zero-check at the top of the SVG path; render a neutral "Legacy" placeholder when the legacy bit is set but the rest of the struct is empty. 14 lines. Re-reviewed in 1 day.

Impact if unfixed: A small subset (~280 wallets) of genesis-era claim NFTs would have shown as "Untitled" in marketplaces. No funds at risk. We treated it as medium because of how visible the failure would have been.

L-01: Reusable nonce in resolver signatures

RegistryResolver.sol allowed pre-signed resolution updates to be relayed by a third party (a meta-transaction pattern). The signatures contained a nonce but not a deadline — a signed message could be replayed forever as long as the nonce hadn't been used yet.

Fix: Added a deadline field to the signed payload and EIP-712 type. Required a minor SDK change for builders, which we rolled in the same release. 18 lines + SDK constants.

L-02: Inconsistent event emission across owner transfers

Two of the four transfer entry points emitted Transfer(from, to, tokenId) and Renewal(owner, expiry); the other two only emitted Transfer. This made off-chain indexers (ours and third-party) under-count renewal events under specific flows.

Fix: All transfer paths now emit Renewal if a renewal occurred. 6 lines.

L-03: Off-by-one in MAXLABELLENGTH check

UsernameRegistry.sol validated label length with <= MAX_LABEL_LENGTH but the constant was set to 63 to match DNS. The intent was to reject names of length 64+, which the check did. But a separate path in RegistryResolver.sol truncated at byte 63 — meaning a name registered with bytes 0..63 (valid) would be resolved against bytes 0..62 (silent truncation).

Fix: Unified the constant and the truncation logic. 4 lines. This one was satisfying to fix — a single source of truth replaced what had been three.

L-04: Missing nonReentrant on claim()

MerkleClaim.sol's claim() was safe because it followed checks-effects-interactions, but the absence of a nonReentrant modifier meant a future change could regress this without anyone noticing. Spearbit recommended adding the modifier as a defence in depth.

Fix: Added. 1 line.

I-01..I-03: Naming, NatSpec, and gas

Three informational notes: - _internal prefix used inconsistently across helpers - One public function missing a complete NatSpec block - One for loop missing unchecked increment (gas)

All adopted in the same PR as the L-series fixes. ~30 lines of cleanups.

Process notes

Three things worked well that we'll keep doing:

Day-1 model handover. We started the engagement with a 90-minute call where two of our engineers walked Spearbit through the threat model, the off-chain components, and the SDK surface. Audits that start with "here is the contract, good luck" lose two or three days to context-building. Starting with the model meant findings started landing on day 2.

Slack channel. A shared Slack channel between the audit team and our engineering team meant questions landed in minutes, not days. Spearbit asked ~40 clarification questions across the engagement. The fast loop was easily the biggest accelerator.

Re-review before merge. Every fix was re-reviewed by Spearbit before we merged the PR. We treated audit findings like security-team code review: nothing is "fixed" until the reviewer says it is. This added 2 days to the engagement budget. Worth every minute.

What we didn't do (deliberately)

A few things Spearbit suggested that we declined, and why:

  • Switching `block.timestamp` to a Chainlink oracle for renewal calculations. Our renewals are at 1-year granularity; a ±15 minute clock drift doesn't change which side of the renewal window a transaction lands on. Adding a Chainlink dependency for this is more risk surface than the drift introduces.
  • Replacing the merkle airdrop pattern with a per-claim signature. The merkle pattern is well-understood and our genesis claim is one-shot. We didn't want to introduce an off-chain signer just for the airdrop.
  • Emitting NFT metadata on-chain for indexers. Storing metadata on-chain costs gas per write. We index off-chain and post deterministic URIs that resolve from any node. We will revisit this if indexer trust ever becomes an issue.

Each of these is documented in the audit report with our reasoning. Spearbit signed off on the deferred items.

Bottom line

12 days, 9 findings, 0 critical, all medium and low fixed and re-reviewed before mainnet. The .sudo registry has now been audited by Spearbit (this report), Trail of Bits (December 2025) and OtterSec (the bridge adapter, September 2025). All three reports are public on the audits index.

We will keep posting audit summaries here whenever a new one closes. The full PDFs always live on the audits page. If you spot something neither audit caught, the bug bounty pays up to $250,000 — full terms on the security & bounty page.

Sudo Labs security team

Subscribe

Get the next post in your wallet.