Audit trail
The contract diff
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
| 272 | 279 | |
| 273 | 280 | delete commitments[commitment]; |
| 274 | 281 | _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); | |
| 275 | 316 | |
| 276 | 317 | if (msg.value > total) { |
| 277 | 318 | 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
| 1 | 1 | // SPDX-License-Identifier: MIT |
| 2 | -pragma solidity ^0.8.30; | |
| 2 | +pragma solidity 0.8.36; | |
| 3 | 3 | |
| 4 | 4 | import {Base64} from "solady/utils/Base64.sol"; |
| 5 | 5 | import {ERC721} from "solady/tokens/ERC721.sol"; |
| 6 | -import {Ownable} from "solady/auth/Ownable.sol"; | |
| 7 | 6 | import {LibString} from "solady/utils/LibString.sol"; |
| 8 | 7 | import {SafeTransferLib} from "solady/utils/SafeTransferLib.sol"; |
| 8 | +import {NameCard} from "./NameCard.sol"; | |
| 9 | 9 | import {ReentrancyGuard} from "soledge/utils/ReentrancyGuard.sol"; |
| 10 | 10 | |
| 11 | 11 | /// @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 | |
| 13 | 13 | /// @dev Token ID = uint256(namehash). ENS-compatible resolution. |
| 14 | 14 | /// |
| 15 | 15 | /// Unicode Support: |
| 18 | 18 | /// - For proper Unicode normalization, callers SHOULD pre-normalize using ENSIP-15 |
| 19 | 19 | /// - Off-chain: use adraffy/ens-normalize library or equivalent before calling |
| 20 | 20 | /// - Example: normalize("RaFFY🚴♂️") => "raffy🚴♂" (do this off-chain, then call contract) |
| 21 | -contract NameNFT is ERC721, Ownable, ReentrancyGuard { | |
| 21 | +contract NameNFT is ERC721, ReentrancyGuard { | |
| 22 | 22 | using LibString for uint256; |
| 23 | 23 | |
| 24 | 24 | /*////////////////////////////////////////////////////////////// |
| 114 | 112 | mapping(uint256 => mapping(uint256 => mapping(uint256 => bytes))) internal _coinAddr; |
| 115 | 113 | mapping(uint256 => mapping(uint256 => mapping(string => string))) internal _text; |
| 116 | 114 | |
| 117 | - /*////////////////////////////////////////////////////////////// | |
| 118 | - CONSTRUCTOR | |
| 119 | - //////////////////////////////////////////////////////////////*/ | |
| 115 | + /// @dev Stateless certificate renderer, pinned forever at deployment | |
| 116 | + NameCard public immutable card; | |
| 120 | 117 | |
| 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; | |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | 128 | /*////////////////////////////////////////////////////////////// |
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
| 539 | 603 | if (b1 < 0x80 || b1 > 0xBF || b2 < 0x80 || b2 > 0xBF) return false; |
| 540 | 604 | if (cb == 0xE0 && b1 < 0xA0) return false; // Overlong |
| 541 | 605 | 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 | |
| 542 | 607 | normalized[i] = b[i]; |
| 543 | 608 | normalized[i + 1] = b[i + 1]; |
| 544 | 609 | normalized[i + 2] = b[i + 2]; |
| 758 | 831 | if (cb == 0xE0 && b1 < 0xA0) revert InvalidName(); |
| 759 | 832 | // Reject surrogates (0xED followed by 0xA0-0xBF = U+D800-U+DFFF) |
| 760 | 833 | 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(); | |
| 761 | 836 | result[i] = b[i]; |
| 762 | 837 | result[i + 1] = b[i + 1]; |
| 763 | 838 | 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
| 29 | 29 | error TooDeep(); |
| 30 | 30 | error EmptyLabel(); |
| 31 | 31 | error InvalidName(); |
| 32 | + error Unauthorized(); | |
| 32 | 33 | error InvalidLength(); |
| 33 | - error LengthMismatch(); | |
| 34 | 34 | error NotParentOwner(); |
| 35 | - error PremiumTooHigh(); | |
| 36 | 35 | error InsufficientFee(); |
| 37 | 36 | error AlreadyCommitted(); |
| 38 | 37 | error CommitmentTooNew(); |
| 39 | 38 | error CommitmentTooOld(); |
| 40 | 39 | error AlreadyRegistered(); |
| 41 | 40 | error CommitmentNotFound(); |
| 42 | - error DecayPeriodTooLong(); | |
| 41 | + error InvalidRenderer(); | |
| 43 | 42 | |
| 44 | 43 | /*////////////////////////////////////////////////////////////// |
| 45 | 44 | EVENTS |
| 154 | 154 | if (!_recordExists(tokenId)) revert TokenDoesNotExist(); |
| 155 | 155 | |
| 156 | 156 | 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 | + "}" | |
| 181 | 174 | ) |
| 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 | + ); | |
| 204 | 178 | |
| 205 | 179 | string memory escapedName = _escapeJSON(fullName); |
| 206 | 180 |
| 211 | 185 | string.concat( |
| 212 | 186 | '{"name":"', |
| 213 | 187 | escapedName, |
| 214 | - '","description":"Wei Name Service: ', | |
| 188 | + '","description":"EtherNames: ', | |
| 215 | 189 | escapedName, |
| 216 | 190 | '","image":"data:image/svg+xml;base64,', |
| 217 | - Base64.encode(bytes(_generateSVG(displayName))), | |
| 191 | + Base64.encode(bytes(card.render(fullName, bytes32(tokenId), status))), | |
| 218 | 192 | '"', |
| 219 | 193 | attributes, |
| 220 | 194 | "}" |
| 809 | 884 | } |
| 810 | 885 | } |
| 811 | 886 | |
| 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 | - | |
| 840 | 887 | function _recordExists(uint256 tokenId) internal view returns (bool) { |
| 841 | 888 | return bytes(records[tokenId].label).length > 0; |
| 842 | 889 | } |
| 891 | 938 | return string.concat(record.label, ".", parentName); |
| 892 | 939 | } |
| 893 | 940 | |
| 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 | - | |
| 902 | 941 | /// @dev Escape JSON special characters for safe metadata embedding |
| 903 | 942 | function _escapeJSON(string memory input) internal pure returns (string memory) { |
| 904 | 943 | bytes memory b = bytes(input); |
| 949 | 988 | |
| 950 | 989 | return string(result); |
| 951 | 990 | } |
| 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 | - } | |
| 1016 | 991 | } |
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
| 347 | 390 | function setAddr(uint256 tokenId, address addr) public { |
| 348 | 391 | if (!_isActive(tokenId)) revert Expired(); |
| 349 | 392 | 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); | |
| 351 | 400 | emit AddrChanged(bytes32(tokenId), addr); |
| 352 | 401 | } |
| 353 | 402 |
| 392 | 441 | function setAddrForCoin(uint256 tokenId, uint256 coinType, bytes calldata addr) public { |
| 393 | 442 | if (!_isActive(tokenId)) revert Expired(); |
| 394 | 443 | 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; | |
| 396 | 458 | emit AddressChanged(bytes32(tokenId), coinType, addr); |
| 397 | 459 | } |
| 398 | 460 |
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
| 59 | 62 | event AddressChanged(bytes32 indexed node, uint256 coinType, bytes addr); |
| 60 | 63 | event TextChanged(bytes32 indexed node, string indexed key, string value); |
| 61 | 64 | |
| 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 | - | |
| 68 | 65 | /*////////////////////////////////////////////////////////////// |
| 69 | 66 | CONSTANTS |
| 70 | 67 | //////////////////////////////////////////////////////////////*/ |
| 71 | 68 | |
| 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; | |
| 75 | 72 | |
| 76 | 73 | uint256 constant MAX_LABEL_LENGTH = 255; |
| 77 | 74 | uint256 constant MIN_LABEL_LENGTH = 1; |
| 130 | 130 | //////////////////////////////////////////////////////////////*/ |
| 131 | 131 | |
| 132 | 132 | function name() public pure override(ERC721) returns (string memory) { |
| 133 | - return "Wei Name Service"; | |
| 133 | + return "EtherNames"; | |
| 134 | 134 | } |
| 135 | 135 | |
| 136 | 136 | function symbol() public pure override(ERC721) returns (string memory) { |
| 137 | - return "WEI"; | |
| 137 | + return "ETHERNAME"; | |
| 138 | 138 | } |
| 139 | 139 | |
| 140 | 140 | /// @dev Blocks transfers of inactive tokens, but allows mint (from==0) and burn (to==0) |
| 370 | 419 | function reverseResolve(address addr) public view returns (string memory) { |
| 371 | 420 | uint256 tokenId = primaryName[addr]; |
| 372 | 421 | if (tokenId == 0 || !_isActive(tokenId) || resolve(tokenId) != addr) return ""; |
| 373 | - return string.concat(_buildFullName(tokenId), ".wei"); | |
| 422 | + return string.concat(_buildFullName(tokenId), ".ether"); | |
| 374 | 423 | } |
| 375 | 424 | |
| 376 | 425 | /*////////////////////////////////////////////////////////////// |
| 463 | 525 | return uint256(computeNamehash(fullName)); |
| 464 | 526 | } |
| 465 | 527 | |
| 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") | |
| 467 | 529 | /// @dev This function is intentionally permissive - it lowercases and hashes any input. |
| 468 | 530 | /// Registration enforces validation: valid UTF-8, no space/control chars/dot. |
| 469 | 531 | /// Use normalize() to check if a label is valid for registration. |
| 470 | 532 | function computeNamehash(string calldata fullName) public pure returns (bytes32 node) { |
| 471 | 533 | bytes memory b = bytes(fullName); |
| 472 | - if (b.length == 0) return WEI_NODE; | |
| 534 | + if (b.length == 0) return ETHER_NODE; | |
| 473 | 535 | |
| 474 | 536 | uint256 len = b.length; |
| 475 | 537 | |
| 476 | - // Strip .wei suffix if present | |
| 538 | + // Strip .ether suffix if present | |
| 477 | 539 | 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) | |
| 479 | 543 | && (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) | |
| 481 | 545 | ) { |
| 482 | - len -= 4; | |
| 546 | + len -= 6; | |
| 483 | 547 | } |
| 484 | 548 | |
| 485 | - if (len == 0) return WEI_NODE; | |
| 549 | + if (len == 0) return ETHER_NODE; | |
| 486 | 550 | if (b[0] == 0x2e || b[len - 1] == 0x2e) revert EmptyLabel(); |
| 487 | 551 | |
| 488 | - node = WEI_NODE; | |
| 552 | + node = ETHER_NODE; | |
| 489 | 553 | uint256 labelEnd = len; |
| 490 | 554 | |
| 491 | 555 | for (uint256 i = len; i > 0; --i) { |
| 566 | 631 | // Hyphen rules |
| 567 | 632 | if (normalized[0] == 0x2d || normalized[b.length - 1] == 0x2d) return false; |
| 568 | 633 | |
| 569 | - bytes32 parentNode = parentId == 0 ? WEI_NODE : bytes32(parentId); | |
| 634 | + bytes32 parentNode = parentId == 0 ? ETHER_NODE : bytes32(parentId); | |
| 570 | 635 | uint256 tokenId = uint256(keccak256(abi.encodePacked(parentNode, keccak256(normalized)))); |
| 571 | 636 | |
| 572 | 637 | if (parentId != 0 && !_isActive(parentId)) return false; |
| 585 | 650 | function getFullName(uint256 tokenId) public view returns (string memory) { |
| 586 | 651 | string memory baseName = _buildFullName(tokenId); |
| 587 | 652 | if (bytes(baseName).length == 0) return ""; |
| 588 | - return string.concat(baseName, ".wei"); | |
| 653 | + return string.concat(baseName, ".ether"); | |
| 589 | 654 | } |
| 590 | 655 | |
| 591 | 656 | /// @notice On-chain normalization (lowercases ASCII only) |
| 671 | 740 | returns (uint256 tokenId) |
| 672 | 741 | { |
| 673 | 742 | bytes memory normalized = _validateAndNormalize(bytes(label)); |
| 674 | - bytes32 parentNode = parentId == 0 ? WEI_NODE : bytes32(parentId); | |
| 743 | + bytes32 parentNode = parentId == 0 ? ETHER_NODE : bytes32(parentId); | |
| 675 | 744 | tokenId = uint256(keccak256(abi.encodePacked(parentNode, keccak256(normalized)))); |
| 676 | 745 | |
| 677 | 746 | // 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
| 52 | 51 | event NameRenewed(uint256 indexed tokenId, uint256 newExpiresAt); |
| 53 | 52 | event PrimaryNameSet(address indexed addr, uint256 indexed tokenId); |
| 54 | 53 | 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 | + ); | |
| 55 | 58 | |
| 56 | 59 | // ENS-compatible resolver events (use bytes32 node for tooling compatibility) |
| 57 | 60 | event AddrChanged(bytes32 indexed node, address addr); |
| 81 | 78 | uint256 constant GRACE_PERIOD = 90 days; |
| 82 | 79 | uint256 constant MAX_SUBDOMAIN_DEPTH = 10; |
| 83 | 80 | 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; | |
| 87 | 89 | |
| 88 | 90 | /*////////////////////////////////////////////////////////////// |
| 89 | 91 | STORAGE |
| 97 | 99 | uint64 parentEpoch; |
| 98 | 100 | } |
| 99 | 101 | |
| 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; | |
| 106 | 102 | mapping(uint256 => NameRecord) public records; |
| 107 | 103 | mapping(uint256 => uint256) public recordVersion; |
| 108 | 104 | mapping(bytes32 => uint256) public commitments; |
| 109 | 105 | mapping(address => uint256) public primaryName; |
| 106 | + mapping(uint256 => uint256) public referrerOf; | |
| 107 | + mapping(uint256 => uint256) public referralEarned; | |
| 110 | 108 | |
| 111 | 109 | // Versioned resolver data |
| 112 | 110 | mapping(uint256 => mapping(uint256 => address)) internal _resolvedAddress; |
| 224 | 198 | ); |
| 225 | 199 | } |
| 226 | 200 | |
| 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 | + | |
| 227 | 221 | /*////////////////////////////////////////////////////////////// |
| 228 | 222 | COMMIT-REVEAL |
| 229 | 223 | //////////////////////////////////////////////////////////////*/ |
| 230 | 224 | |
| 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) | |
| 232 | 239 | public |
| 233 | 240 | pure |
| 234 | 241 | returns (bytes32) |
| 235 | 242 | { |
| 236 | 243 | bytes memory normalized = _validateAndNormalize(bytes(label)); |
| 237 | - return keccak256(abi.encode(normalized, owner, secret)); | |
| 244 | + return _commitmentHash(normalized, owner, referrerId, secret); | |
| 238 | 245 | } |
| 239 | 246 | |
| 240 | 247 | function commit(bytes32 commitment) public { |
| 248 | 255 | emit Committed(commitment, msg.sender); |
| 249 | 256 | } |
| 250 | 257 | |
| 251 | - function reveal(string calldata label, bytes32 secret) | |
| 258 | + function reveal(string calldata label, uint256 referrerId, bytes32 secret) | |
| 252 | 259 | external |
| 253 | 260 | payable |
| 254 | 261 | nonReentrant |
| 257 | 264 | uint256 fee = getFee(bytes(label).length); |
| 258 | 265 | bytes memory normalized = _validateAndNormalize(bytes(label)); |
| 259 | 266 | |
| 260 | - tokenId = uint256(keccak256(abi.encodePacked(WEI_NODE, keccak256(normalized)))); | |
| 267 | + tokenId = uint256(keccak256(abi.encodePacked(ETHER_NODE, keccak256(normalized)))); | |
| 261 | 268 | uint256 premium = getPremium(tokenId); |
| 262 | 269 | uint256 total = fee + premium; |
| 263 | 270 | |
| 264 | 271 | if (msg.value < total) revert InsufficientFee(); |
| 265 | 272 | |
| 266 | - bytes32 commitment = keccak256(abi.encode(normalized, msg.sender, secret)); | |
| 273 | + bytes32 commitment = _commitmentHash(normalized, msg.sender, referrerId, secret); | |
| 267 | 274 | uint256 committedAt = commitments[commitment]; |
| 268 | 275 | |
| 269 | 276 | if (committedAt == 0) revert CommitmentNotFound(); |
| 321 | 362 | |
| 322 | 363 | emit NameRenewed(tokenId, record.expiresAt); |
| 323 | 364 | |
| 365 | + _handleReferral(tokenId, 0, fee); | |
| 366 | + | |
| 324 | 367 | if (msg.value > fee) { |
| 325 | 368 | SafeTransferLib.safeTransferETH(msg.sender, msg.value - fee); |
| 326 | 369 | } |
| 608 | 673 | FEE MANAGEMENT |
| 609 | 674 | //////////////////////////////////////////////////////////////*/ |
| 610 | 675 | |
| 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; | |
| 613 | 682 | } |
| 614 | 683 | |
| 615 | 684 | function getPremium(uint256 tokenId) public view returns (uint256) { |
| 616 | 685 | NameRecord storage record = records[tokenId]; |
| 617 | 686 | if (bytes(record.label).length == 0 || record.parent != 0) return 0; |
| 618 | - if (maxPremium == 0 || premiumDecayPeriod == 0) return 0; | |
| 619 | 687 | |
| 620 | 688 | uint256 gracePeriodEnd = record.expiresAt + GRACE_PERIOD; |
| 621 | 689 | if (block.timestamp <= gracePeriodEnd) return 0; |
| 622 | 690 | |
| 623 | 691 | uint256 elapsed = block.timestamp - gracePeriodEnd; |
| 624 | - if (elapsed >= premiumDecayPeriod) return 0; | |
| 692 | + if (elapsed >= PREMIUM_DECAY_PERIOD) return 0; | |
| 625 | 693 | |
| 626 | - return maxPremium * (premiumDecayPeriod - elapsed) / premiumDecayPeriod; | |
| 694 | + return MAX_PREMIUM * (PREMIUM_DECAY_PERIOD - elapsed) / PREMIUM_DECAY_PERIOD; | |
| 627 | 695 | } |
| 628 | 696 | |
| 629 | 697 | /*////////////////////////////////////////////////////////////// |
| 630 | - ADMIN FUNCTIONS | |
| 698 | + REFERRALS | |
| 631 | 699 | //////////////////////////////////////////////////////////////*/ |
| 632 | 700 | |
| 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; | |
| 644 | 718 | } |
| 719 | + _payReferral(tokenId, current, amount); | |
| 645 | 720 | } |
| 646 | 721 | |
| 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; | |
| 652 | 726 | |
| 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); | |
| 663 | 732 | } |
| 664 | 733 | |
| 665 | 734 | /*////////////////////////////////////////////////////////////// |
| 689 | 758 | if (block.timestamp <= existing.expiresAt + GRACE_PERIOD) { |
| 690 | 759 | revert AlreadyRegistered(); |
| 691 | 760 | } |
| 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]; | |
| 692 | 765 | } |
| 693 | 766 | // Subdomain overwrites: parent owner can always reclaim (checked above) |
| 694 | 767 | // 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
| 41 | 42 | error BadGateConfig(); |
| 42 | 43 | error NotAuthorized(); |
| 43 | 44 | error UnexpectedETH(); |
| 45 | + error UnexpectedFeeToken(); | |
| 46 | + error AddrRecordRequired(); | |
| 44 | 47 | error ValueTooLarge(); |
| 45 | 48 | error AlreadyEscrowed(); |
| 46 | 49 | error InsufficientFee(); |
| 50 | + error PriceTooHigh(); | |
| 47 | 51 | error StaleEscrow(); |
| 48 | 52 | error StaleController(); |
| 49 | 53 | error ETHTransferFailed(); |
| 132 | 138 | ) public { |
| 133 | 139 | if (_controllerOf(parentId) != msg.sender) revert NotAuthorized(); |
| 134 | 140 | if (payout == address(0)) payout = msg.sender; |
| 141 | + if (payout == address(this)) revert NotAuthorized(); | |
| 135 | 142 | |
| 136 | 143 | // prevent silent truncation into uint96 |
| 137 | 144 | if (price > type(uint96).max || minGateBalance > type(uint96).max) revert ValueTooLarge(); |
| 190 | 197 | escrowedEpoch[parentId] = epoch; |
| 191 | 198 | name.transferFrom(msg.sender, address(this), parentId); |
| 192 | 199 | |
| 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 | + | |
| 193 | 204 | emit Deposited(parentId, msg.sender); |
| 194 | 205 | } |
| 195 | 206 |
| 203 | 214 | if (escrowedEpoch[parentId] != currentEpoch) revert StaleEscrow(); |
| 204 | 215 | |
| 205 | 216 | if (to == address(0)) to = msg.sender; |
| 217 | + if (to == address(this)) revert NotAuthorized(); | |
| 206 | 218 | |
| 207 | 219 | // Disable to avoid stale always-on config after custody changes. |
| 208 | 220 | Config storage c = config[parentId]; |
| 226 | 238 | // Ignore mints (from=0). Also ignore internal moves (from=this). |
| 227 | 239 | if (from == address(0) || from == address(this)) return this.onERC721Received.selector; |
| 228 | 240 | |
| 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 | + | |
| 229 | 245 | escrowedController[tokenId] = from; |
| 230 | 246 | (,,, uint64 epoch,) = name.records(tokenId); |
| 231 | 247 | escrowedEpoch[tokenId] = epoch; |
| 238 | 254 | REGISTRATION |
| 239 | 255 | //////////////////////////////////////////////////////////////*/ |
| 240 | 256 | |
| 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); | |
| 247 | 264 | } |
| 248 | 265 | |
| 249 | 266 | /// @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 | + | |
| 256 | 276 | Config memory c = config[parentId]; |
| 257 | 277 | if (!c.enabled) revert NotEnabled(); |
| 278 | + if (c.feeToken != expectedFeeToken) revert UnexpectedFeeToken(); | |
| 279 | + if (uint256(c.price) > maxPrice) revert PriceTooHigh(); | |
| 258 | 280 | if (!name.isAvailable(label, parentId)) revert NotAvailable(); |
| 259 | 281 | |
| 260 | 282 | // 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
| 1 | 1 | // SPDX-License-Identifier: MIT |
| 2 | -pragma solidity ^0.8.33; | |
| 2 | +pragma solidity 0.8.36; | |
| 3 | 3 | |
| 4 | 4 | interface IERC721Like { |
| 5 | 5 | function ownerOf(uint256 tokenId) external view returns (address); |
| 17 | 17 | external |
| 18 | 18 | returns (uint256); |
| 19 | 19 | function isAvailable(string calldata label, uint256 parentId) external view returns (bool); |
| 20 | + function resolve(uint256 tokenId) external view returns (address); | |
| 20 | 21 | function records(uint256 tokenId) |
| 21 | 22 | external |
| 22 | 23 | view |
| 92 | 96 | address payout; // receives ERC20 directly; ETH via ethBalance |
| 93 | 97 | } |
| 94 | 98 | |
| 95 | - INameNFT public constant name = INameNFT(0x0000000000696760E15f265e828DB644A0c242EB); | |
| 99 | + INameNFT public immutable name; | |
| 96 | 100 | |
| 97 | 101 | mapping(uint256 => Config) public config; |
| 98 | 102 | mapping(uint256 => address) public escrowedController; // nonzero => escrowed, controller recorded |
| 101 | 105 | |
| 102 | 106 | uint256 constant _REENTRANCY_GUARD_SLOT = 0x929eee149b4bd21268; |
| 103 | 107 | |
| 104 | - constructor() payable {} | |
| 108 | + constructor(INameNFT nameNFT) { | |
| 109 | + name = nameNFT; | |
| 110 | + } | |
| 105 | 111 | |
| 106 | - modifier nonReentrant() virtual { | |
| 112 | + modifier nonReentrant() { | |
| 107 | 113 | assembly ("memory-safe") { |
| 108 | 114 | if tload(_REENTRANCY_GUARD_SLOT) { |
| 109 | 115 | mstore(0x00, 0xab143c06) |