diff --git a/contracts/reverseRegistrar/ReverseNamer.sol b/contracts/reverseRegistrar/ReverseNamer.sol new file mode 100644 index 000000000..5798337f4 --- /dev/null +++ b/contracts/reverseRegistrar/ReverseNamer.sol @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +interface IReverseRegistrarStub { + function setName(string memory name) external; +} + +/// @notice https://docs.ens.domains/ensip/19/ +library ReverseNamer { + address constant MAINNET = address(0); // TODO: ENSv2 addr.reverse registrar + address constant MAINNET_ROLLUP = + 0x0000000000D8e504002cC26E3Ec46D81971C1664; + address constant TESTNET_ROLLUP = + 0x00000BeEF055f7934784D6d81b6BC86665630dbA; + + function registrarFromChain( + uint256 chainId + ) internal pure returns (address) { + if (isMainnet(chainId)) { + return MAINNET; + } else if (isMainnetRollup(chainId)) { + return MAINNET_ROLLUP; + } else if (isTestnetRollup(chainId)) { + return TESTNET_ROLLUP; + } else { + return address(0); + } + } + + function setName(string memory primary) internal { + address registrar = registrarFromChain(block.chainid); + if (registrar != address(0)) { + IReverseRegistrarStub(registrar).setName(primary); + } + } + + function isMainnet(uint256 chainId) internal pure returns (bool) { + return + chainId == 1 || // mainnet + chainId == 17000 || // holesky + chainId == 560048 || // hoodi + chainId == 11155111; // sepolia + } + + function isMainnetRollup(uint256 chainId) internal pure returns (bool) { + return + chainId == 10 || // optimism + chainId == 8453 || // base + chainId == 42161 || // arb1 + chainId == 59144 || // linea + chainId == 534352; // scroll + } + + function isTestnetRollup(uint256 chainId) internal pure returns (bool) { + return + chainId == 59141 || // linea-sepolia + chainId == 84532 || // base-sepolia + chainId == 421614 || // arb1-sepolia + chainId == 534351 || // scroll-sepolia + chainId == 11155420; // optimism-sepolia + } +} + +/// @dev Compile-time guard to prevent being Ownable. +contract NotOwnable { + function owner() internal pure {} +} + +/// @notice Mixin for naming a contract once. +contract NamedOnce is NotOwnable { + constructor(string memory primary) { + ReverseNamer.setName(primary); + } +} + +/// @notice Mixin for delegated contract naming. +contract NameableBy is NotOwnable { + address public nameOwner; + + constructor(address owner, string memory primary) { + nameOwner = owner; + if (bytes(primary).length > 0) { + ReverseNamer.setName(primary); + } + } + + /// @notice Set the name owner. + /// @dev Use `address(0)` to revoke ownership. + function setNameOwner(address owner) public { + require(msg.sender == nameOwner); + nameOwner = owner; + } + + /// @notice Set contract primary name. + function setName(string memory primary) public { + require(msg.sender == nameOwner); + ReverseNamer.setName(primary); + } +} diff --git a/contracts/test/mocks/MockReverseNamer.sol b/contracts/test/mocks/MockReverseNamer.sol new file mode 100644 index 000000000..b94ce965b --- /dev/null +++ b/contracts/test/mocks/MockReverseNamer.sol @@ -0,0 +1,16 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.4; + +import {ReverseNamer} from "../../reverseRegistrar/ReverseNamer.sol"; + +contract MockReverseNamer { + function registrarFromChain( + uint256 chainId + ) external pure returns (address) { + return ReverseNamer.registrarFromChain(chainId); + } +} + +// import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +// import {NamedOnce} from "../../reverseRegistrar/ReverseNamer.sol"; +// contract MockNamedOnce is Ownable, NamedOnce("") {} // compile-time error if Ownable diff --git a/test/reverseRegistrar/TestReverseNamer.test.ts b/test/reverseRegistrar/TestReverseNamer.test.ts new file mode 100644 index 000000000..21d98acb6 --- /dev/null +++ b/test/reverseRegistrar/TestReverseNamer.test.ts @@ -0,0 +1,141 @@ +import hre from 'hardhat' +import { type Address, getAddress, zeroAddress } from 'viem' +import { readdirSync, readFileSync } from 'node:fs' +import { coinTypeFromChain } from '../fixtures/ensip19.js' + +const REVERSE_REGISTRAR_MAINNET_ROLLUP = + '0x0000000000D8e504002cC26E3Ec46D81971C1664' + +const connection = await hre.network.connect({ + override: { + chainId: 8453, // appear like an L2 + }, +}) + +async function fixture() { + const [owner] = await connection.viem.getWalletClients() + const publicClient = await connection.viem.getPublicClient() + const reverseNamer = await connection.viem.deployContract('MockReverseNamer') + const l2ReverseRegistrar0 = await connection.viem.deployContract( + 'L2ReverseRegistrar', + [coinTypeFromChain(connection.networkConfig.chainId!)], + ) + await connection.networkHelpers.setCode( + REVERSE_REGISTRAR_MAINNET_ROLLUP, + (await publicClient.getCode(l2ReverseRegistrar0))!, + ) + const l2ReverseRegistrar = await connection.viem.getContractAt( + 'L2ReverseRegistrar', + REVERSE_REGISTRAR_MAINNET_ROLLUP, + ) + return { + owner, + reverseNamer, + l2ReverseRegistrar, + } +} + +const PRIMARY = 'mycontract.eth' + +describe('ReverseNamer', () => { + describe('registryFromChain', () => { + const dir = new URL('../../deployments/', import.meta.url) + for (const chainName of readdirSync(dir)) { + try { + const chainId = BigInt( + readFileSync(new URL(`./${chainName}/.chainId`, dir), { + encoding: 'utf8', + }), + ) + const deploy = JSON.parse( + readFileSync(new URL(`./${chainName}/L2ReverseRegistrar.json`, dir), { + encoding: 'utf8', + }), + ) as { address: Address } + it(chainName, async () => { + const F = await connection.networkHelpers.loadFixture(fixture) + await expect( + F.reverseNamer.read.registrarFromChain([chainId]), + ).resolves.toStrictEqual(deploy.address) + }) + } catch (err) {} + } + }) + + it('NamedOnce', async () => { + const F = await connection.networkHelpers.loadFixture(fixture) + const contract = await connection.viem.deployContract('NamedOnce', [ + PRIMARY, + ]) + await expect( + F.l2ReverseRegistrar.read.nameForAddr([contract.address]), + ).resolves.toStrictEqual(PRIMARY) + }) + + describe('NameableBy', () => { + it('w/o primary', async () => { + const F = await connection.networkHelpers.loadFixture(fixture) + const contract = await connection.viem.deployContract('NameableBy', [ + F.owner.account.address, + '', + ]) + await expect( + F.l2ReverseRegistrar.read.nameForAddr([contract.address]), + 'primary', + ).resolves.toStrictEqual('') + await expect(contract.read.nameOwner()).resolves.toStrictEqual( + getAddress(F.owner.account.address), + ) + }) + + it('w/primary', async () => { + const F = await connection.networkHelpers.loadFixture(fixture) + const contract = await connection.viem.deployContract('NameableBy', [ + F.owner.account.address, + PRIMARY, + ]) + await expect( + F.l2ReverseRegistrar.read.nameForAddr([contract.address]), + 'primary', + ).resolves.toStrictEqual(PRIMARY) + }) + + it('setName', async () => { + const F = await connection.networkHelpers.loadFixture(fixture) + const contract = await connection.viem.deployContract('NameableBy', [ + F.owner.account.address, + PRIMARY, + ]) + const primary = 'new-name' + await contract.write.setName([primary]) + await expect( + F.l2ReverseRegistrar.read.nameForAddr([contract.address]), + ).resolves.toStrictEqual(primary) + }) + + it('disown', async () => { + const F = await connection.networkHelpers.loadFixture(fixture) + const contract = await connection.viem.deployContract('NameableBy', [ + F.owner.account.address, + PRIMARY, + ]) + await contract.write.setNameOwner([zeroAddress]) + await expect(contract.read.nameOwner()).resolves.toStrictEqual( + zeroAddress, + ) + }) + + it('not owner', async () => { + const contract = await connection.viem.deployContract('NameableBy', [ + zeroAddress, + PRIMARY, + ]) + await expect(contract.write.setNameOwner([zeroAddress])).rejects.toThrow( + 'reverted without a reason', + ) + await expect(contract.write.setName([''])).rejects.toThrow( + 'reverted without a reason', + ) + }) + }) +})