diff --git a/script/deploy/DeployBaseRegistrar.s.sol b/script/deploy/DeployBaseRegistrar.s.sol index e2cd08bf..66957067 100644 --- a/script/deploy/DeployBaseRegistrar.s.sol +++ b/script/deploy/DeployBaseRegistrar.s.sol @@ -18,7 +18,7 @@ contract DeployBaseRegistrar is Script { address ensAddress = vm.envAddress("REGISTRY_ADDR"); // deployer-owned registry (, bytes32 node) = NameEncoder.dnsEncodeName("basetest.eth"); - BaseRegistrar base = new BaseRegistrar(ENS(ensAddress), deployerAddress, node); + BaseRegistrar base = new BaseRegistrar(ENS(ensAddress), deployerAddress, node, "", ""); console.log("Base Registrar deployed to:"); console.log(address(base)); diff --git a/src/L2/BaseRegistrar.sol b/src/L2/BaseRegistrar.sol index 6e128ec7..9a08a40c 100644 --- a/src/L2/BaseRegistrar.sol +++ b/src/L2/BaseRegistrar.sol @@ -6,6 +6,7 @@ import {ERC721} from "lib/solady/src/tokens/ERC721.sol"; import {IERC721} from "openzeppelin-contracts/contracts/token/ERC721/IERC721.sol"; import {IERC165} from "openzeppelin-contracts/contracts/utils/introspection/ERC165.sol"; import {Ownable} from "solady/auth/Ownable.sol"; +import {LibString} from "solady/utils/LibString.sol"; import {GRACE_PERIOD} from "src/util/Constants.sol"; @@ -21,6 +22,8 @@ import {GRACE_PERIOD} from "src/util/Constants.sol"; /// /// @author Coinbase (https://github.com/base-org/usernames) contract BaseRegistrar is ERC721, Ownable { + using LibString for uint256; + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* STORAGE */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -34,6 +37,12 @@ contract BaseRegistrar is ERC721, Ownable { /// @notice The namehash of the TLD this registrar owns (eg, base.eth). bytes32 public immutable baseNode; + /// @notice The base URI for token metadata. + string private _baseURI; + + /// @notice The URI for collection metadata. + string private _collectionURI; + /// @notice A map of addresses that are authorised to register and renew names. mapping(address controller => bool isApproved) public controllers; @@ -64,6 +73,11 @@ contract BaseRegistrar is ERC721, Ownable { /// @param tokenId The id of the name that is not available. error NotAvailable(uint256 tokenId); + /// @notice Thrown when the queried tokenId does not exist. + /// + /// @param tokenId The id of the name that does not exist. + error NonexistentToken(uint256 tokenId); + /// @notice Thrown when the name is not registered or in its Grace Period. /// /// @param tokenId The id of the token that is not registered or in Grace Period. @@ -113,6 +127,22 @@ contract BaseRegistrar is ERC721, Ownable { uint256 indexed id, address indexed owner, uint256 expires, address resolver, uint64 ttl ); + /// @notice Emitted when metadata for a token range is updated. + /// + /// @dev Useful for third-party platforms such as NFT marketplaces who can update + /// the images and related attributes of the NFTs in a timely fashion. + /// To refresh a whole collection, emit `_toTokenId` with `type(uint256).max` + /// ERC-4906: https://eip.tools/eip/4906 + /// + /// @param _fromTokenId The starting range of `tokenId` for which metadata has been updated. + /// @param _toTokenId The ending range of `tokenId` for which metadata has been updated. + event BatchMetadataUpdate(uint256 _fromTokenId, uint256 _toTokenId); + + /// @notice Emitted when the metadata for the contract collection is updated. + /// + /// @dev ERC-7572: https://eips.ethereum.org/EIPS/eip-7572 + event ContractURIUpdated(); + /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ /* MODIFIERS */ /*.•°:°.´+˚.*°.˚:*.´•*.+°.•°:´*.´•*.•°.•°:°.´:•˚°.*°.˚:*.´+°.•*/ @@ -154,10 +184,20 @@ contract BaseRegistrar is ERC721, Ownable { /// @param registry_ The Registry contract. /// @param owner_ The permissioned address initialized as the `owner` in the `Ownable` context. /// @param baseNode_ The node that this contract manages registrations for. - constructor(ENS registry_, address owner_, bytes32 baseNode_) { + /// @param baseURI_ The base token URI for NFT metadata. + /// @param collectionURI_ The URI for the collection's metadata. + constructor( + ENS registry_, + address owner_, + bytes32 baseNode_, + string memory baseURI_, + string memory collectionURI_ + ) { _initializeOwner(owner_); registry = registry_; baseNode = baseNode_; + _baseURI = baseURI_; + _collectionURI = collectionURI_; } /// @notice Authorises a controller, who can register and renew domains. @@ -316,9 +356,41 @@ contract BaseRegistrar is ERC721, Ownable { return "BASENAME"; } - /// @dev Returns the Uniform Resource Identifier (URI) for token `id`. - function tokenURI(uint256) public pure override returns (string memory) { - return ""; + /// @notice Returns the Uniform Resource Identifier (URI) for token `id`. + /// + /// @dev Reverts if the `tokenId` has not be registered. + /// + /// @param tokenId The token for which to return the metadata uri. + /// + /// @return The URI for the specified `tokenId`. + function tokenURI(uint256 tokenId) public view override returns (string memory) { + if (_ownerOf(tokenId) == address(0)) revert NonexistentToken(tokenId); + + return bytes(_baseURI).length > 0 ? string.concat(_baseURI, tokenId.toString()) : ""; + } + + /// @notice Returns the Uniform Resource Identifier (URI) for the contract. + /// + /// @dev ERC-7572: https://eips.ethereum.org/EIPS/eip-7572 + function contractURI() public view returns (string memory) { + return _collectionURI; + } + + /// @dev Allows the owner to set the the base Uniform Resource Identifier (URI)`. + /// Emits the `BatchMetadataUpdate` event for the full range of valid `tokenIds`. + function setBaseTokenURI(string memory baseURI_) public onlyOwner { + _baseURI = baseURI_; + /// @dev minimum valid tokenId is `1` because uint256(nodehash) will never be called against `nodehash == 0x0`. + uint256 minTokenId = 1; + uint256 maxTokenId = type(uint256).max; + emit BatchMetadataUpdate(minTokenId, maxTokenId); + } + + /// @dev Allows the owner to set the the contract Uniform Resource Identifier (URI)`. + /// Emits the `ContractURIUpdated` event. + function setContractURI(string memory collectionURI_) public onlyOwner { + _collectionURI = collectionURI_; + emit ContractURIUpdated(); } /*´:°•.°+.*•´.*:˚.°*.˚•´.°:°•.°•.*•´.*:˚.°*.˚•´.°:°•.°+.*•´.*:*/ diff --git a/test/BaseRegistrar/BaseRegistrarBase.t.sol b/test/BaseRegistrar/BaseRegistrarBase.t.sol index 287fba19..b0a43448 100644 --- a/test/BaseRegistrar/BaseRegistrarBase.t.sol +++ b/test/BaseRegistrar/BaseRegistrarBase.t.sol @@ -20,11 +20,13 @@ contract BaseRegistrarBase is Test { bytes32 public node = keccak256(abi.encodePacked(BASE_ETH_NODE, label)); uint256 public duration = 365 days; uint256 public blockTimestamp = 1716496498; // May 23, 2024 + string public baseURI = "https://base.org/api/basenames/metadata/"; + string public collectionURI = "https://base.org/api/basenames/contract/"; function setUp() public { vm.prank(owner); registry = new Registry(owner); - baseRegistrar = new BaseRegistrar(registry, owner, BASE_ETH_NODE); + baseRegistrar = new BaseRegistrar(registry, owner, BASE_ETH_NODE, baseURI, collectionURI); _ensSetup(); } diff --git a/test/BaseRegistrar/ContractURI.t.sol b/test/BaseRegistrar/ContractURI.t.sol new file mode 100644 index 00000000..c76f531d --- /dev/null +++ b/test/BaseRegistrar/ContractURI.t.sol @@ -0,0 +1,11 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {BaseRegistrarBase} from "./BaseRegistrarBase.t.sol"; + +contract ContractURI is BaseRegistrarBase { + function test_contractURI_isReturnedAsExpected() public view { + assertEq(keccak256(bytes(baseRegistrar.contractURI())), keccak256(bytes(collectionURI))); + } +} diff --git a/test/BaseRegistrar/SetBaseTokenURI.t.sol b/test/BaseRegistrar/SetBaseTokenURI.t.sol new file mode 100644 index 00000000..8b95b90a --- /dev/null +++ b/test/BaseRegistrar/SetBaseTokenURI.t.sol @@ -0,0 +1,36 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {BaseRegistrar} from "src/L2/BaseRegistrar.sol"; +import {BaseRegistrarBase} from "./BaseRegistrarBase.t.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; +import {LibString} from "solady/utils/LibString.sol"; + +contract SetBaseTokenURI is BaseRegistrarBase { + using LibString for uint256; + + string public newBaseURI = "https://newurl.org/"; + + function test_allowsTheOwnerToSetTheBaseURI() public { + vm.expectEmit(address(baseRegistrar)); + emit BaseRegistrar.BatchMetadataUpdate(1, type(uint256).max); + vm.prank(owner); + baseRegistrar.setBaseTokenURI(newBaseURI); + + _registrationSetup(); + vm.warp(blockTimestamp); + vm.prank(controller); + baseRegistrar.register(id, user, duration); + + string memory returnedURI = baseRegistrar.tokenURI(id); + string memory expectedURI = string.concat(newBaseURI, id.toString()); + assertEq(keccak256(bytes(returnedURI)), keccak256(bytes(expectedURI))); + } + + function test_reverts_whenCalledByNonOwner(address caller) public { + vm.assume(caller != owner); + vm.prank(caller); + vm.expectRevert(Ownable.Unauthorized.selector); + baseRegistrar.setBaseTokenURI(newBaseURI); + } +} diff --git a/test/BaseRegistrar/SetContractURI.t.sol b/test/BaseRegistrar/SetContractURI.t.sol new file mode 100644 index 00000000..e26780ab --- /dev/null +++ b/test/BaseRegistrar/SetContractURI.t.sol @@ -0,0 +1,27 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {BaseRegistrarBase} from "./BaseRegistrarBase.t.sol"; +import {BaseRegistrar} from "src/L2/BaseRegistrar.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; + +contract SetContractURI is BaseRegistrarBase { + string newContractURI = "NewURI"; + + function test_allowsTheOwnerToSetTheContractURI() public { + vm.expectEmit(address(baseRegistrar)); + emit BaseRegistrar.ContractURIUpdated(); + + vm.prank(owner); + baseRegistrar.setContractURI(newContractURI); + assertEq(keccak256(bytes(baseRegistrar.contractURI())), keccak256(bytes(newContractURI))); + } + + function test_reverts_whenCalledByNonOwner(address caller) public { + vm.assume(caller != owner); + vm.prank(caller); + vm.expectRevert(Ownable.Unauthorized.selector); + baseRegistrar.setBaseTokenURI(newContractURI); + } +} diff --git a/test/BaseRegistrar/Symbol.t.sol b/test/BaseRegistrar/Symbol.t.sol index c41e51de..580a5645 100644 --- a/test/BaseRegistrar/Symbol.t.sol +++ b/test/BaseRegistrar/Symbol.t.sol @@ -5,7 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {BaseRegistrarBase} from "./BaseRegistrarBase.t.sol"; contract Symbol is BaseRegistrarBase { - function test_nameIsSetAsExpected() public view { + function test_symbolIsSetAsExpected() public view { string memory expectedSymbol = "BASENAME"; assertEq(keccak256(bytes(baseRegistrar.symbol())), keccak256(bytes(expectedSymbol))); } diff --git a/test/BaseRegistrar/TokenURI.t.sol b/test/BaseRegistrar/TokenURI.t.sol index 251b5d18..7eb02ae8 100644 --- a/test/BaseRegistrar/TokenURI.t.sol +++ b/test/BaseRegistrar/TokenURI.t.sol @@ -3,10 +3,36 @@ pragma solidity ^0.8.23; import {Test} from "forge-std/Test.sol"; import {BaseRegistrarBase} from "./BaseRegistrarBase.t.sol"; +import {BaseRegistrar} from "src/L2/BaseRegistrar.sol"; +import {LibString} from "solady/utils/LibString.sol"; contract TokenURI is BaseRegistrarBase { - function test_nameIsSetAsExpected() public view { - string memory expectedURI = ""; - assertEq(keccak256(bytes(baseRegistrar.tokenURI(1))), keccak256(bytes(expectedURI))); + using LibString for uint256; + + function test_tokenURIIsSetAsExpected() public { + _registrationSetup(); + vm.warp(blockTimestamp); + vm.prank(controller); + baseRegistrar.register(id, user, duration); + + string memory expectedURI = string.concat(baseURI, id.toString()); + assertEq(keccak256(bytes(baseRegistrar.tokenURI(id))), keccak256(bytes(expectedURI))); + } + + function test_returnsTokenURI_ifTheTokenIsExpired() public { + _registrationSetup(); + vm.warp(blockTimestamp); + vm.prank(controller); + uint256 expires = baseRegistrar.register(id, user, duration); + vm.warp(expires + 1); + baseRegistrar.tokenURI(id); + + string memory expectedURI = string.concat(baseURI, id.toString()); + assertEq(keccak256(bytes(baseRegistrar.tokenURI(id))), keccak256(bytes(expectedURI))); + } + + function test_reverts_ifTheTokenHasNotBeenRegistered() public { + vm.expectRevert(abi.encodeWithSelector(BaseRegistrar.NonexistentToken.selector, id)); + baseRegistrar.tokenURI(id); } } diff --git a/test/IntegrationTest.t.sol b/test/IntegrationTest.t.sol index 17540328..63a63d94 100644 --- a/test/IntegrationTest.t.sol +++ b/test/IntegrationTest.t.sol @@ -70,7 +70,7 @@ contract IntegrationTest is Test { rentPrices[5] = 3_170_979; //3,170,979.1983764587 = 1e14 / (365 * 24 * 3600) exponentialPremiumPriceOracle = new ExponentialPremiumPriceOracle(rentPrices, 1e18, 21); - baseRegistrar = new BaseRegistrar(registry, owner, BASE_ETH_NODE); + baseRegistrar = new BaseRegistrar(registry, owner, BASE_ETH_NODE, "", ""); _establishNamespaces();