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), and reveal itself is unchanged. 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. Verified by 8 Halmos symbolic proofs, a dedicated forge suite, mutation testing, and a full anvil deployment rehearsal.
+32 −0 across 1 hunk
| 278 | 265 | } |
| 279 | 266 | } |
| 280 | 267 | |
| 268 | + /// @dev Same as reveal, except the commitment must bind `owner` and the name mints to `owner`. | |
| 269 | + /// msg.sender pays the fee; excess is refunded to msg.sender. | |
| 270 | + function revealFor(string calldata label, address owner, bytes32 secret) | |
| 271 | + external | |
| 272 | + payable | |
| 273 | + nonReentrant | |
| 274 | + returns (uint256 tokenId) | |
| 275 | + { | |
| 276 | + uint256 fee = getFee(bytes(label).length); | |
| 277 | + bytes memory normalized = _validateAndNormalize(bytes(label)); | |
| 278 | + | |
| 279 | + tokenId = uint256(keccak256(abi.encodePacked(ETHER_NODE, keccak256(normalized)))); | |
| 280 | + uint256 premium = getPremium(tokenId); | |
| 281 | + uint256 total = fee + premium; | |
| 282 | + | |
| 283 | + if (msg.value < total) revert InsufficientFee(); | |
| 284 | + | |
| 285 | + bytes32 commitment = keccak256(abi.encode(normalized, owner, secret)); | |
| 286 | + uint256 committedAt = commitments[commitment]; | |
| 287 | + | |
| 288 | + if (committedAt == 0) revert CommitmentNotFound(); | |
| 289 | + if (block.timestamp < committedAt + MIN_COMMITMENT_AGE) revert CommitmentTooNew(); | |
| 290 | + if (block.timestamp > committedAt + MAX_COMMITMENT_AGE) revert CommitmentTooOld(); | |
| 291 | + | |
| 292 | + delete commitments[commitment]; | |
| 293 | + _register(string(normalized), 0, owner); | |
| 294 | + | |
| 295 | + if (msg.value > total) { | |
| 296 | + SafeTransferLib.safeTransferETH(msg.sender, msg.value - total); | |
| 297 | + } | |
| 298 | + } | |
| 299 | + | |
| 281 | 300 | /*////////////////////////////////////////////////////////////// |
| 282 | 301 | SUBDOMAIN REGISTRATION |
| 283 | 302 | //////////////////////////////////////////////////////////////*/ |
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 → pure. With no withdraw and no owner, paid ETH is locked in the contract forever, effectively burned. No privileged role remains.
+28 −61 across 6 hunks
| 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 | /*////////////////////////////////////////////////////////////// |
| 81 | 74 | uint256 constant GRACE_PERIOD = 90 days; |
| 82 | 75 | uint256 constant MAX_SUBDOMAIN_DEPTH = 10; |
| 83 | 76 | 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; | |
| 77 | + uint256 constant FEE_LEN1 = 0.05 ether; // 1-byte labels | |
| 78 | + uint256 constant FEE_LEN2 = 0.05 ether; // 2-byte labels | |
| 79 | + uint256 constant FEE_LEN3 = 0.005 ether; // 3-byte labels | |
| 80 | + uint256 constant FEE_LEN4 = 0.005 ether; // 4-byte labels | |
| 81 | + uint256 constant DEFAULT_FEE = 0.0005 ether; // 5+ byte labels | |
| 82 | + uint256 constant MAX_PREMIUM = 100 ether; | |
| 83 | + uint256 constant PREMIUM_DECAY_PERIOD = 21 days; | |
| 87 | 84 | |
| 88 | 85 | /*////////////////////////////////////////////////////////////// |
| 89 | 86 | STORAGE |
| 97 | 94 | uint64 parentEpoch; |
| 98 | 95 | } |
| 99 | 96 | |
| 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 | 97 | mapping(uint256 => NameRecord) public records; |
| 107 | 98 | mapping(uint256 => uint256) public recordVersion; |
| 108 | 99 | mapping(bytes32 => uint256) public commitments; |
| 114 | 105 | mapping(uint256 => mapping(uint256 => mapping(uint256 => bytes))) internal _coinAddr; |
| 115 | 106 | mapping(uint256 => mapping(uint256 => mapping(string => string))) internal _text; |
| 116 | 107 | |
| 117 | - /*////////////////////////////////////////////////////////////// | |
| 118 | - CONSTRUCTOR | |
| 119 | - //////////////////////////////////////////////////////////////*/ | |
| 108 | + /// @dev Stateless certificate renderer, pinned forever at deployment | |
| 109 | + NameCard public immutable card; | |
| 120 | 110 | |
| 121 | - constructor() payable { | |
| 122 | - _initializeOwner(tx.origin); | |
| 123 | - defaultFee = DEFAULT_FEE; | |
| 124 | - maxPremium = 100 ether; | |
| 125 | - premiumDecayPeriod = 21 days; | |
| 111 | + constructor(NameCard nameCard) { | |
| 112 | + if (address(nameCard) == address(0)) revert InvalidRenderer(); | |
| 113 | + // probe the renderer: a wrong address (an EOA, a wrong contract) must | |
| 114 | + // fail the deployment here, not every tokenURI call forever | |
| 115 | + if (bytes(nameCard.render("a.ether", bytes32(0), NameCard.Status.Active)).length < 100) { | |
| 116 | + revert InvalidRenderer(); | |
| 117 | + } | |
| 118 | + card = nameCard; | |
| 126 | 119 | } |
| 127 | 120 | |
| 128 | 121 | /*////////////////////////////////////////////////////////////// |
| 608 | 648 | FEE MANAGEMENT |
| 609 | 649 | //////////////////////////////////////////////////////////////*/ |
| 610 | 650 | |
| 611 | - function getFee(uint256 length) public view returns (uint256) { | |
| 612 | - return lengthFeeSet[length] ? lengthFees[length] : defaultFee; | |
| 651 | + function getFee(uint256 length) public pure returns (uint256) { | |
| 652 | + if (length == 1) return FEE_LEN1; | |
| 653 | + if (length == 2) return FEE_LEN2; | |
| 654 | + if (length == 3) return FEE_LEN3; | |
| 655 | + if (length == 4) return FEE_LEN4; | |
| 656 | + return DEFAULT_FEE; | |
| 613 | 657 | } |
| 614 | 658 | |
| 615 | 659 | function getPremium(uint256 tokenId) public view returns (uint256) { |
| 616 | 660 | NameRecord storage record = records[tokenId]; |
| 617 | 661 | if (bytes(record.label).length == 0 || record.parent != 0) return 0; |
| 618 | - if (maxPremium == 0 || premiumDecayPeriod == 0) return 0; | |
| 619 | 662 | |
| 620 | 663 | uint256 gracePeriodEnd = record.expiresAt + GRACE_PERIOD; |
| 621 | 664 | if (block.timestamp <= gracePeriodEnd) return 0; |
| 622 | 665 | |
| 623 | 666 | uint256 elapsed = block.timestamp - gracePeriodEnd; |
| 624 | - if (elapsed >= premiumDecayPeriod) return 0; | |
| 667 | + if (elapsed >= PREMIUM_DECAY_PERIOD) return 0; | |
| 625 | 668 | |
| 626 | - return maxPremium * (premiumDecayPeriod - elapsed) / premiumDecayPeriod; | |
| 627 | - } | |
| 628 | - | |
| 629 | - /*////////////////////////////////////////////////////////////// | |
| 630 | - ADMIN FUNCTIONS | |
| 631 | - //////////////////////////////////////////////////////////////*/ | |
| 632 | - | |
| 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]); | |
| 644 | - } | |
| 645 | - } | |
| 646 | - | |
| 647 | - function clearLengthFee(uint256 length) public onlyOwner { | |
| 648 | - delete lengthFees[length]; | |
| 649 | - delete lengthFeeSet[length]; | |
| 650 | - emit LengthFeeCleared(length); | |
| 651 | - } | |
| 652 | - | |
| 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); | |
| 669 | + return MAX_PREMIUM * (PREMIUM_DECAY_PERIOD - elapsed) / PREMIUM_DECAY_PERIOD; | |
| 663 | 670 | } |
| 664 | 671 | |
| 665 | 672 | /*////////////////////////////////////////////////////////////// |
On-chain certificate renderer
tokenURI renders a real certificate SVG (name, hash, status chip) 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.
+44 −151 across 7 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 | 147 | if (!_recordExists(tokenId)) revert TokenDoesNotExist(); |
| 155 | 148 | |
| 156 | 149 | 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 | - ) | |
| 150 | + NameCard.Status status = _cardStatus(tokenId); | |
| 151 | + | |
| 152 | + // a stale chain cannot be rebuilt, so fall back to the token's own label | |
| 153 | + string memory built = _buildFullName(tokenId); | |
| 154 | + string memory fullName = | |
| 155 | + string.concat(bytes(built).length == 0 ? record.label : built, ".ether"); | |
| 156 | + | |
| 157 | + string[5] memory statusNames = ["Active", "Subdomain", "Grace", "Expired", "Invalid"]; | |
| 158 | + string memory attributes = string.concat( | |
| 159 | + ',"attributes":[{"trait_type":"Status","value":"', | |
| 160 | + statusNames[uint256(status)], | |
| 161 | + '"}', | |
| 162 | + record.parent == 0 | |
| 163 | + ? string.concat( | |
| 164 | + ',{"trait_type":"Expires","display_type":"date","value":', | |
| 165 | + uint256(record.expiresAt).toString(), | |
| 166 | + "}" | |
| 181 | 167 | ) |
| 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 | - } | |
| 168 | + : "", | |
| 169 | + "]" | |
| 170 | + ); | |
| 204 | 171 | |
| 205 | 172 | string memory escapedName = _escapeJSON(fullName); |
| 206 | 173 |
| 211 | 178 | string.concat( |
| 212 | 179 | '{"name":"', |
| 213 | 180 | escapedName, |
| 214 | - '","description":"Wei Name Service: ', | |
| 181 | + '","description":"EtherNames: ', | |
| 215 | 182 | escapedName, |
| 216 | 183 | '","image":"data:image/svg+xml;base64,', |
| 217 | - Base64.encode(bytes(_generateSVG(displayName))), | |
| 184 | + Base64.encode(bytes(card.render(fullName, bytes32(tokenId), status))), | |
| 218 | 185 | '"', |
| 219 | 186 | attributes, |
| 220 | 187 | "}" |
| 224 | 191 | ); |
| 225 | 192 | } |
| 226 | 193 | |
| 194 | + /// @dev Card status for tokenURI. isExpired and inGracePeriod use the | |
| 195 | + /// expiresAt sentinel and lie for subdomains, so a subdomain walks | |
| 196 | + /// its parent chain: an epoch mismatch anywhere is Invalid forever, | |
| 197 | + /// otherwise the top ancestor's clock decides. | |
| 198 | + function _cardStatus(uint256 tokenId) internal view returns (NameCard.Status) { | |
| 199 | + uint256 cur = tokenId; | |
| 200 | + bool sub = records[cur].parent != 0; | |
| 201 | + for (uint256 depth = 0; depth <= MAX_SUBDOMAIN_DEPTH; ++depth) { | |
| 202 | + NameRecord storage r = records[cur]; | |
| 203 | + if (r.parent == 0) { | |
| 204 | + if (block.timestamp > r.expiresAt + GRACE_PERIOD) return NameCard.Status.Expired; | |
| 205 | + if (block.timestamp > r.expiresAt) return NameCard.Status.Grace; | |
| 206 | + return sub ? NameCard.Status.Subdomain : NameCard.Status.Active; | |
| 207 | + } | |
| 208 | + if (r.parentEpoch != records[r.parent].epoch) return NameCard.Status.Invalid; | |
| 209 | + cur = r.parent; | |
| 210 | + } | |
| 211 | + return NameCard.Status.Invalid; | |
| 212 | + } | |
| 213 | + | |
| 227 | 214 | /*////////////////////////////////////////////////////////////// |
| 228 | 215 | COMMIT-REVEAL |
| 229 | 216 | //////////////////////////////////////////////////////////////*/ |
| 809 | 816 | } |
| 810 | 817 | } |
| 811 | 818 | |
| 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 | 819 | function _recordExists(uint256 tokenId) internal view returns (bool) { |
| 841 | 820 | return bytes(records[tokenId].label).length > 0; |
| 842 | 821 | } |
| 891 | 870 | return string.concat(record.label, ".", parentName); |
| 892 | 871 | } |
| 893 | 872 | |
| 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 | 873 | /// @dev Escape JSON special characters for safe metadata embedding |
| 903 | 874 | function _escapeJSON(string memory input) internal pure returns (string memory) { |
| 904 | 875 | bytes memory b = bytes(input); |
| 949 | 920 | |
| 950 | 921 | return string(result); |
| 951 | 922 | } |
| 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 | 923 | } |
New file: src/NameCard.sol
The certificate renderer is entirely new code, not derived from the audited base. It is 369 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. Its output is pinned byte-for-byte to a differential fixture suite and re-proven against the site renderer.
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. Verified by a Halmos symbolic proof that the two records stay in lockstep for every input.
+21 −2 across 2 hunks
| 347 | 366 | function setAddr(uint256 tokenId, address addr) public { |
| 348 | 367 | if (!_isActive(tokenId)) revert Expired(); |
| 349 | 368 | if (ownerOf(tokenId) != msg.sender) revert Unauthorized(); |
| 350 | - _resolvedAddress[tokenId][recordVersion[tokenId]] = addr; | |
| 369 | + uint256 v = recordVersion[tokenId]; | |
| 370 | + _resolvedAddress[tokenId][v] = addr; | |
| 371 | + // keep the ENSIP-9 coin-60 record in lockstep so addr(node) and addr(node,60) | |
| 372 | + // can never disagree; zero address canonicalizes to empty bytes in both slots | |
| 373 | + bytes memory enc = addr == address(0) ? bytes("") : abi.encodePacked(addr); | |
| 374 | + _coinAddr[tokenId][v][COIN_TYPE_ETH] = enc; | |
| 375 | + emit AddressChanged(bytes32(tokenId), COIN_TYPE_ETH, enc); | |
| 351 | 376 | emit AddrChanged(bytes32(tokenId), addr); |
| 352 | 377 | } |
| 353 | 378 |
| 392 | 417 | function setAddrForCoin(uint256 tokenId, uint256 coinType, bytes calldata addr) public { |
| 393 | 418 | if (!_isActive(tokenId)) revert Expired(); |
| 394 | 419 | if (ownerOf(tokenId) != msg.sender) revert Unauthorized(); |
| 395 | - _coinAddr[tokenId][recordVersion[tokenId]][coinType] = addr; | |
| 420 | + uint256 v = recordVersion[tokenId]; | |
| 421 | + if (coinType == COIN_TYPE_ETH) { | |
| 422 | + // ETH addresses must be 20 bytes (or empty to clear); mirror into the | |
| 423 | + // legacy slot so both getters always denote the same address | |
| 424 | + if (addr.length != 20 && addr.length != 0) revert InvalidLength(); | |
| 425 | + address dec = addr.length == 20 ? address(bytes20(addr)) : address(0); | |
| 426 | + bytes memory enc = dec == address(0) ? bytes("") : bytes(addr); | |
| 427 | + _resolvedAddress[tokenId][v] = dec; | |
| 428 | + _coinAddr[tokenId][v][COIN_TYPE_ETH] = enc; | |
| 429 | + emit AddressChanged(bytes32(tokenId), COIN_TYPE_ETH, enc); | |
| 430 | + emit AddrChanged(bytes32(tokenId), dec); | |
| 431 | + return; | |
| 432 | + } | |
| 433 | + _coinAddr[tokenId][v][coinType] = addr; | |
| 396 | 434 | emit AddressChanged(bytes32(tokenId), coinType, addr); |
| 397 | 435 | } |
| 398 | 436 |
Renamed TLD: .wei → .ether
TLD namehash WEI_NODE → ETHER_NODE (= namehash('ether')), name()/symbol() → "EtherNames"/"ETHERNAME", and the suffix strings and suffix-stripping (4-byte .wei → 6-byte .ether).
+20 −24 across 8 hunks
| 59 | 58 | event AddressChanged(bytes32 indexed node, uint256 coinType, bytes addr); |
| 60 | 59 | event TextChanged(bytes32 indexed node, string indexed key, string value); |
| 61 | 60 | |
| 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 | 61 | /*////////////////////////////////////////////////////////////// |
| 69 | 62 | CONSTANTS |
| 70 | 63 | //////////////////////////////////////////////////////////////*/ |
| 71 | 64 | |
| 72 | - /// @dev Namehash of "wei" TLD - kept public for off-chain tooling | |
| 73 | - bytes32 public constant WEI_NODE = | |
| 74 | - 0xa82820059d5df798546bcc2985157a77c3eef25eba9ba01899927333efacbd6f; | |
| 65 | + /// @dev Namehash of "ether" TLD - kept public for off-chain tooling | |
| 66 | + bytes32 public constant ETHER_NODE = | |
| 67 | + 0xb7f8c26395211ac249dcf196aa8bf23249a87186996f42c47bc4d55dfe608eee; | |
| 75 | 68 | |
| 76 | 69 | uint256 constant MAX_LABEL_LENGTH = 255; |
| 77 | 70 | uint256 constant MIN_LABEL_LENGTH = 1; |
| 130 | 123 | //////////////////////////////////////////////////////////////*/ |
| 131 | 124 | |
| 132 | 125 | function name() public pure override(ERC721) returns (string memory) { |
| 133 | - return "Wei Name Service"; | |
| 126 | + return "EtherNames"; | |
| 134 | 127 | } |
| 135 | 128 | |
| 136 | 129 | function symbol() public pure override(ERC721) returns (string memory) { |
| 137 | - return "WEI"; | |
| 130 | + return "ETHERNAME"; | |
| 138 | 131 | } |
| 139 | 132 | |
| 140 | 133 | /// @dev Blocks transfers of inactive tokens, but allows mint (from==0) and burn (to==0) |
| 257 | 244 | uint256 fee = getFee(bytes(label).length); |
| 258 | 245 | bytes memory normalized = _validateAndNormalize(bytes(label)); |
| 259 | 246 | |
| 260 | - tokenId = uint256(keccak256(abi.encodePacked(WEI_NODE, keccak256(normalized)))); | |
| 247 | + tokenId = uint256(keccak256(abi.encodePacked(ETHER_NODE, keccak256(normalized)))); | |
| 261 | 248 | uint256 premium = getPremium(tokenId); |
| 262 | 249 | uint256 total = fee + premium; |
| 263 | 250 |
| 370 | 395 | function reverseResolve(address addr) public view returns (string memory) { |
| 371 | 396 | uint256 tokenId = primaryName[addr]; |
| 372 | 397 | if (tokenId == 0 || !_isActive(tokenId) || resolve(tokenId) != addr) return ""; |
| 373 | - return string.concat(_buildFullName(tokenId), ".wei"); | |
| 398 | + return string.concat(_buildFullName(tokenId), ".ether"); | |
| 374 | 399 | } |
| 375 | 400 | |
| 376 | 401 | /*////////////////////////////////////////////////////////////// |
| 463 | 501 | return uint256(computeNamehash(fullName)); |
| 464 | 502 | } |
| 465 | 503 | |
| 466 | - /// @notice Compute namehash for a full name (e.g. "sub.name.wei" or "name") | |
| 504 | + /// @notice Compute namehash for a full name (e.g. "sub.name.ether" or "name") | |
| 467 | 505 | /// @dev This function is intentionally permissive - it lowercases and hashes any input. |
| 468 | 506 | /// Registration enforces validation: valid UTF-8, no space/control chars/dot. |
| 469 | 507 | /// Use normalize() to check if a label is valid for registration. |
| 470 | 508 | function computeNamehash(string calldata fullName) public pure returns (bytes32 node) { |
| 471 | 509 | bytes memory b = bytes(fullName); |
| 472 | - if (b.length == 0) return WEI_NODE; | |
| 510 | + if (b.length == 0) return ETHER_NODE; | |
| 473 | 511 | |
| 474 | 512 | uint256 len = b.length; |
| 475 | 513 | |
| 476 | - // Strip .wei suffix if present | |
| 514 | + // Strip .ether suffix if present | |
| 477 | 515 | if ( |
| 478 | - len >= 4 && b[len - 4] == 0x2e && (b[len - 3] == 0x77 || b[len - 3] == 0x57) | |
| 516 | + len >= 6 && b[len - 6] == 0x2e && (b[len - 5] == 0x65 || b[len - 5] == 0x45) | |
| 517 | + && (b[len - 4] == 0x74 || b[len - 4] == 0x54) | |
| 518 | + && (b[len - 3] == 0x68 || b[len - 3] == 0x48) | |
| 479 | 519 | && (b[len - 2] == 0x65 || b[len - 2] == 0x45) |
| 480 | - && (b[len - 1] == 0x69 || b[len - 1] == 0x49) | |
| 520 | + && (b[len - 1] == 0x72 || b[len - 1] == 0x52) | |
| 481 | 521 | ) { |
| 482 | - len -= 4; | |
| 522 | + len -= 6; | |
| 483 | 523 | } |
| 484 | 524 | |
| 485 | - if (len == 0) return WEI_NODE; | |
| 525 | + if (len == 0) return ETHER_NODE; | |
| 486 | 526 | if (b[0] == 0x2e || b[len - 1] == 0x2e) revert EmptyLabel(); |
| 487 | 527 | |
| 488 | - node = WEI_NODE; | |
| 528 | + node = ETHER_NODE; | |
| 489 | 529 | uint256 labelEnd = len; |
| 490 | 530 | |
| 491 | 531 | for (uint256 i = len; i > 0; --i) { |
| 566 | 606 | // Hyphen rules |
| 567 | 607 | if (normalized[0] == 0x2d || normalized[b.length - 1] == 0x2d) return false; |
| 568 | 608 | |
| 569 | - bytes32 parentNode = parentId == 0 ? WEI_NODE : bytes32(parentId); | |
| 609 | + bytes32 parentNode = parentId == 0 ? ETHER_NODE : bytes32(parentId); | |
| 570 | 610 | uint256 tokenId = uint256(keccak256(abi.encodePacked(parentNode, keccak256(normalized)))); |
| 571 | 611 | |
| 572 | 612 | if (parentId != 0 && !_isActive(parentId)) return false; |
| 585 | 625 | function getFullName(uint256 tokenId) public view returns (string memory) { |
| 586 | 626 | string memory baseName = _buildFullName(tokenId); |
| 587 | 627 | if (bytes(baseName).length == 0) return ""; |
| 588 | - return string.concat(baseName, ".wei"); | |
| 628 | + return string.concat(baseName, ".ether"); | |
| 589 | 629 | } |
| 590 | 630 | |
| 591 | 631 | /// @notice On-chain normalization (lowercases ASCII only) |
| 671 | 678 | returns (uint256 tokenId) |
| 672 | 679 | { |
| 673 | 680 | bytes memory normalized = _validateAndNormalize(bytes(label)); |
| 674 | - bytes32 parentNode = parentId == 0 ? WEI_NODE : bytes32(parentId); | |
| 681 | + bytes32 parentNode = parentId == 0 ? ETHER_NODE : bytes32(parentId); | |
| 675 | 682 | tokenId = uint256(keccak256(abi.encodePacked(parentNode, keccak256(normalized)))); |
| 676 | 683 | |
| 677 | 684 | // Invariant: subdomain registration requires parent ownership |
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.
+4 −2 across 2 hunks
| 92 | 92 | address payout; // receives ERC20 directly; ETH via ethBalance |
| 93 | 93 | } |
| 94 | 94 | |
| 95 | - INameNFT public constant name = INameNFT(0x0000000000696760E15f265e828DB644A0c242EB); | |
| 95 | + INameNFT public immutable name; | |
| 96 | 96 | |
| 97 | 97 | mapping(uint256 => Config) public config; |
| 98 | 98 | mapping(uint256 => address) public escrowedController; // nonzero => escrowed, controller recorded |
| 101 | 101 | |
| 102 | 102 | uint256 constant _REENTRANCY_GUARD_SLOT = 0x929eee149b4bd21268; |
| 103 | 103 | |
| 104 | - constructor() payable {} | |
| 104 | + constructor(INameNFT nameNFT) { | |
| 105 | + name = nameNFT; | |
| 106 | + } | |
| 105 | 107 | |
| 106 | 108 | modifier nonReentrant() virtual { |
| 107 | 109 | assembly ("memory-safe") { |