Audit trail

The contract diff

This is every line that differs between the audited wei-names base and the deployed NameNFT and SubdomainRegistrar.
Nothing is summarised away: the sections below cover all changed lines, the NameCard renderer is a brand-new file, not a diff, it is shown in full.
Back to the audit overview

Sponsored registration: revealFor

New function alongside reveal. It binds the commitment to an explicit owner and mints the name to that owner while the caller pays the fee and receives any refund. It is a byte-for-byte copy of reveal with two substitutions (the commitment binds owner instead of msg.sender, and the name mints to owner). The only changes to reveal itself are the new referrerId parameter and the _handleReferral call shown below. This is the relayer-safe reveal Cantina recommended (findings WNS-13 and WNS-10): because the commitment binds the recipient, a sponsor or relayer flow cannot be front-run to redirect the name, and refunds go to the payer. Like reveal, it also binds the referrer into the commitment (see Referral rewards below).

+34 −0 across 1 hunk

src/NameNFT.sol+34 −0 · line 279
272279
273280 delete commitments[commitment];
274281 _register(string(normalized), 0, msg.sender);
282+ _handleReferral(tokenId, referrerId, total);
283+
284+ if (msg.value > total) {
285+ SafeTransferLib.safeTransferETH(msg.sender, msg.value - total);
286+ }
287+ }
288+
289+ /// @dev Same as reveal, except the commitment must bind `owner` and the name mints to `owner`.
290+ /// msg.sender pays the fee; excess is refunded to msg.sender.
291+ function revealFor(string calldata label, address owner, uint256 referrerId, bytes32 secret)
292+ external
293+ payable
294+ nonReentrant
295+ returns (uint256 tokenId)
296+ {
297+ uint256 fee = getFee(bytes(label).length);
298+ bytes memory normalized = _validateAndNormalize(bytes(label));
299+
300+ tokenId = uint256(keccak256(abi.encodePacked(ETHER_NODE, keccak256(normalized))));
301+ uint256 premium = getPremium(tokenId);
302+ uint256 total = fee + premium;
303+
304+ if (msg.value < total) revert InsufficientFee();
305+
306+ bytes32 commitment = _commitmentHash(normalized, owner, referrerId, secret);
307+ uint256 committedAt = commitments[commitment];
308+
309+ if (committedAt == 0) revert CommitmentNotFound();
310+ if (block.timestamp < committedAt + MIN_COMMITMENT_AGE) revert CommitmentTooNew();
311+ if (block.timestamp > committedAt + MAX_COMMITMENT_AGE) revert CommitmentTooOld();
312+
313+ delete commitments[commitment];
314+ _register(string(normalized), 0, owner);
315+ _handleReferral(tokenId, referrerId, total);
275316
276317 if (msg.value > total) {
277318 SafeTransferLib.safeTransferETH(msg.sender, msg.value - total);

Ownerless, no admin, fees burned

Removed Ownable (its inheritance and the constructor's _initializeOwner) and every owner-only function: the fee and premium setters and withdraw. The mutable fee storage becomes constants and getFee goes view to pure. With no withdraw and no owner, paid ETH is locked in the contract forever, effectively burned. No privileged role remains.

+14 −12 across 3 hunks

src/NameNFT.sol+3 −3 · line 1
11 // SPDX-License-Identifier: MIT
2-pragma solidity ^0.8.30;
2+pragma solidity 0.8.36;
33
44 import {Base64} from "solady/utils/Base64.sol";
55 import {ERC721} from "solady/tokens/ERC721.sol";
6-import {Ownable} from "solady/auth/Ownable.sol";
76 import {LibString} from "solady/utils/LibString.sol";
87 import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol";
8+import {NameCard} from "./NameCard.sol";
99 import {ReentrancyGuard} from "soledge/utils/ReentrancyGuard.sol";
1010
1111 /// @title NameNFT
12-/// @notice ENS-style naming system for .wei TLD with ERC721 ownership
12+/// @notice ENS-style naming system for .ether TLD with ERC721 ownership
1313 /// @dev Token ID = uint256(namehash). ENS-compatible resolution.
1414 ///
1515 /// Unicode Support:
src/NameNFT.sol+1 −1 · line 18
1818 /// - For proper Unicode normalization, callers SHOULD pre-normalize using ENSIP-15
1919 /// - Off-chain: use adraffy/ens-normalize library or equivalent before calling
2020 /// - Example: normalize("RaFFY🚴‍♂️") => "raffy🚴‍♂" (do this off-chain, then call contract)
21-contract NameNFT is ERC721, Ownable, ReentrancyGuard {
21+contract NameNFT is ERC721, ReentrancyGuard {
2222 using LibString for uint256;
2323
2424 /*//////////////////////////////////////////////////////////////
src/NameNFT.sol+10 −8 · line 112
114112 mapping(uint256 => mapping(uint256 => mapping(uint256 => bytes))) internal _coinAddr;
115113 mapping(uint256 => mapping(uint256 => mapping(string => string))) internal _text;
116114
117- /*//////////////////////////////////////////////////////////////
118- CONSTRUCTOR
119- //////////////////////////////////////////////////////////////*/
115+ /// @dev Stateless certificate renderer, pinned forever at deployment
116+ NameCard public immutable card;
120117
121- constructor() payable {
122- _initializeOwner(tx.origin);
123- defaultFee = DEFAULT_FEE;
124- maxPremium = 100 ether;
125- premiumDecayPeriod = 21 days;
118+ constructor(NameCard nameCard) {
119+ if (address(nameCard) == address(0)) revert InvalidRenderer();
120+ // probe the renderer: a wrong address (an EOA, a wrong contract) must
121+ // fail the deployment here, not every tokenURI call forever
122+ if (bytes(nameCard.render("a.ether", bytes32(0), NameCard.Status.Active)).length < 100) {
123+ revert InvalidRenderer();
124+ }
125+ card = nameCard;
126126 }
127127
128128 /*//////////////////////////////////////////////////////////////

Reject U+FFFE and U+FFFF labels

Both label validators (the reverting _validateAndNormalize and the isAvailable mirror) reject the two Unicode noncharacters U+FFFE and U+FFFF, next to the existing overlong and surrogate checks. They are valid UTF-8 but are not legal XML 1.0 characters and have no escape, so a label containing one would produce a certificate SVG that is not well-formed XML. Every other code point, including U+FFFD and 4-byte noncharacters, stays valid.

+3 −0 across 2 hunks

src/NameNFT.sol+1 −0 · line 603
539603 if (b1 < 0x80 || b1 > 0xBF || b2 < 0x80 || b2 > 0xBF) return false;
540604 if (cb == 0xE0 && b1 < 0xA0) return false; // Overlong
541605 if (cb == 0xED && b1 >= 0xA0) return false; // Surrogate
606+ if (cb == 0xEF && b1 == 0xBF && b2 >= 0xBE) return false; // U+FFFE/U+FFFF, not legal XML
542607 normalized[i] = b[i];
543608 normalized[i + 1] = b[i + 1];
544609 normalized[i + 2] = b[i + 2];
src/NameNFT.sol+2 −0 · line 831
758831 if (cb == 0xE0 && b1 < 0xA0) revert InvalidName();
759832 // Reject surrogates (0xED followed by 0xA0-0xBF = U+D800-U+DFFF)
760833 if (cb == 0xED && b1 >= 0xA0) revert InvalidName();
834+ // Reject U+FFFE and U+FFFF (valid UTF-8 but not legal XML, would break the card)
835+ if (cb == 0xEF && b1 == 0xBF && b2 >= 0xBE) revert InvalidName();
761836 result[i] = b[i];
762837 result[i + 1] = b[i + 1];
763838 result[i + 2] = b[i + 2];

On-chain certificate renderer

tokenURI renders a real certificate SVG (name, hash, status label) via the NameCard contract in every state, instead of the old plain placeholder, and the JSON carries a Status attribute. The constructor now takes the renderer, probes it, and pins its address as immutable, so a wrong renderer fails at deploy time rather than on every call forever. NameCard is a separate, stateless contract shown in full below.

+24 −151 across 6 hunks

src/NameNFT.sol+2 −3 · line 29
2929 error TooDeep();
3030 error EmptyLabel();
3131 error InvalidName();
32+ error Unauthorized();
3233 error InvalidLength();
33- error LengthMismatch();
3434 error NotParentOwner();
35- error PremiumTooHigh();
3635 error InsufficientFee();
3736 error AlreadyCommitted();
3837 error CommitmentTooNew();
3938 error CommitmentTooOld();
4039 error AlreadyRegistered();
4140 error CommitmentNotFound();
42- error DecayPeriodTooLong();
41+ error InvalidRenderer();
4342
4443 /*//////////////////////////////////////////////////////////////
4544 EVENTS
src/NameNFT.sol+20 −46 · line 154
154154 if (!_recordExists(tokenId)) revert TokenDoesNotExist();
155155
156156 NameRecord storage record = records[tokenId];
157-
158- // Check for stale subdomain FIRST (parent epoch mismatch)
159- if (record.parent != 0) {
160- NameRecord storage parentRecord = records[record.parent];
161- if (record.parentEpoch != parentRecord.epoch) {
162- return string.concat(
163- "data:application/json;base64,",
164- Base64.encode(
165- bytes(
166- '{"name":"[Invalid]","description":"This subdomain is no longer valid.","image":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MDAgNDAwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iIzk5OSIvPjx0ZXh0IHg9IjIwMCIgeT0iMjAwIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iI2ZmZiI+W0ludmFsaWRdPC90ZXh0Pjwvc3ZnPg=="}'
167- )
168- )
169- );
170- }
171- }
172-
173- // Check for expired (top-level or parent chain expired)
174- if (!_isActive(tokenId)) {
175- return string.concat(
176- "data:application/json;base64,",
177- Base64.encode(
178- bytes(
179- '{"name":"[Expired]","description":"This name has expired.","image":"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0MDAgNDAwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjQwMCIgZmlsbD0iIzk5OSIvPjx0ZXh0IHg9IjIwMCIgeT0iMjAwIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIyNCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZmlsbD0iI2ZmZiI+W0V4cGlyZWRdPC90ZXh0Pjwvc3ZnPg=="}'
180- )
157+ NameCard.Status status = _cardStatus(tokenId);
158+
159+ // a stale chain cannot be rebuilt, so fall back to the token's own label
160+ string memory built = _buildFullName(tokenId);
161+ string memory fullName =
162+ string.concat(bytes(built).length == 0 ? record.label : built, ".ether");
163+
164+ string[5] memory statusNames = ["Active", "Subdomain", "Grace", "Expired", "Invalid"];
165+ string memory attributes = string.concat(
166+ ',"attributes":[{"trait_type":"Status","value":"',
167+ statusNames[uint256(status)],
168+ '"}',
169+ record.parent == 0
170+ ? string.concat(
171+ ',{"trait_type":"Expires","display_type":"date","value":',
172+ uint256(record.expiresAt).toString(),
173+ "}"
181174 )
182- );
183- }
184-
185- string memory fullName = _buildFullName(tokenId);
186- fullName = string.concat(fullName, ".wei");
187- string memory displayName = bytes(fullName).length <= 20
188- ? fullName
189- : string.concat(_truncateUTF8(fullName, 17), "...");
190-
191- // Build attributes with expiry info for marketplace compatibility
192- string memory attributes;
193- if (record.parent == 0) {
194- // Top-level name: show expiry
195- attributes = string.concat(
196- ',"attributes":[{"trait_type":"Expires","display_type":"date","value":',
197- uint256(record.expiresAt).toString(),
198- "}]"
199- );
200- } else {
201- // Subdomain: no direct expiry
202- attributes = ',"attributes":[{"trait_type":"Type","value":"Subdomain"}]';
203- }
175+ : "",
176+ "]"
177+ );
204178
205179 string memory escapedName = _escapeJSON(fullName);
206180
src/NameNFT.sol+2 −2 · line 185
211185 string.concat(
212186 '{"name":"',
213187 escapedName,
214- '","description":"Wei Name Service: ',
188+ '","description":"EtherNames: ',
215189 escapedName,
216190 '","image":"data:image/svg+xml;base64,',
217- Base64.encode(bytes(_generateSVG(displayName))),
191+ Base64.encode(bytes(card.render(fullName, bytes32(tokenId), status))),
218192 '"',
219193 attributes,
220194 "}"
src/NameNFT.sol+0 −28 · line 884
809884 }
810885 }
811886
812- /// @dev Truncate string to maxBytes, ensuring we don't cut in the middle of a UTF-8 character
813- function _truncateUTF8(string memory str, uint256 maxBytes)
814- internal
815- pure
816- returns (string memory)
817- {
818- bytes memory b = bytes(str);
819- if (b.length <= maxBytes) return str;
820-
821- // Find safe cut point - step back over UTF-8 continuation bytes (0x80-0xBF)
822- // This ensures we don't cut in the middle of a multi-byte character
823- uint256 cutPoint = maxBytes;
824- while (cutPoint > 0 && uint8(b[cutPoint]) >= 0x80 && uint8(b[cutPoint]) <= 0xBF) {
825- unchecked {
826- --cutPoint;
827- }
828- }
829- // cutPoint is now at either:
830- // - An ASCII byte (will be included as it's a complete character)
831- // - A multi-byte start byte (won't be included since we copy 0..cutPoint-1)
832-
833- bytes memory result = new bytes(cutPoint);
834- for (uint256 i; i < cutPoint; ++i) {
835- result[i] = b[i];
836- }
837- return string(result);
838- }
839-
840887 function _recordExists(uint256 tokenId) internal view returns (bool) {
841888 return bytes(records[tokenId].label).length > 0;
842889 }
src/NameNFT.sol+0 −8 · line 938
891938 return string.concat(record.label, ".", parentName);
892939 }
893940
894- function _generateSVG(string memory displayName) internal pure returns (string memory) {
895- return string.concat(
896- '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><rect width="400" height="400" fill="#fff"/><text x="200" y="200" font-family="sans-serif" font-size="24" text-anchor="middle" dominant-baseline="middle">',
897- _escapeXML(displayName),
898- "</text></svg>"
899- );
900- }
901-
902941 /// @dev Escape JSON special characters for safe metadata embedding
903942 function _escapeJSON(string memory input) internal pure returns (string memory) {
904943 bytes memory b = bytes(input);
src/NameNFT.sol+0 −64 · line 988
949988
950989 return string(result);
951990 }
952-
953- /// @dev Escape XML special characters for safe SVG embedding
954- function _escapeXML(string memory input) internal pure returns (string memory) {
955- bytes memory b = bytes(input);
956-
957- // Count how much extra space we need
958- uint256 extraLen;
959- unchecked {
960- for (uint256 i; i < b.length; ++i) {
961- bytes1 c = b[i];
962- if (c == 0x26) extraLen += 4;
963- else if (c == 0x3c) extraLen += 3;
964- else if (c == 0x3e) extraLen += 3;
965- else if (c == 0x22) extraLen += 5;
966- else if (c == 0x27) extraLen += 5;
967- }
968- }
969-
970- if (extraLen == 0) return input;
971-
972- bytes memory result = new bytes(b.length + extraLen);
973- uint256 j;
974-
975- unchecked {
976- for (uint256 i; i < b.length; ++i) {
977- bytes1 c = b[i];
978- if (c == 0x26) {
979- result[j++] = "&";
980- result[j++] = "a";
981- result[j++] = "m";
982- result[j++] = "p";
983- result[j++] = ";";
984- } else if (c == 0x3c) {
985- result[j++] = "&";
986- result[j++] = "l";
987- result[j++] = "t";
988- result[j++] = ";";
989- } else if (c == 0x3e) {
990- result[j++] = "&";
991- result[j++] = "g";
992- result[j++] = "t";
993- result[j++] = ";";
994- } else if (c == 0x22) {
995- result[j++] = "&";
996- result[j++] = "q";
997- result[j++] = "u";
998- result[j++] = "o";
999- result[j++] = "t";
1000- result[j++] = ";";
1001- } else if (c == 0x27) {
1002- result[j++] = "&";
1003- result[j++] = "a";
1004- result[j++] = "p";
1005- result[j++] = "o";
1006- result[j++] = "s";
1007- result[j++] = ";";
1008- } else {
1009- result[j++] = c;
1010- }
1011- }
1012- }
1013-
1014- return string(result);
1015- }
1016991 }

New file: src/NameCard.sol

The certificate renderer is entirely new code, not derived from the audited base. It is 337 lines of pure, stateless, string-building Solidity with no storage, no owner, and nothing to administer. It only ever returns an SVG string; the EVM's STATICCALL semantics make it incapable of mutating state or moving funds. NameCard on Etherscan

One canonical ETH address

setAddr and setAddrForCoin(id, 60, …) now write the legacy and the ENSIP-9 multicoin ETH record together, so the two getters can never disagree.

+21 −2 across 2 hunks

src/NameNFT.sol+7 −1 · line 390
347390 function setAddr(uint256 tokenId, address addr) public {
348391 if (!_isActive(tokenId)) revert Expired();
349392 if (ownerOf(tokenId) != msg.sender) revert Unauthorized();
350- _resolvedAddress[tokenId][recordVersion[tokenId]] = addr;
393+ uint256 v = recordVersion[tokenId];
394+ _resolvedAddress[tokenId][v] = addr;
395+ // keep the ENSIP-9 coin-60 record in lockstep so addr(node) and addr(node,60)
396+ // can never disagree; zero address canonicalizes to empty bytes in both slots
397+ bytes memory enc = addr == address(0) ? bytes("") : abi.encodePacked(addr);
398+ _coinAddr[tokenId][v][COIN_TYPE_ETH] = enc;
399+ emit AddressChanged(bytes32(tokenId), COIN_TYPE_ETH, enc);
351400 emit AddrChanged(bytes32(tokenId), addr);
352401 }
353402
src/NameNFT.sol+14 −1 · line 441
392441 function setAddrForCoin(uint256 tokenId, uint256 coinType, bytes calldata addr) public {
393442 if (!_isActive(tokenId)) revert Expired();
394443 if (ownerOf(tokenId) != msg.sender) revert Unauthorized();
395- _coinAddr[tokenId][recordVersion[tokenId]][coinType] = addr;
444+ uint256 v = recordVersion[tokenId];
445+ if (coinType == COIN_TYPE_ETH) {
446+ // ETH addresses must be 20 bytes (or empty to clear); mirror into the
447+ // legacy slot so both getters always denote the same address
448+ if (addr.length != 20 && addr.length != 0) revert InvalidLength();
449+ address dec = addr.length == 20 ? address(bytes20(addr)) : address(0);
450+ bytes memory enc = dec == address(0) ? bytes("") : bytes(addr);
451+ _resolvedAddress[tokenId][v] = dec;
452+ _coinAddr[tokenId][v][COIN_TYPE_ETH] = enc;
453+ emit AddressChanged(bytes32(tokenId), COIN_TYPE_ETH, enc);
454+ emit AddrChanged(bytes32(tokenId), dec);
455+ return;
456+ }
457+ _coinAddr[tokenId][v][coinType] = addr;
396458 emit AddressChanged(bytes32(tokenId), coinType, addr);
397459 }
398460

Renamed TLD: .wei to .ether

TLD namehash WEI_NODE becomes ETHER_NODE (= namehash('ether')), name()/symbol() become "EtherNames"/"ETHERNAME", and the suffix strings and suffix-stripping (4-byte .wei to 6-byte .ether).

+19 −23 across 7 hunks

src/NameNFT.sol+3 −9 · line 62
5962 event AddressChanged(bytes32 indexed node, uint256 coinType, bytes addr);
6063 event TextChanged(bytes32 indexed node, string indexed key, string value);
6164
62- // Admin events
63- event DefaultFeeChanged(uint256 fee);
64- event LengthFeeChanged(uint256 indexed length, uint256 fee);
65- event LengthFeeCleared(uint256 indexed length);
66- event PremiumSettingsChanged(uint256 maxPremium, uint256 decayPeriod);
67-
6865 /*//////////////////////////////////////////////////////////////
6966 CONSTANTS
7067 //////////////////////////////////////////////////////////////*/
7168
72- /// @dev Namehash of "wei" TLD - kept public for off-chain tooling
73- bytes32 public constant WEI_NODE =
74- 0xa82820059d5df798546bcc2985157a77c3eef25eba9ba01899927333efacbd6f;
69+ /// @dev Namehash of "ether" TLD - kept public for off-chain tooling
70+ bytes32 public constant ETHER_NODE =
71+ 0xb7f8c26395211ac249dcf196aa8bf23249a87186996f42c47bc4d55dfe608eee;
7572
7673 uint256 constant MAX_LABEL_LENGTH = 255;
7774 uint256 constant MIN_LABEL_LENGTH = 1;
src/NameNFT.sol+2 −2 · line 130
130130 //////////////////////////////////////////////////////////////*/
131131
132132 function name() public pure override(ERC721) returns (string memory) {
133- return "Wei Name Service";
133+ return "EtherNames";
134134 }
135135
136136 function symbol() public pure override(ERC721) returns (string memory) {
137- return "WEI";
137+ return "ETHERNAME";
138138 }
139139
140140 /// @dev Blocks transfers of inactive tokens, but allows mint (from==0) and burn (to==0)
src/NameNFT.sol+1 −1 · line 419
370419 function reverseResolve(address addr) public view returns (string memory) {
371420 uint256 tokenId = primaryName[addr];
372421 if (tokenId == 0 || !_isActive(tokenId) || resolve(tokenId) != addr) return "";
373- return string.concat(_buildFullName(tokenId), ".wei");
422+ return string.concat(_buildFullName(tokenId), ".ether");
374423 }
375424
376425 /*//////////////////////////////////////////////////////////////
src/NameNFT.sol+10 −8 · line 525
463525 return uint256(computeNamehash(fullName));
464526 }
465527
466- /// @notice Compute namehash for a full name (e.g. "sub.name.wei" or "name")
528+ /// @notice Compute namehash for a full name (e.g. "sub.name.ether" or "name")
467529 /// @dev This function is intentionally permissive - it lowercases and hashes any input.
468530 /// Registration enforces validation: valid UTF-8, no space/control chars/dot.
469531 /// Use normalize() to check if a label is valid for registration.
470532 function computeNamehash(string calldata fullName) public pure returns (bytes32 node) {
471533 bytes memory b = bytes(fullName);
472- if (b.length == 0) return WEI_NODE;
534+ if (b.length == 0) return ETHER_NODE;
473535
474536 uint256 len = b.length;
475537
476- // Strip .wei suffix if present
538+ // Strip .ether suffix if present
477539 if (
478- len >= 4 && b[len - 4] == 0x2e && (b[len - 3] == 0x77 || b[len - 3] == 0x57)
540+ len >= 6 && b[len - 6] == 0x2e && (b[len - 5] == 0x65 || b[len - 5] == 0x45)
541+ && (b[len - 4] == 0x74 || b[len - 4] == 0x54)
542+ && (b[len - 3] == 0x68 || b[len - 3] == 0x48)
479543 && (b[len - 2] == 0x65 || b[len - 2] == 0x45)
480- && (b[len - 1] == 0x69 || b[len - 1] == 0x49)
544+ && (b[len - 1] == 0x72 || b[len - 1] == 0x52)
481545 ) {
482- len -= 4;
546+ len -= 6;
483547 }
484548
485- if (len == 0) return WEI_NODE;
549+ if (len == 0) return ETHER_NODE;
486550 if (b[0] == 0x2e || b[len - 1] == 0x2e) revert EmptyLabel();
487551
488- node = WEI_NODE;
552+ node = ETHER_NODE;
489553 uint256 labelEnd = len;
490554
491555 for (uint256 i = len; i > 0; --i) {
src/NameNFT.sol+1 −1 · line 631
566631 // Hyphen rules
567632 if (normalized[0] == 0x2d || normalized[b.length - 1] == 0x2d) return false;
568633
569- bytes32 parentNode = parentId == 0 ? WEI_NODE : bytes32(parentId);
634+ bytes32 parentNode = parentId == 0 ? ETHER_NODE : bytes32(parentId);
570635 uint256 tokenId = uint256(keccak256(abi.encodePacked(parentNode, keccak256(normalized))));
571636
572637 if (parentId != 0 && !_isActive(parentId)) return false;
src/NameNFT.sol+1 −1 · line 650
585650 function getFullName(uint256 tokenId) public view returns (string memory) {
586651 string memory baseName = _buildFullName(tokenId);
587652 if (bytes(baseName).length == 0) return "";
588- return string.concat(baseName, ".wei");
653+ return string.concat(baseName, ".ether");
589654 }
590655
591656 /// @notice On-chain normalization (lowercases ASCII only)
src/NameNFT.sol+1 −1 · line 740
671740 returns (uint256 tokenId)
672741 {
673742 bytes memory normalized = _validateAndNormalize(bytes(label));
674- bytes32 parentNode = parentId == 0 ? WEI_NODE : bytes32(parentId);
743+ bytes32 parentNode = parentId == 0 ? ETHER_NODE : bytes32(parentId);
675744 tokenId = uint256(keccak256(abi.encodePacked(parentNode, keccak256(normalized))));
676745
677746 // Invariant: subdomain registration requires parent ownership

Referral rewards

New since the audit. A registration can name one referrer, another top-level .ether name, bound into the commitment through _commitmentHash so a pending reveal cannot be front-run onto a different referrer. While the referrer is active it earns REFERRAL_BPS (20%) of everything the name pays, at registration and on every renewal, sent to where it resolves via _handleReferral/_payReferral; the rest stays locked in the contract. referrerOf and referralEarned track it, ReferrerSet and ReferralPaid report it, and re-registering a name after full expiry clears both. A referrer that is missing, past its grace period, a subdomain, or the name itself is ignored, and the registration still succeeds.

+94 −46 across 9 hunks

src/NameNFT.sol+4 −0 · line 51
5251 event NameRenewed(uint256 indexed tokenId, uint256 newExpiresAt);
5352 event PrimaryNameSet(address indexed addr, uint256 indexed tokenId);
5453 event Committed(bytes32 indexed commitment, address indexed committer);
54+ event ReferrerSet(uint256 indexed tokenId, uint256 indexed referrerId);
55+ event ReferralPaid(
56+ uint256 indexed tokenId, uint256 indexed referrerId, address to, uint256 amount
57+ );
5558
5659 // ENS-compatible resolver events (use bytes32 node for tooling compatibility)
5760 event AddrChanged(bytes32 indexed node, address addr);
src/NameNFT.sol+8 −3 · line 78
8178 uint256 constant GRACE_PERIOD = 90 days;
8279 uint256 constant MAX_SUBDOMAIN_DEPTH = 10;
8380 uint256 constant COIN_TYPE_ETH = 60;
84- uint256 constant MAX_PREMIUM_CAP = 10000 ether;
85- uint256 constant MAX_DECAY_PERIOD = 3650 days;
86- uint256 constant DEFAULT_FEE = 0.001 ether;
81+ uint256 constant FEE_LEN1 = 0.05 ether; // 1-byte labels
82+ uint256 constant FEE_LEN2 = 0.05 ether; // 2-byte labels
83+ uint256 constant FEE_LEN3 = 0.005 ether; // 3-byte labels
84+ uint256 constant FEE_LEN4 = 0.005 ether; // 4-byte labels
85+ uint256 constant DEFAULT_FEE = 0.0005 ether; // 5+ byte labels
86+ uint256 constant MAX_PREMIUM = 100 ether;
87+ uint256 constant PREMIUM_DECAY_PERIOD = 21 days;
88+ uint256 constant REFERRAL_BPS = 2000;
8789
8890 /*//////////////////////////////////////////////////////////////
8991 STORAGE
src/NameNFT.sol+2 −6 · line 99
9799 uint64 parentEpoch;
98100 }
99101
100- uint256 public defaultFee;
101- uint256 public maxPremium;
102- uint256 public premiumDecayPeriod;
103-
104- mapping(uint256 => uint256) public lengthFees;
105- mapping(uint256 => bool) public lengthFeeSet;
106102 mapping(uint256 => NameRecord) public records;
107103 mapping(uint256 => uint256) public recordVersion;
108104 mapping(bytes32 => uint256) public commitments;
109105 mapping(address => uint256) public primaryName;
106+ mapping(uint256 => uint256) public referrerOf;
107+ mapping(uint256 => uint256) public referralEarned;
110108
111109 // Versioned resolver data
112110 mapping(uint256 => mapping(uint256 => address)) internal _resolvedAddress;
src/NameNFT.sol+35 −2 · line 198
224198 );
225199 }
226200
201+ /// @dev Card status for tokenURI. isExpired and inGracePeriod use the
202+ /// expiresAt sentinel and lie for subdomains, so a subdomain walks
203+ /// its parent chain: an epoch mismatch anywhere is Invalid forever,
204+ /// otherwise the top ancestor's clock decides.
205+ function _cardStatus(uint256 tokenId) internal view returns (NameCard.Status) {
206+ uint256 cur = tokenId;
207+ bool sub = records[cur].parent != 0;
208+ for (uint256 depth = 0; depth <= MAX_SUBDOMAIN_DEPTH; ++depth) {
209+ NameRecord storage r = records[cur];
210+ if (r.parent == 0) {
211+ if (block.timestamp > r.expiresAt + GRACE_PERIOD) return NameCard.Status.Expired;
212+ if (block.timestamp > r.expiresAt) return NameCard.Status.Grace;
213+ return sub ? NameCard.Status.Subdomain : NameCard.Status.Active;
214+ }
215+ if (r.parentEpoch != records[r.parent].epoch) return NameCard.Status.Invalid;
216+ cur = r.parent;
217+ }
218+ return NameCard.Status.Invalid;
219+ }
220+
227221 /*//////////////////////////////////////////////////////////////
228222 COMMIT-REVEAL
229223 //////////////////////////////////////////////////////////////*/
230224
231- function makeCommitment(string calldata label, address owner, bytes32 secret)
225+ /// @dev The one commitment preimage. Every commit and reveal path must derive
226+ /// the hash here so no field can ever be bound on one path and not another.
227+ function _commitmentHash(
228+ bytes memory normalized,
229+ address owner,
230+ uint256 referrerId,
231+ bytes32 secret
232+ ) internal pure returns (bytes32) {
233+ return keccak256(abi.encode(normalized, owner, referrerId, secret));
234+ }
235+
236+ /// @dev The referrer is part of the commitment so a pending reveal cannot be
237+ /// front-run with a different referrer. Pass 0 for no referrer.
238+ function makeCommitment(string calldata label, address owner, uint256 referrerId, bytes32 secret)
232239 public
233240 pure
234241 returns (bytes32)
235242 {
236243 bytes memory normalized = _validateAndNormalize(bytes(label));
237- return keccak256(abi.encode(normalized, owner, secret));
244+ return _commitmentHash(normalized, owner, referrerId, secret);
238245 }
239246
240247 function commit(bytes32 commitment) public {
src/NameNFT.sol+1 −1 · line 255
248255 emit Committed(commitment, msg.sender);
249256 }
250257
251- function reveal(string calldata label, bytes32 secret)
258+ function reveal(string calldata label, uint256 referrerId, bytes32 secret)
252259 external
253260 payable
254261 nonReentrant
src/NameNFT.sol+2 −2 · line 264
257264 uint256 fee = getFee(bytes(label).length);
258265 bytes memory normalized = _validateAndNormalize(bytes(label));
259266
260- tokenId = uint256(keccak256(abi.encodePacked(WEI_NODE, keccak256(normalized))));
267+ tokenId = uint256(keccak256(abi.encodePacked(ETHER_NODE, keccak256(normalized))));
261268 uint256 premium = getPremium(tokenId);
262269 uint256 total = fee + premium;
263270
264271 if (msg.value < total) revert InsufficientFee();
265272
266- bytes32 commitment = keccak256(abi.encode(normalized, msg.sender, secret));
273+ bytes32 commitment = _commitmentHash(normalized, msg.sender, referrerId, secret);
267274 uint256 committedAt = commitments[commitment];
268275
269276 if (committedAt == 0) revert CommitmentNotFound();
src/NameNFT.sol+2 −0 · line 362
321362
322363 emit NameRenewed(tokenId, record.expiresAt);
323364
365+ _handleReferral(tokenId, 0, fee);
366+
324367 if (msg.value > fee) {
325368 SafeTransferLib.safeTransferETH(msg.sender, msg.value - fee);
326369 }
src/NameNFT.sol+36 −32 · line 673
608673 FEE MANAGEMENT
609674 //////////////////////////////////////////////////////////////*/
610675
611- function getFee(uint256 length) public view returns (uint256) {
612- return lengthFeeSet[length] ? lengthFees[length] : defaultFee;
676+ function getFee(uint256 length) public pure returns (uint256) {
677+ if (length == 1) return FEE_LEN1;
678+ if (length == 2) return FEE_LEN2;
679+ if (length == 3) return FEE_LEN3;
680+ if (length == 4) return FEE_LEN4;
681+ return DEFAULT_FEE;
613682 }
614683
615684 function getPremium(uint256 tokenId) public view returns (uint256) {
616685 NameRecord storage record = records[tokenId];
617686 if (bytes(record.label).length == 0 || record.parent != 0) return 0;
618- if (maxPremium == 0 || premiumDecayPeriod == 0) return 0;
619687
620688 uint256 gracePeriodEnd = record.expiresAt + GRACE_PERIOD;
621689 if (block.timestamp <= gracePeriodEnd) return 0;
622690
623691 uint256 elapsed = block.timestamp - gracePeriodEnd;
624- if (elapsed >= premiumDecayPeriod) return 0;
692+ if (elapsed >= PREMIUM_DECAY_PERIOD) return 0;
625693
626- return maxPremium * (premiumDecayPeriod - elapsed) / premiumDecayPeriod;
694+ return MAX_PREMIUM * (PREMIUM_DECAY_PERIOD - elapsed) / PREMIUM_DECAY_PERIOD;
627695 }
628696
629697 /*//////////////////////////////////////////////////////////////
630- ADMIN FUNCTIONS
698+ REFERRALS
631699 //////////////////////////////////////////////////////////////*/
632700
633- function setDefaultFee(uint256 fee) public onlyOwner {
634- defaultFee = fee;
635- emit DefaultFeeChanged(fee);
636- }
637-
638- function setLengthFees(uint256[] calldata lengths, uint256[] calldata fees) public onlyOwner {
639- if (lengths.length != fees.length) revert LengthMismatch();
640- for (uint256 i; i < lengths.length; ++i) {
641- lengthFees[lengths[i]] = fees[i];
642- lengthFeeSet[lengths[i]] = true;
643- emit LengthFeeChanged(lengths[i], fees[i]);
701+ // A registration can optionally set a referrer: another top-level name, locked
702+ // into the commitment. It's fixed for that registration; re-registering after
703+ // expiry can set a new one. While the referrer is alive it earns 20% of what the
704+ // name pays (registration, renewals, premium), sent wherever it resolves. The
705+ // rest stays locked for good.
706+
707+ function _handleReferral(uint256 tokenId, uint256 referrerId, uint256 amount) internal {
708+ uint256 current = referrerOf[tokenId];
709+ if (current == 0) {
710+ // only a top-level name someone still holds qualifies; a bad one is ignored
711+ if (referrerId == 0 || referrerId == tokenId) return;
712+ NameRecord storage ref = records[referrerId];
713+ if (bytes(ref.label).length == 0 || ref.parent != 0) return;
714+ if (isExpired(referrerId)) return;
715+ referrerOf[tokenId] = referrerId;
716+ emit ReferrerSet(tokenId, referrerId);
717+ current = referrerId;
644718 }
719+ _payReferral(tokenId, current, amount);
645720 }
646721
647- function clearLengthFee(uint256 length) public onlyOwner {
648- delete lengthFees[length];
649- delete lengthFeeSet[length];
650- emit LengthFeeCleared(length);
651- }
722+ function _payReferral(uint256 tokenId, uint256 referrerId, uint256 amount) internal {
723+ address to = resolve(referrerId);
724+ // a dead referrer, or one pointing back here, earns nothing and stays locked
725+ if (to == address(0) || to == address(this)) return;
652726
653- function setPremiumSettings(uint256 _maxPremium, uint256 _decayPeriod) public onlyOwner {
654- if (_maxPremium > MAX_PREMIUM_CAP) revert PremiumTooHigh();
655- if (_decayPeriod > MAX_DECAY_PERIOD) revert DecayPeriodTooLong();
656- maxPremium = _maxPremium;
657- premiumDecayPeriod = _decayPeriod;
658- emit PremiumSettingsChanged(_maxPremium, _decayPeriod);
659- }
660-
661- function withdraw() public onlyOwner nonReentrant {
662- SafeTransferLib.safeTransferAllETH(msg.sender);
727+ uint256 share = amount * REFERRAL_BPS / 10_000;
728+ referralEarned[referrerId] += share;
729+ // force-send so a hostile recipient can't block the registration or renewal
730+ SafeTransferLib.forceSafeTransferETH(to, share);
731+ emit ReferralPaid(tokenId, referrerId, to, share);
663732 }
664733
665734 /*//////////////////////////////////////////////////////////////
src/NameNFT.sol+4 −0 · line 758
689758 if (block.timestamp <= existing.expiresAt + GRACE_PERIOD) {
690759 revert AlreadyRegistered();
691760 }
761+ // A fresh registration after full expiry clears the referrer and earnings tally
762+ // left by the previous one, so it records its own referrer instead of the old.
763+ delete referrerOf[tokenId];
764+ delete referralEarned[tokenId];
692765 }
693766 // Subdomain overwrites: parent owner can always reclaim (checked above)
694767 // Stale subdomains can also be overwritten by new parent owner

SubdomainRegistrar: sales settle on quoted terms

Subdomain purchases can no longer be repriced out from under a buyer. register/registerFor take maxPrice and expectedFeeToken and revert PriceTooHigh or UnexpectedFeeToken if a parent controller changes the price or fee token after the buyer commits to a purchase. Three address(this) guards close stranding holes: configure and withdrawParent reject the registrar as the payout or withdrawal target, registerFor rejects it as the mint recipient, and an escrowed parent that resolves to the registrar is rejected (AddrRecordRequired), which would otherwise trap payments sent to that name.

+31 −12 across 6 hunks

src/SubdomainRegistrar.sol+3 −0 · line 42
4142 error BadGateConfig();
4243 error NotAuthorized();
4344 error UnexpectedETH();
45+ error UnexpectedFeeToken();
46+ error AddrRecordRequired();
4447 error ValueTooLarge();
4548 error AlreadyEscrowed();
4649 error InsufficientFee();
50+ error PriceTooHigh();
4751 error StaleEscrow();
4852 error StaleController();
4953 error ETHTransferFailed();
src/SubdomainRegistrar.sol+1 −0 · line 138
132138 ) public {
133139 if (_controllerOf(parentId) != msg.sender) revert NotAuthorized();
134140 if (payout == address(0)) payout = msg.sender;
141+ if (payout == address(this)) revert NotAuthorized();
135142
136143 // prevent silent truncation into uint96
137144 if (price > type(uint96).max || minGateBalance > type(uint96).max) revert ValueTooLarge();
src/SubdomainRegistrar.sol+4 −0 · line 197
190197 escrowedEpoch[parentId] = epoch;
191198 name.transferFrom(msg.sender, address(this), parentId);
192199
200+ // an escrowed name must not resolve to this contract, or payments
201+ // sent to the name would strand here
202+ if (name.resolve(parentId) == address(this)) revert AddrRecordRequired();
203+
193204 emit Deposited(parentId, msg.sender);
194205 }
195206
src/SubdomainRegistrar.sol+1 −0 · line 214
203214 if (escrowedEpoch[parentId] != currentEpoch) revert StaleEscrow();
204215
205216 if (to == address(0)) to = msg.sender;
217+ if (to == address(this)) revert NotAuthorized();
206218
207219 // Disable to avoid stale always-on config after custody changes.
208220 Config storage c = config[parentId];
src/SubdomainRegistrar.sol+4 −0 · line 238
226238 // Ignore mints (from=0). Also ignore internal moves (from=this).
227239 if (from == address(0) || from == address(this)) return this.onERC721Received.selector;
228240
241+ // an escrowed name must not resolve to this contract, or payments
242+ // sent to the name would strand here
243+ if (name.resolve(tokenId) == address(this)) revert AddrRecordRequired();
244+
229245 escrowedController[tokenId] = from;
230246 (,,, uint64 epoch,) = name.records(tokenId);
231247 escrowedEpoch[tokenId] = epoch;
src/SubdomainRegistrar.sol+18 −12 · line 254
238254 REGISTRATION
239255 //////////////////////////////////////////////////////////////*/
240256
241- function register(uint256 parentId, string calldata label)
242- public
243- payable
244- returns (uint256 subId)
245- {
246- return registerFor(parentId, label, msg.sender);
257+ function register(
258+ uint256 parentId,
259+ string calldata label,
260+ uint256 maxPrice,
261+ address expectedFeeToken
262+ ) public payable returns (uint256 subId) {
263+ return registerFor(parentId, label, msg.sender, maxPrice, expectedFeeToken);
247264 }
248265
249266 /// @dev Gate + fee are evaluated against msg.sender (the payer).
250- function registerFor(uint256 parentId, string calldata label, address to)
251- public
252- payable
253- nonReentrant
254- returns (uint256 subId)
255- {
267+ function registerFor(
268+ uint256 parentId,
269+ string calldata label,
270+ address to,
271+ uint256 maxPrice,
272+ address expectedFeeToken
273+ ) public payable nonReentrant returns (uint256 subId) {
274+ if (to == address(this)) revert NotAuthorized();
275+
256276 Config memory c = config[parentId];
257277 if (!c.enabled) revert NotEnabled();
278+ if (c.feeToken != expectedFeeToken) revert UnexpectedFeeToken();
279+ if (uint256(c.price) > maxPrice) revert PriceTooHigh();
258280 if (!name.isAvailable(label, parentId)) revert NotAvailable();
259281
260282 // prevent sales after controller changes (transfer or escrow controller mismatch)

SubdomainRegistrar: immutable name, non-payable constructor

The hardcoded mainnet name address becomes immutable, injected via the constructor; the constructor also drops payable so a deploy that accidentally carries ETH reverts. The deprecated virtual on nonReentrant is removed, and the supporting error declarations are added.

+7 −4 across 4 hunks

src/SubdomainRegistrar.sol+1 −1 · line 1
11 // SPDX-License-Identifier: MIT
2-pragma solidity ^0.8.33;
2+pragma solidity 0.8.36;
33
44 interface IERC721Like {
55 function ownerOf(uint256 tokenId) external view returns (address);
src/SubdomainRegistrar.sol+1 −0 · line 17
1717 external
1818 returns (uint256);
1919 function isAvailable(string calldata label, uint256 parentId) external view returns (bool);
20+ function resolve(uint256 tokenId) external view returns (address);
2021 function records(uint256 tokenId)
2122 external
2223 view
src/SubdomainRegistrar.sol+1 −1 · line 96
9296 address payout; // receives ERC20 directly; ETH via ethBalance
9397 }
9498
95- INameNFT public constant name = INameNFT(0x0000000000696760E15f265e828DB644A0c242EB);
99+ INameNFT public immutable name;
96100
97101 mapping(uint256 => Config) public config;
98102 mapping(uint256 => address) public escrowedController; // nonzero => escrowed, controller recorded
src/SubdomainRegistrar.sol+4 −2 · line 105
101105
102106 uint256 constant _REENTRANCY_GUARD_SLOT = 0x929eee149b4bd21268;
103107
104- constructor() payable {}
108+ constructor(INameNFT nameNFT) {
109+ name = nameNFT;
110+ }
105111
106- modifier nonReentrant() virtual {
112+ modifier nonReentrant() {
107113 assembly ("memory-safe") {
108114 if tload(_REENTRANCY_GUARD_SLOT) {
109115 mstore(0x00, 0xab143c06)