From fb732053f6acacecff02a9cc1d80b1cea6dc57ca Mon Sep 17 00:00:00 2001 From: JOY <5027251+JOY@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:06:48 +0700 Subject: [PATCH] feat: add score-gated DOS domain policy --- .../DeployDOSDomainPolicyTestnet.s.sol | 144 +++++ .../src/registrar/AbstractETHRegistrar.sol | 22 +- .../src/registrar/DOSPolicyRegistrar.sol | 118 ++++ contracts/src/registrar/DosDomainPolicy.sol | 564 ++++++++++++++++++ contracts/src/registrar/ETHRegistrar.sol | 95 +-- .../registrar/interfaces/IDosDomainPolicy.sol | 269 +++++++++ .../registrar/interfaces/IETHRegistrar.sol | 5 +- .../test/unit/registrar/DosDomainPolicy.t.sol | 517 ++++++++++++++++ subgraph/dos-names/package.json | 2 +- subgraph/dos-names/schema.graphql | 6 + subgraph/dos-names/src/registry.ts | 18 +- subgraph/dos-names/subgraph.template.yaml | 27 + subgraph/dos-names/tests/registry.test.ts | 8 + 13 files changed, 1745 insertions(+), 50 deletions(-) create mode 100644 contracts/script/foundry/DeployDOSDomainPolicyTestnet.s.sol create mode 100644 contracts/src/registrar/DOSPolicyRegistrar.sol create mode 100644 contracts/src/registrar/DosDomainPolicy.sol create mode 100644 contracts/src/registrar/interfaces/IDosDomainPolicy.sol create mode 100644 contracts/test/unit/registrar/DosDomainPolicy.t.sol diff --git a/contracts/script/foundry/DeployDOSDomainPolicyTestnet.s.sol b/contracts/script/foundry/DeployDOSDomainPolicyTestnet.s.sol new file mode 100644 index 00000000..d2cf04fc --- /dev/null +++ b/contracts/script/foundry/DeployDOSDomainPolicyTestnet.s.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.20; + +import {Script} from "forge-std/Script.sol"; + +import {DosDomainPolicy} from "~src/registrar/DosDomainPolicy.sol"; +import {DOSPolicyRegistrar} from "~src/registrar/DOSPolicyRegistrar.sol"; +import {StandardRentPriceOracle} from "~src/registrar/StandardRentPriceOracle.sol"; +import {RegistryRolesLib} from "~src/registry/libraries/RegistryRolesLib.sol"; +import {PermissionedRegistry} from "~src/registry/PermissionedRegistry.sol"; + +/// @title Deploy DOS ID Domain Policy on DOS Testnet +/// @notice Replaces the public Testnet registrar role with a policy-controlled registrar. +/// @dev Required env: PRIVATE_KEY (the canonical registry owner) and DOS_DOMAIN_VOUCHER_SIGNER. +/// No private voucher key is read by this deployment script. +contract DeployDOSDomainPolicyTestnet is Script { + //////////////////////////////////////////////////////////////////////// + // Types + //////////////////////////////////////////////////////////////////////// + + /// @notice Contracts deployed by this policy deployment profile. + struct Deployment { + DosDomainPolicy policy; + DOSPolicyRegistrar registrar; + } + + //////////////////////////////////////////////////////////////////////// + // Constants + //////////////////////////////////////////////////////////////////////// + + /// @dev Expected DOS Testnet chain ID. + uint256 internal constant EXPECTED_CHAIN_ID = 3939; + + /// @dev Account that owns the deployed registry roles. + address internal constant EXPECTED_OWNER = 0x310Bc061214ee89aF5CfB28a6ebF96c5436fa3CD; + + /// @dev Existing `.dos` registry address. + address internal constant DOS_REGISTRY = 0x95366f1E44532F50c022aEefF708F424b7854173; + + /// @dev Existing `.dos` rent price oracle address. + address internal constant PRICE_ORACLE = 0x2eE958BcF29d140cdf64ad767f32E2554B0CCfa6; + + /// @dev Existing public registrar whose roles are retired by this script. + address internal constant LEGACY_DOS_REGISTRAR = 0x4E5B48aC8B221aAF8cFF070671CB6Eeea2122b5c; + + /// @dev Renewable period after a name expires. + uint64 internal constant GRACE_PERIOD = 28 days; + + /// @dev Minimum age required before a commitment can register. + uint64 internal constant MIN_COMMITMENT_AGE = 60; + + /// @dev Maximum age at which a commitment remains valid. + uint64 internal constant MAX_COMMITMENT_AGE = 1 days; + + /// @dev Shortest duration permitted by the registrar. + uint64 internal constant MIN_REGISTER_DURATION = 28 days; + + /// @dev Root roles moved from the legacy registrar to the policy registrar. + uint256 internal constant REGISTRAR_ROLES = + RegistryRolesLib.ROLE_REGISTRAR | RegistryRolesLib.ROLE_RENEW; + + //////////////////////////////////////////////////////////////////////// + // Errors + //////////////////////////////////////////////////////////////////////// + + /// @dev Error selector: `0xdb2dd806` + error UnexpectedChain(uint256 actual, uint256 expected); + /// @dev Error selector: `0x7e9c2f9d` + error UnexpectedOwner(address actual, address expected); + /// @dev Error selector: `0x4535258f` + error MissingContractCode(address target); + /// @dev Error selector: `0x7f1dd828` + error LegacyRegistrarAlreadyRetired(address registrar); + /// @dev Error selector: `0x16319300` + error InvalidMinimumScore(uint256 value); + + //////////////////////////////////////////////////////////////////////// + // Implementation + //////////////////////////////////////////////////////////////////////// + + /// @notice Deploys the policy and registrar, then atomically moves registrar roles per transaction. + /// @dev Policy subsidy funding is intentionally separate: seed it only after address verification. + /// @return deployment The deployed policy and policy-controlled registrar. + function run() external returns (Deployment memory deployment) { + uint256 privateKey = vm.envUint("PRIVATE_KEY"); + address voucherSigner = vm.envAddress("DOS_DOMAIN_VOUCHER_SIGNER"); + uint256 configuredMinimumScore = vm.envOr("DOS_DOMAIN_MINIMUM_SCORE", uint256(20)); + address defaultResolver = vm.envOr("DOS_DOMAIN_DEFAULT_RESOLVER", address(0)); + if (configuredMinimumScore > type(uint32).max) { + revert InvalidMinimumScore(configuredMinimumScore); + } + + address broadcaster = vm.addr(privateKey); + preflight(broadcaster); + + PermissionedRegistry registry = PermissionedRegistry(DOS_REGISTRY); + vm.startBroadcast(privateKey); + deployment.policy = new DosDomainPolicy( + registry, + EXPECTED_OWNER, + voucherSigner, + uint32(configuredMinimumScore), + defaultResolver + ); + deployment.registrar = new DOSPolicyRegistrar( + EXPECTED_OWNER, + registry, + EXPECTED_OWNER, + StandardRentPriceOracle(PRICE_ORACLE), + GRACE_PERIOD, + MIN_COMMITMENT_AGE, + MAX_COMMITMENT_AGE, + MIN_REGISTER_DURATION, + address(deployment.policy) + ); + registry.revokeRootRoles(REGISTRAR_ROLES, LEGACY_DOS_REGISTRAR); + registry.grantRootRoles(REGISTRAR_ROLES, address(deployment.registrar)); + deployment.policy.setRegistrar(deployment.registrar); + vm.stopBroadcast(); + } + + /// @notice Validates network, broadcaster, existing contracts, and legacy registrar roles before deployment. + /// @param broadcaster The address derived from the transaction private key. + function preflight(address broadcaster) public view { + if (block.chainid != EXPECTED_CHAIN_ID) { + revert UnexpectedChain(block.chainid, EXPECTED_CHAIN_ID); + } + if (broadcaster != EXPECTED_OWNER) { + revert UnexpectedOwner(broadcaster, EXPECTED_OWNER); + } + if (DOS_REGISTRY.code.length == 0) { + revert MissingContractCode(DOS_REGISTRY); + } + if (PRICE_ORACLE.code.length == 0) { + revert MissingContractCode(PRICE_ORACLE); + } + if (LEGACY_DOS_REGISTRAR.code.length == 0) { + revert MissingContractCode(LEGACY_DOS_REGISTRAR); + } + if (!PermissionedRegistry(DOS_REGISTRY).hasRootRoles(REGISTRAR_ROLES, LEGACY_DOS_REGISTRAR)) { + revert LegacyRegistrarAlreadyRetired(LEGACY_DOS_REGISTRAR); + } + } +} diff --git a/contracts/src/registrar/AbstractETHRegistrar.sol b/contracts/src/registrar/AbstractETHRegistrar.sol index c03ecc50..9db990b6 100755 --- a/contracts/src/registrar/AbstractETHRegistrar.sol +++ b/contracts/src/registrar/AbstractETHRegistrar.sol @@ -83,14 +83,9 @@ abstract contract AbstractETHRegistrar is Ownable, ERC165, IETHRenewer { /// @inheritdoc IETHRenewer function renew(string calldata label, uint64 duration, IERC20 paymentToken, bytes32 referrer) external + virtual { - IPermissionedRegistry.State memory state = _requireRenewable(label, duration); // reverts if not - uint64 newExpiry = state.expiry + duration; // reverts if overflow - uint256 amount = rentPriceOracle.getRenewPrice(label, state.expiry, duration, paymentToken); // reverts if invalid - SafeERC20.safeTransferFrom(paymentToken, msg.sender, BENEFICIARY, amount); // reverts if payment failed - ETH_REGISTRY.renew(state.tokenId, newExpiry); - _onRenew(label, duration); - emit NameRenewed(state.tokenId, label, duration, newExpiry, paymentToken, referrer, amount); + _renew(label, duration, paymentToken, referrer); } /// @inheritdoc IETHRenewer @@ -117,6 +112,19 @@ abstract contract AbstractETHRegistrar is Ownable, ERC165, IETHRenewer { // Internal Functions //////////////////////////////////////////////////////////////////////// + /// @dev Performs a renewal after any derived registrar authorization check. + function _renew(string calldata label, uint64 duration, IERC20 paymentToken, bytes32 referrer) + internal + { + IPermissionedRegistry.State memory state = _requireRenewable(label, duration); // reverts if not + uint64 newExpiry = state.expiry + duration; // reverts if overflow + uint256 amount = rentPriceOracle.getRenewPrice(label, state.expiry, duration, paymentToken); // reverts if invalid + SafeERC20.safeTransferFrom(paymentToken, msg.sender, BENEFICIARY, amount); // reverts if payment failed + ETH_REGISTRY.renew(state.tokenId, newExpiry); + _onRenew(label, duration); + emit NameRenewed(state.tokenId, label, duration, newExpiry, paymentToken, referrer, amount); + } + /// @dev Callback for when a name is renewed. function _onRenew(string calldata label, uint64 duration) internal virtual {} diff --git a/contracts/src/registrar/DOSPolicyRegistrar.sol b/contracts/src/registrar/DOSPolicyRegistrar.sol new file mode 100644 index 00000000..d2ee8a9e --- /dev/null +++ b/contracts/src/registrar/DOSPolicyRegistrar.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {IPermissionedRegistry} from "../registry/interfaces/IPermissionedRegistry.sol"; +import {IRegistry} from "../registry/interfaces/IRegistry.sol"; + +import {AbstractETHRegistrar} from "./AbstractETHRegistrar.sol"; +import {ETHRegistrar} from "./ETHRegistrar.sol"; +import {IETHRenewer} from "./interfaces/IETHRenewer.sol"; +import {IRentPriceOracle} from "./interfaces/IRentPriceOracle.sol"; + +/// @notice DOS Chain registrar that can only be operated through one policy controller. +/// @dev The registrar preserves the ENSv2 pricing, commitment, expiry and event behavior. +/// The controller only gates who may invoke registration and renewal. +contract DOSPolicyRegistrar is ETHRegistrar { + //////////////////////////////////////////////////////////////////////// + // Immutables + //////////////////////////////////////////////////////////////////////// + + /// @notice Policy contract allowed to invoke `register` and `renew`. + address public immutable REGISTRATION_CONTROLLER; + + //////////////////////////////////////////////////////////////////////// + // Errors + //////////////////////////////////////////////////////////////////////// + + /// @dev Error selector: `0xed6f956a` + error InvalidRegistrationController(); + /// @dev Error selector: `0x653353a4` + error UnauthorizedRegistrationController(address caller, address registrationController); + + //////////////////////////////////////////////////////////////////////// + // Modifiers + //////////////////////////////////////////////////////////////////////// + + modifier onlyRegistrationController() { + if (msg.sender != REGISTRATION_CONTROLLER) { + revert UnauthorizedRegistrationController(msg.sender, REGISTRATION_CONTROLLER); + } + _; + } + + //////////////////////////////////////////////////////////////////////// + // Initialization + //////////////////////////////////////////////////////////////////////// + + /// @param owner_ The registrar administrator. + /// @param dosRegistry The `.dos` registry. + /// @param beneficiary The recipient of registration and renewal payments. + /// @param oracle The ENSv2 rent price oracle. + /// @param gracePeriod The post-expiry renewal period in seconds. + /// @param minCommitmentAge The minimum commitment age in seconds. + /// @param maxCommitmentAge The maximum commitment age in seconds. + /// @param minRegisterDuration The minimum registration duration in seconds. + /// @param registrationController The only policy allowed to invoke registration and renewal. + constructor( + address owner_, + IPermissionedRegistry dosRegistry, + address beneficiary, + IRentPriceOracle oracle, + uint64 gracePeriod, + uint64 minCommitmentAge, + uint64 maxCommitmentAge, + uint64 minRegisterDuration, + address registrationController + ) + ETHRegistrar( + owner_, + dosRegistry, + beneficiary, + oracle, + gracePeriod, + minCommitmentAge, + maxCommitmentAge, + minRegisterDuration + ) + { + if (registrationController == address(0)) { + revert InvalidRegistrationController(); + } + REGISTRATION_CONTROLLER = registrationController; + } + + //////////////////////////////////////////////////////////////////////// + // Implementation + //////////////////////////////////////////////////////////////////////// + + /// @inheritdoc ETHRegistrar + function register( + string calldata label, + address owner, + bytes32 secret, + IRegistry subregistry, + address resolver, + uint64 duration, + IERC20 paymentToken, + bytes32 referrer + ) + external + override + onlyRegistrationController + returns (uint256 tokenId) + { + return + _register(label, owner, secret, subregistry, resolver, duration, paymentToken, referrer); + } + + /// @inheritdoc IETHRenewer + function renew(string calldata label, uint64 duration, IERC20 paymentToken, bytes32 referrer) + external + override(AbstractETHRegistrar, IETHRenewer) + onlyRegistrationController + { + _renew(label, duration, paymentToken, referrer); + } +} diff --git a/contracts/src/registrar/DosDomainPolicy.sol b/contracts/src/registrar/DosDomainPolicy.sol new file mode 100644 index 00000000..8afec5a0 --- /dev/null +++ b/contracts/src/registrar/DosDomainPolicy.sol @@ -0,0 +1,564 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {SafeERC20, IERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +import {IPermissionedRegistry} from "../registry/interfaces/IPermissionedRegistry.sol"; +import {IRegistry} from "../registry/interfaces/IRegistry.sol"; +import {LibLabel} from "../utils/LibLabel.sol"; + +import {IDosDomainPolicy} from "./interfaces/IDosDomainPolicy.sol"; +import {IETHRegistrar} from "./interfaces/IETHRegistrar.sol"; + +/// @dev Interface selector: `0x210b5bd2` +interface IDOSPolicyRegistrar is IETHRegistrar { + /// @notice Returns the only contract permitted to register and renew names. + function REGISTRATION_CONTROLLER() external view returns (address); +} + + +/// @notice Score-gated controller for DOS ID `.dos` registrations. +/// @dev DOS.Me verifies identity, wallet binding, and score off-chain, then issues a short-lived +/// EIP-712 voucher. This contract enforces voucher validity, lifecycle invariants, and the +/// non-bypass path into the ENSv2 registrar. +contract DosDomainPolicy is IDosDomainPolicy, EIP712, Ownable, Pausable, ReentrancyGuard { + using SafeERC20 for IERC20; + + //////////////////////////////////////////////////////////////////////// + // Constants & Immutables + //////////////////////////////////////////////////////////////////////// + + /// @notice Free initial claim duration, in seconds. + uint64 public constant INITIAL_CLAIM_DURATION = 365 days; + + /// @notice Post-expiry renewal window, in seconds. + uint64 public constant GRACE_PERIOD = 28 days; + + /// @dev EIP-712 type hash for policy vouchers. + bytes32 private constant VOUCHER_TYPEHASH = + keccak256( + "Voucher(uint8 operation,address wallet,bytes32 accountId,bytes32 labelhash,uint64 duration,address paymentToken,uint256 maxPrice,uint256 nonce,uint64 deadline)" + ); + + /// @notice Registry that owns `.dos` name state. + IPermissionedRegistry public immutable REGISTRY; + + /// @notice Resolver applied to policy-created names. + address public immutable DEFAULT_RESOLVER; + + //////////////////////////////////////////////////////////////////////// + // Storage + //////////////////////////////////////////////////////////////////////// + + /// @notice Registrar restricted to this policy controller. + IETHRegistrar public registrar; + + /// @notice Signer authorized to issue DOS.Me vouchers. + address public voucherSigner; + + /// @notice Minimum DOS.Me score included in eligibility checks. + uint32 public minimumScore; + + /// @notice Whether a voucher nonce has been consumed. + mapping(uint256 nonce => bool used) public usedNonces; + + /// @notice Whether a label has ever completed an initial claim. + mapping(bytes32 labelhash => bool claimed) public labelEverClaimed; + + /// @notice Whether a wallet has completed its one free initial claim. + mapping(address wallet => bool claimed) public walletEverClaimed; + + /// @notice Whether a DOS.Me account has completed its one free initial claim. + mapping(bytes32 accountId => bool claimed) public accountEverClaimed; + + /// @notice Registry expiry recorded after each successful policy operation. + mapping(bytes32 labelhash => uint64 expiry) public paidExpiry; + + //////////////////////////////////////////////////////////////////////// + // Initialization + //////////////////////////////////////////////////////////////////////// + + /// @param registry The `.dos` registry. + /// @param initialOwner The policy administrator. + /// @param initialVoucherSigner The initial DOS.Me voucher signer. + /// @param initialMinimumScore The initial DOS.Me eligibility threshold. + /// @param defaultResolver The resolver applied to policy-created names. + constructor( + IPermissionedRegistry registry, + address initialOwner, + address initialVoucherSigner, + uint32 initialMinimumScore, + address defaultResolver + ) + EIP712("DOS Domain Policy", "1") + Ownable(initialOwner) + { + if (address(registry) == address(0) || initialVoucherSigner == address(0)) { + revert ZeroAddress(); + } + + REGISTRY = registry; + DEFAULT_RESOLVER = defaultResolver; + voucherSigner = initialVoucherSigner; + minimumScore = initialMinimumScore; + + emit VoucherSignerSet(initialVoucherSigner); + emit MinimumScoreSet(initialMinimumScore); + } + + //////////////////////////////////////////////////////////////////////// + // Implementation + //////////////////////////////////////////////////////////////////////// + + /// @inheritdoc IDosDomainPolicy + function setRegistrar(IETHRegistrar registrar_) external override onlyOwner { + if (address(registrar) != address(0)) { + revert RegistrarAlreadySet(); + } + if (address(registrar_) == address(0)) { + revert ZeroAddress(); + } + address controller = IDOSPolicyRegistrar(address(registrar_)).REGISTRATION_CONTROLLER(); + if (controller != address(this)) { + revert InvalidRegistrationController(controller); + } + + registrar = registrar_; + emit RegistrarSet(registrar_); + } + + /// @notice Changes the signer allowed to authorize policy operations. + /// @param newVoucherSigner The new voucher signer. + function setVoucherSigner(address newVoucherSigner) external onlyOwner { + if (newVoucherSigner == address(0)) { + revert ZeroAddress(); + } + + voucherSigner = newVoucherSigner; + emit VoucherSignerSet(newVoucherSigner); + } + + /// @notice Changes the score that DOS.Me must enforce before issuing a voucher. + /// @param newMinimumScore The new minimum score in DOS.Me score units. + function setMinimumScore(uint32 newMinimumScore) external onlyOwner { + minimumScore = newMinimumScore; + emit MinimumScoreSet(newMinimumScore); + } + + /// @notice Pauses all voucher-consuming operations. + function pause() external onlyOwner { + _pause(); + } + + /// @notice Restores voucher-consuming operations. + function unpause() external onlyOwner { + _unpause(); + } + + /// @notice Withdraws policy subsidy funds. + /// @param token The token to withdraw. + /// @param recipient The recipient of the withdrawn tokens. + /// @param amount The amount to withdraw. + function withdrawToken(IERC20 token, address recipient, uint256 amount) external onlyOwner { + if (recipient == address(0)) { + revert ZeroAddress(); + } + token.safeTransfer(recipient, amount); + } + + /// @inheritdoc IDosDomainPolicy + function commitClaim( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + bytes32 secret + ) + external + override + whenNotPaused + { + _validateVoucher(label, voucher, signature, Operation.CLAIM); + _validateInitialClaim(label, voucher); + _commit(label, voucher, secret); + } + + /// @inheritdoc IDosDomainPolicy + function commitReclaim( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + bytes32 secret + ) + external + override + whenNotPaused + { + _validateVoucher(label, voucher, signature, Operation.RECLAIM); + _validateReclaim(label, voucher); + _commit(label, voucher, secret); + } + + /// @inheritdoc IDosDomainPolicy + function claim( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + bytes32 secret + ) + external + override + whenNotPaused + nonReentrant + returns (uint256 tokenId) + { + _validateVoucher(label, voucher, signature, Operation.CLAIM); + _validateInitialClaim(label, voucher); + + uint256 price = _priceAndCheckCap(label, voucher); + _approveRegistrar(voucher.paymentToken, price); + tokenId = _register(label, voucher, secret); + + usedNonces[voucher.nonce] = true; + labelEverClaimed[voucher.labelhash] = true; + walletEverClaimed[voucher.wallet] = true; + accountEverClaimed[voucher.accountId] = true; + uint64 expiry = uint64(block.timestamp) + voucher.duration; + paidExpiry[voucher.labelhash] = expiry; + + emit DosDomainClaimed( + tokenId, + voucher.labelhash, + label, + voucher.wallet, + expiry, + voucher.paymentToken, + price + ); + } + + /// @inheritdoc IDosDomainPolicy + function renew(string calldata label, Voucher calldata voucher, bytes calldata signature) + external + override + whenNotPaused + nonReentrant + { + _validateVoucher(label, voucher, signature, Operation.RENEW); + _requireRegistrar(); + + IPermissionedRegistry.State memory state = REGISTRY.getState(LibLabel.id(label)); + if (state.latestOwner != voucher.wallet) { + revert NotCurrentOwner(voucher.wallet, state.latestOwner); + } + uint64 currentExpiry = state.expiry; + if (currentExpiry == 0 || block.timestamp >= uint256(currentExpiry) + GRACE_PERIOD) { + revert RenewalGracePeriodEnded(voucher.labelhash); + } + + uint256 price = _priceAndCheckCap(label, voucher); + voucher.paymentToken.safeTransferFrom(_msgSender(), address(this), price); + _approveRegistrar(voucher.paymentToken, price); + registrar.renew(label, voucher.duration, voucher.paymentToken, bytes32(0)); + + usedNonces[voucher.nonce] = true; + labelEverClaimed[voucher.labelhash] = true; + accountEverClaimed[voucher.accountId] = true; + uint64 expiry = currentExpiry + voucher.duration; + paidExpiry[voucher.labelhash] = expiry; + + emit DosDomainRenewed( + state.tokenId, + voucher.labelhash, + label, + voucher.wallet, + expiry, + voucher.paymentToken, + price + ); + } + + /// @inheritdoc IDosDomainPolicy + function reclaim( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + bytes32 secret + ) + external + override + whenNotPaused + nonReentrant + returns (uint256 tokenId) + { + _validateVoucher(label, voucher, signature, Operation.RECLAIM); + _validateReclaim(label, voucher); + + uint256 price = _priceAndCheckCap(label, voucher); + voucher.paymentToken.safeTransferFrom(_msgSender(), address(this), price); + _approveRegistrar(voucher.paymentToken, price); + tokenId = _register(label, voucher, secret); + + usedNonces[voucher.nonce] = true; + labelEverClaimed[voucher.labelhash] = true; + accountEverClaimed[voucher.accountId] = true; + uint64 expiry = uint64(block.timestamp) + voucher.duration; + paidExpiry[voucher.labelhash] = expiry; + + emit DosDomainReclaimed( + tokenId, + voucher.labelhash, + label, + voucher.wallet, + expiry, + voucher.paymentToken, + price + ); + } + + /// @inheritdoc IDosDomainPolicy + function isDosMeEntitled(string calldata label, address wallet) + external + view + override + returns (bool) + { + bytes32 labelhash = keccak256(bytes(label)); + uint64 expiry = paidExpiry[labelhash]; + if (expiry == 0 || block.timestamp >= uint256(expiry) + GRACE_PERIOD) { + return false; + } + + IPermissionedRegistry.State memory state = REGISTRY.getState(LibLabel.id(label)); + return state.latestOwner == wallet; + } + + /// @inheritdoc IDosDomainPolicy + function hashVoucher(Voucher memory voucher) public view override returns (bytes32) { + return _hashTypedDataV4(_voucherStructHash(voucher)); + } + + /// @inheritdoc IDosDomainPolicy + function hasHistoricalClaim(bytes32 labelhash) public view override returns (bool) { + return labelEverClaimed[labelhash] || REGISTRY.getState(uint256(labelhash)).expiry != 0; + } + + /// @inheritdoc IDosDomainPolicy + function isRenewalEligible(string calldata label, address wallet) + public + view + override + returns (bool) + { + IPermissionedRegistry.State memory state = REGISTRY.getState(LibLabel.id(label)); + return + state.latestOwner == wallet && + state.expiry != 0 && + block.timestamp < uint256(state.expiry) + GRACE_PERIOD; + } + + /// @inheritdoc IDosDomainPolicy + function isReclaimEligible(string calldata label) public view override returns (bool) { + return + address(registrar) != address(0) && + hasHistoricalClaim(keccak256(bytes(label))) && + registrar.isAvailable(label); + } + + /// @inheritdoc IDosDomainPolicy + function quote(Operation operation, string calldata label, uint64 duration, IERC20 paymentToken) + public + view + override + returns (uint256 base, uint256 premium, uint256 total) + { + _requireRegistrar(); + if (operation == Operation.RENEW) { + base = registrar.getRenewPrice(label, duration, paymentToken); + } else { + (base, premium) = registrar.getRegisterPrice(label, duration, paymentToken); + } + total = base + premium; + } + + //////////////////////////////////////////////////////////////////////// + // Internal Functions + //////////////////////////////////////////////////////////////////////// + + /// @dev Sets the registrar allowance to the current payment amount. + function _approveRegistrar(IERC20 token, uint256 amount) internal { + token.forceApprove(address(registrar), amount); + } + + /// @dev Creates a commitment unless an unexpired identical commitment already exists. + function _commit(string calldata label, Voucher calldata voucher, bytes32 secret) internal { + _requireRegistrar(); + bytes32 commitment = + registrar.makeCommitment( + label, + voucher.wallet, + secret, + IRegistry(address(0)), + DEFAULT_RESOLVER, + voucher.duration, + bytes32(0) + ); + uint64 committedAt = registrar.commitmentAt(commitment); + if ( + committedAt == 0 || + block.timestamp >= uint256(committedAt) + registrar.MAX_COMMITMENT_AGE() + ) { + registrar.commit(commitment); + } + emit ClaimCommitted(commitment, voucher.labelhash, voucher.wallet, voucher.nonce); + } + + /// @dev Registers through the policy-controlled registrar using voucher parameters. + function _register(string calldata label, Voucher calldata voucher, bytes32 secret) + internal + returns (uint256) + { + return + registrar.register( + label, + voucher.wallet, + secret, + IRegistry(address(0)), + DEFAULT_RESOLVER, + voucher.duration, + voucher.paymentToken, + bytes32(0) + ); + } + + /// @dev Validates that the wallet, operation, expiry, nonce, label, and signature match a voucher. + function _validateVoucher( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + Operation expectedOperation + ) + internal + view + { + _validateLabel(label); + if (_msgSender() != voucher.wallet) { + revert WrongWallet(_msgSender(), voucher.wallet); + } + if (voucher.operation != expectedOperation) { + revert InvalidOperation(expectedOperation, voucher.operation); + } + if (voucher.accountId == bytes32(0)) { + revert InvalidVoucher(); + } + if (voucher.deadline < block.timestamp) { + revert VoucherExpired(voucher.deadline); + } + if (usedNonces[voucher.nonce]) { + revert NonceAlreadyUsed(voucher.nonce); + } + if (voucher.labelhash != keccak256(bytes(label))) { + revert InvalidVoucher(); + } + if (!SignatureChecker.isValidSignatureNow(voucherSigner, hashVoucher(voucher), signature)) { + revert InvalidVoucher(); + } + } + + /// @dev Validates free-claim duration, per-wallet and per-label history, and registrar availability. + function _validateInitialClaim(string calldata label, Voucher calldata voucher) internal view { + _requireRegistrar(); + if (voucher.duration != INITIAL_CLAIM_DURATION) { + revert InitialClaimDurationInvalid(voucher.duration); + } + if (walletEverClaimed[voucher.wallet]) { + revert FirstClaimAlreadyUsed(voucher.wallet); + } + if (accountEverClaimed[voucher.accountId]) { + revert AccountAlreadyClaimed(voucher.accountId); + } + if (hasHistoricalClaim(voucher.labelhash)) { + revert LabelAlreadyClaimed(voucher.labelhash); + } + if (!registrar.isAvailable(label)) { + revert NameNotAvailable(label); + } + } + + /// @dev Validates historical claim status and current registrar availability for a paid reclaim. + function _validateReclaim(string calldata label, Voucher calldata voucher) internal view { + _requireRegistrar(); + if (!hasHistoricalClaim(voucher.labelhash)) { + revert LabelNotPreviouslyClaimed(voucher.labelhash); + } + if (!registrar.isAvailable(label)) { + revert NameNotAvailable(label); + } + } + + /// @dev Returns the current registrar price only when it does not exceed the signed cap. + function _priceAndCheckCap(string calldata label, Voucher calldata voucher) + internal + view + returns (uint256 price) + { + (, , price) = quote(voucher.operation, label, voucher.duration, voucher.paymentToken); + if (price > voucher.maxPrice) { + revert PriceExceedsVoucher(price, voucher.maxPrice); + } + } + + /// @dev Reverts while no registrar has been bound to the policy. + function _requireRegistrar() internal view { + if (address(registrar) == address(0)) { + revert RegistrarNotSet(); + } + } + + /// @dev Produces the EIP-712 struct hash before the domain separator is applied. + function _voucherStructHash(Voucher memory voucher) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + VOUCHER_TYPEHASH, + voucher.operation, + voucher.wallet, + voucher.accountId, + voucher.labelhash, + voucher.duration, + voucher.paymentToken, + voucher.maxPrice, + voucher.nonce, + voucher.deadline + ) + ); + } + + /// @dev Enforces the lower-case DOS ID label grammar. + function _validateLabel(string calldata label) internal pure { + bytes calldata value = bytes(label); + uint256 length = value.length; + if (length < 5 || length > 63) { + revert InvalidLabel(label); + } + if (!_isAlphaNumeric(value[0]) || !_isAlphaNumeric(value[length - 1])) { + revert InvalidLabel(label); + } + + for (uint256 i; i < length; ++i) { + bytes1 character = value[i]; + if (character != "-" && !_isAlphaNumeric(character)) { + revert InvalidLabel(label); + } + } + } + + /// @dev Reports whether one byte is an ASCII lower-case letter or digit. + function _isAlphaNumeric(bytes1 character) internal pure returns (bool) { + return (character >= "a" && character <= "z") || (character >= "0" && character <= "9"); + } +} diff --git a/contracts/src/registrar/ETHRegistrar.sol b/contracts/src/registrar/ETHRegistrar.sol index 9a46380e..58d85276 100644 --- a/contracts/src/registrar/ETHRegistrar.sol +++ b/contracts/src/registrar/ETHRegistrar.sol @@ -131,43 +131,11 @@ contract ETHRegistrar is AbstractETHRegistrar, IETHRegistrar { bytes32 referrer ) external + virtual returns (uint256 tokenId) { - if (owner == address(0)) { - revert InvalidOwner(); - } - _consumeCommitment( - makeCommitment(label, owner, secret, subregistry, resolver, duration, referrer) - ); // reverts if no commitment - IPermissionedRegistry.State memory state = _requireAvailable(label, duration); // reverts if not - (uint256 base, uint256 premium) = - rentPriceOracle.getRegisterPrice( - label, - _availablePeriod(state.expiry), - duration, - paymentToken - ); // reverts if invalid - SafeERC20.safeTransferFrom(paymentToken, msg.sender, BENEFICIARY, base + premium); // reverts if payment failed - tokenId = ETH_REGISTRY.register( - label, - owner, - subregistry, - resolver, - REGISTRATION_ROLE_BITMAP, - uint64(block.timestamp) + duration // new expiry - ); // should not revert - emit NameRegistered( - tokenId, - label, - owner, - subregistry, - resolver, - duration, - paymentToken, - referrer, - base, - premium - ); + return + _register(label, owner, secret, subregistry, resolver, duration, paymentToken, referrer); } /// @inheritdoc IETHRegistrar @@ -194,11 +162,7 @@ contract ETHRegistrar is AbstractETHRegistrar, IETHRegistrar { function getRemainingGracePeriod(string calldata label) external view returns (uint64) { IPermissionedRegistry.State memory state = ETH_REGISTRY.getState(LibLabel.id(label)); return - uint64( - _isRenewableGrace(state) - ? GRACE_PERIOD - (block.timestamp - state.expiry) - : 0 - ); + uint64(_isRenewableGrace(state) ? GRACE_PERIOD - (block.timestamp - state.expiry) : 0); } /// @inheritdoc IETHRegistrar @@ -224,6 +188,57 @@ contract ETHRegistrar is AbstractETHRegistrar, IETHRegistrar { // Internal Functions //////////////////////////////////////////////////////////////////////// + /// @dev Performs registration after any derived registrar authorization check. + function _register( + string calldata label, + address owner, + bytes32 secret, + IRegistry subregistry, + address resolver, + uint64 duration, + IERC20 paymentToken, + bytes32 referrer + ) + internal + returns (uint256 tokenId) + { + if (owner == address(0)) { + revert InvalidOwner(); + } + _consumeCommitment( + makeCommitment(label, owner, secret, subregistry, resolver, duration, referrer) + ); // reverts if no commitment + IPermissionedRegistry.State memory state = _requireAvailable(label, duration); // reverts if not + (uint256 base, uint256 premium) = + rentPriceOracle.getRegisterPrice( + label, + _availablePeriod(state.expiry), + duration, + paymentToken + ); // reverts if invalid + SafeERC20.safeTransferFrom(paymentToken, msg.sender, BENEFICIARY, base + premium); // reverts if payment failed + tokenId = ETH_REGISTRY.register( + label, + owner, + subregistry, + resolver, + REGISTRATION_ROLE_BITMAP, + uint64(block.timestamp) + duration // new expiry + ); // should not revert + emit NameRegistered( + tokenId, + label, + owner, + subregistry, + resolver, + duration, + paymentToken, + referrer, + base, + premium + ); + } + /// @dev Validates that the given `commitment` was recorded within the allowed time window /// (between minimum and maximum commitment age), then deletes it so it cannot be reused. /// @param commitment The commitment hash to validate and consume. diff --git a/contracts/src/registrar/interfaces/IDosDomainPolicy.sol b/contracts/src/registrar/interfaces/IDosDomainPolicy.sol new file mode 100644 index 00000000..59cef60f --- /dev/null +++ b/contracts/src/registrar/interfaces/IDosDomainPolicy.sol @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import {IETHRegistrar} from "./IETHRegistrar.sol"; + +/// @dev Interface selector: `0xb291e5bf` +interface IDosDomainPolicy { + //////////////////////////////////////////////////////////////////////// + // Types + //////////////////////////////////////////////////////////////////////// + + /// @notice Operation authorized by a DOS.Me voucher. + enum Operation { + CLAIM, + RENEW, + RECLAIM + } + + /// @notice Signed authorization for one policy operation. + struct Voucher { + Operation operation; + address wallet; + bytes32 accountId; + bytes32 labelhash; + uint64 duration; + IERC20 paymentToken; + uint256 maxPrice; + uint256 nonce; + uint64 deadline; + } + + //////////////////////////////////////////////////////////////////////// + // Events + //////////////////////////////////////////////////////////////////////// + + /// @notice The policy registrar was configured. + /// @param registrar The configured registrar. + event RegistrarSet(IETHRegistrar indexed registrar); + + /// @notice The voucher signer was updated. + /// @param signer The new voucher signer. + event VoucherSignerSet(address indexed signer); + + /// @notice The DOS.Me score threshold was updated. + /// @param minimumScore The required score in DOS.Me score units. + event MinimumScoreSet(uint32 minimumScore); + + /// @notice A claim or reclaim commitment was recorded. + /// @param commitment The registrar commitment. + /// @param labelhash The claimed label hash. + /// @param wallet The authorized DOS Wallet. + /// @param nonce The voucher nonce. + event ClaimCommitted( + bytes32 indexed commitment, + bytes32 indexed labelhash, + address indexed wallet, + uint256 nonce + ); + + /// @notice An initial free DOS ID domain was registered. + /// @param tokenId The registry token ID. + /// @param labelhash The registered label hash. + /// @param label The registered label. + /// @param wallet The DOS Wallet that owns the name. + /// @param paidExpiry The on-chain expiry after registration. + /// @param paymentToken The registrar payment token. + /// @param paidPrice The subsidy amount paid by this policy. + event DosDomainClaimed( + uint256 indexed tokenId, + bytes32 indexed labelhash, + string label, + address wallet, + uint64 paidExpiry, + IERC20 paymentToken, + uint256 paidPrice + ); + + /// @notice A DOS ID domain was renewed. + /// @param tokenId The registry token ID. + /// @param labelhash The renewed label hash. + /// @param label The renewed label. + /// @param wallet The DOS Wallet that owns the name. + /// @param paidExpiry The on-chain expiry after renewal. + /// @param paymentToken The registrar payment token. + /// @param paidPrice The amount paid by the wallet. + event DosDomainRenewed( + uint256 indexed tokenId, + bytes32 indexed labelhash, + string label, + address wallet, + uint64 paidExpiry, + IERC20 paymentToken, + uint256 paidPrice + ); + + /// @notice A historically claimed DOS ID domain was registered again for a fee. + /// @param tokenId The registry token ID. + /// @param labelhash The reclaimed label hash. + /// @param label The reclaimed label. + /// @param wallet The DOS Wallet that owns the name. + /// @param paidExpiry The on-chain expiry after registration. + /// @param paymentToken The registrar payment token. + /// @param paidPrice The amount paid by the wallet. + event DosDomainReclaimed( + uint256 indexed tokenId, + bytes32 indexed labelhash, + string label, + address wallet, + uint64 paidExpiry, + IERC20 paymentToken, + uint256 paidPrice + ); + + //////////////////////////////////////////////////////////////////////// + // Errors + //////////////////////////////////////////////////////////////////////// + + /// @dev Error selector: `0x0a9644cf` + error InvalidLabel(string label); + /// @dev Error selector: `0x6d67dbd6` + error InvalidVoucher(); + /// @dev Error selector: `0x68641fc4` + error VoucherExpired(uint64 deadline); + /// @dev Error selector: `0x91cab504` + error NonceAlreadyUsed(uint256 nonce); + /// @dev Error selector: `0xb9137cf5` + error WrongWallet(address caller, address wallet); + /// @dev Error selector: `0x7ee36f10` + error InvalidOperation(Operation expected, Operation actual); + /// @dev Error selector: `0xc138385a` + error InitialClaimDurationInvalid(uint64 duration); + /// @dev Error selector: `0xecf304be` + error FirstClaimAlreadyUsed(address wallet); + /// @dev Error selector: `0xa12ff4c4` + error AccountAlreadyClaimed(bytes32 accountId); + /// @dev Error selector: `0x8c961146` + error LabelAlreadyClaimed(bytes32 labelhash); + /// @dev Error selector: `0x70fef996` + error LabelNotPreviouslyClaimed(bytes32 labelhash); + /// @dev Error selector: `0x477707e8` + error NameNotAvailable(string label); + /// @dev Error selector: `0x9e6a3193` + error NotCurrentOwner(address expected, address actual); + /// @dev Error selector: `0xeb491a07` + error RenewalGracePeriodEnded(bytes32 labelhash); + /// @dev Error selector: `0xe352f6db` + error PriceExceedsVoucher(uint256 actualPrice, uint256 maxPrice); + /// @dev Error selector: `0xf94a80d9` + error RegistrarAlreadySet(); + /// @dev Error selector: `0xe4cea3b4` + error RegistrarNotSet(); + /// @dev Error selector: `0xfe4404fb` + error InvalidRegistrationController(address actual); + /// @dev Error selector: `0xd92e233d` + error ZeroAddress(); + + //////////////////////////////////////////////////////////////////////// + // Functions + //////////////////////////////////////////////////////////////////////// + + /// @notice Configures the one policy-controlled registrar. + /// @param registrar_ The registrar whose controller is this policy. + function setRegistrar(IETHRegistrar registrar_) external; + + /// @notice Records an initial-claim commitment. + /// @param label The label to claim. + /// @param voucher The authorization voucher. + /// @param signature The voucher signature. + /// @param secret The commitment secret. + function commitClaim( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + bytes32 secret + ) + external; + + /// @notice Records a paid-reclaim commitment. + /// @param label The label to reclaim. + /// @param voucher The authorization voucher. + /// @param signature The voucher signature. + /// @param secret The commitment secret. + function commitReclaim( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + bytes32 secret + ) + external; + + /// @notice Registers the wallet's first free DOS ID domain. + /// @param label The label to claim. + /// @param voucher The authorization voucher. + /// @param signature The voucher signature. + /// @param secret The commitment secret. + /// @return tokenId The registered registry token ID. + function claim( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + bytes32 secret + ) + external + returns (uint256 tokenId); + + /// @notice Renews a qualifying wallet's domain during the grace window. + /// @param label The label to renew. + /// @param voucher The authorization voucher. + /// @param signature The voucher signature. + function renew(string calldata label, Voucher calldata voucher, bytes calldata signature) + external; + + /// @notice Registers a previously claimed label again for a fee. + /// @param label The label to reclaim. + /// @param voucher The authorization voucher. + /// @param signature The voucher signature. + /// @param secret The commitment secret. + /// @return tokenId The registered registry token ID. + function reclaim( + string calldata label, + Voucher calldata voucher, + bytes calldata signature, + bytes32 secret + ) + external + returns (uint256 tokenId); + + /// @notice Hashes a voucher using the policy EIP-712 domain. + /// @param voucher The voucher to hash. + /// @return The EIP-712 digest. + function hashVoucher(Voucher memory voucher) external view returns (bytes32); + + /// @notice Reports whether the label was registered before or through this policy. + /// @param labelhash The label hash to check. + /// @return `true` if the registry or policy records a prior registration. + function hasHistoricalClaim(bytes32 labelhash) external view returns (bool); + + /// @notice Reports whether the wallet can renew the name during its grace window. + /// @param label The label to check. + /// @param wallet The current DOS Wallet owner. + /// @return `true` if the wallet owns a renewable name. + function isRenewalEligible(string calldata label, address wallet) external view returns (bool); + + /// @notice Reports whether a historical name is currently available for paid reclaim. + /// @param label The label to check. + /// @return `true` if the name has history and can be registered again. + function isReclaimEligible(string calldata label) external view returns (bool); + + /// @notice Quotes the registrar cost for a policy operation. + /// @param operation The intended operation. + /// @param label The label to price. + /// @param duration The requested duration in seconds. + /// @param paymentToken The registrar payment token. + /// @return base The base registration or renewal price. + /// @return premium The registration premium, if applicable. + /// @return total The total amount to pay. + function quote(Operation operation, string calldata label, uint64 duration, IERC20 paymentToken) + external + view + returns (uint256 base, uint256 premium, uint256 total); + + /// @notice Reports whether the wallet currently holds an active policy claim. + /// @param label The label to check. + /// @param wallet The DOS Wallet to check. + /// @return `true` if the wallet owns the name before its grace period ends. + function isDosMeEntitled(string calldata label, address wallet) external view returns (bool); +} diff --git a/contracts/src/registrar/interfaces/IETHRegistrar.sol b/contracts/src/registrar/interfaces/IETHRegistrar.sol index 6c69a19c..dd467d83 100755 --- a/contracts/src/registrar/interfaces/IETHRegistrar.sol +++ b/contracts/src/registrar/interfaces/IETHRegistrar.sol @@ -8,7 +8,7 @@ import {IRegistry} from "../../registry/interfaces/IRegistry.sol"; import {IETHRenewer} from "./IETHRenewer.sol"; /// @notice Interface for registering ".eth" names. -/// @dev Interface selector: `0xc1401b80` +/// @dev Interface selector: `0x4d8b8526` interface IETHRegistrar is IETHRenewer { //////////////////////////////////////////////////////////////////////// // Events @@ -99,6 +99,9 @@ interface IETHRegistrar is IETHRenewer { /// @return The commitment time, in seconds, or 0 if unknown. function commitmentAt(bytes32 commitment) external view returns (uint64); + /// @notice Maximum age of a valid commitment in seconds. + function MAX_COMMITMENT_AGE() external view returns (uint64); + /// @notice Determine register price for a name. /// @param label The name to register. /// @param duration The registration duration, in seconds. diff --git a/contracts/test/unit/registrar/DosDomainPolicy.t.sol b/contracts/test/unit/registrar/DosDomainPolicy.t.sol new file mode 100644 index 00000000..93adcf1d --- /dev/null +++ b/contracts/test/unit/registrar/DosDomainPolicy.t.sol @@ -0,0 +1,517 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.13; + +import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol"; + +import {DosDomainPolicy} from "~src/registrar/DosDomainPolicy.sol"; +import {DOSPolicyRegistrar} from "~src/registrar/DOSPolicyRegistrar.sol"; +import {IDosDomainPolicy} from "~src/registrar/interfaces/IDosDomainPolicy.sol"; +import {IRegistry} from "~src/registry/interfaces/IRegistry.sol"; +import {IPermissionedRegistry} from "~src/registry/interfaces/IPermissionedRegistry.sol"; +import {RegistryRolesLib} from "~src/registry/libraries/RegistryRolesLib.sol"; +import {LibLabel} from "~src/utils/LibLabel.sol"; +import {MigrationControllerFixture} from "~test/fixtures/MigrationControllerFixture.sol"; +import {StandardRentPriceOracleFixture} from "~test/fixtures/StandardRentPriceOracleFixture.sol"; +import {StandardRegistrar} from "~test/StandardRegistrar.sol"; + +contract DosDomainPolicyTest is MigrationControllerFixture, StandardRentPriceOracleFixture { + uint64 constant CLAIM_DURATION = 365 days; + uint64 constant GRACE_PERIOD = 28 days; + uint256 constant VOUCHER_SIGNER_KEY = 0xA11CE; + + DosDomainPolicy policy; + DOSPolicyRegistrar registrar; + + address voucherSigner = vm.addr(VOUCHER_SIGNER_KEY); + address wallet = makeAddr("dosWallet"); + + function setUp() external { + deployMigrationControllerFixture(); + deployStandardRentPriceOracleFixture(); + + policy = new DosDomainPolicy(ethRegistry, address(this), voucherSigner, 20, address(0)); + registrar = new DOSPolicyRegistrar( + address(this), + ethRegistry, + beneficiary, + rentPriceOracle, + GRACE_PERIOD, + StandardRegistrar.MIN_COMMITMENT_AGE, + StandardRegistrar.MAX_COMMITMENT_AGE, + StandardRegistrar.MIN_REGISTER_DURATION, + address(policy) + ); + uint256 registrarRoles = RegistryRolesLib.ROLE_REGISTRAR | RegistryRolesLib.ROLE_RENEW; + ethRegistry.grantRootRoles(registrarRoles, address(registrar)); + ethRegistry.revokeRootRoles(registrarRoles, address(this)); + policy.setRegistrar(registrar); + + tokenUSDC.mint(address(policy), type(uint128).max); + vm.warp(GRACE_PERIOD + 1); + } + + function test_claimUsesPolicySubsidyAndSetsPaidExpiry() external { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("alice", wallet, 1); + bytes32 secret = keccak256("commitment secret"); + bytes memory signature = _sign(voucher); + + vm.prank(wallet); + policy.commitClaim("alice", voucher, signature, secret); + vm.warp(block.timestamp + registrar.MIN_COMMITMENT_AGE() + 1); + + vm.prank(wallet); + policy.claim("alice", voucher, signature, secret); + + bytes32 labelhash = keccak256(bytes("alice")); + assertTrue(policy.labelEverClaimed(labelhash)); + assertTrue(policy.walletEverClaimed(wallet)); + assertTrue(policy.accountEverClaimed(voucher.accountId)); + assertEq(policy.paidExpiry(labelhash), uint64(block.timestamp) + CLAIM_DURATION); + assertTrue(policy.isDosMeEntitled("alice", wallet)); + } + + function test_claimRejectsReplay() external { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("alice", wallet, 1); + bytes32 secret = keccak256("commitment secret"); + bytes memory signature = _sign(voucher); + + vm.prank(wallet); + policy.commitClaim("alice", voucher, signature, secret); + vm.warp(block.timestamp + registrar.MIN_COMMITMENT_AGE() + 1); + vm.prank(wallet); + policy.claim("alice", voucher, signature, secret); + + vm.prank(wallet); + vm.expectRevert( + abi.encodeWithSelector(IDosDomainPolicy.NonceAlreadyUsed.selector, voucher.nonce) + ); + policy.claim("alice", voucher, signature, secret); + } + + function test_claimRejectsExternalWalletCaller() external { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("alice", wallet, 1); + bytes memory signature = _sign(voucher); + address externalWallet = makeAddr("externalWallet"); + + vm.prank(externalWallet); + vm.expectRevert( + abi.encodeWithSelector(IDosDomainPolicy.WrongWallet.selector, externalWallet, wallet) + ); + policy.commitClaim("alice", voucher, signature, keccak256("commitment secret")); + } + + function test_claimRejectsUnderscoredLabel() external { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("alice_id", wallet, 1); + bytes memory signature = _sign(voucher); + + vm.prank(wallet); + vm.expectRevert(abi.encodeWithSelector(IDosDomainPolicy.InvalidLabel.selector, "alice_id")); + policy.commitClaim("alice_id", voucher, signature, keccak256("commitment secret")); + } + + function test_claimRejectsInvalidVoucherSignature() external { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("alice", wallet, 1); + bytes32 digest = policy.hashVoucher(voucher); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(0xB0B, digest); + bytes memory invalidSignature = abi.encodePacked(r, s, v); + + vm.prank(wallet); + vm.expectRevert(IDosDomainPolicy.InvalidVoucher.selector); + policy.commitClaim("alice", voucher, invalidSignature, keccak256("commitment secret")); + } + + function test_claimReusesAFrontRunCommitmentWithoutBypass() external { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("alice", wallet, 1); + bytes32 secret = keccak256("commitment secret"); + bytes memory signature = _sign(voucher); + bytes32 commitment = + registrar.makeCommitment( + "alice", + wallet, + secret, + IRegistry(address(0)), + address(0), + CLAIM_DURATION, + bytes32(0) + ); + + vm.prank(makeAddr("frontRunner")); + registrar.commit(commitment); + + vm.prank(wallet); + policy.commitClaim("alice", voucher, signature, secret); + vm.warp(block.timestamp + registrar.MIN_COMMITMENT_AGE() + 1); + vm.prank(wallet); + policy.claim("alice", voucher, signature, secret); + + assertTrue(policy.isDosMeEntitled("alice", wallet)); + } + + function test_claimRejectsSecondFreeClaimForWallet() external { + _claim("alice", wallet, 1); + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("bravo", wallet, 2); + bytes memory signature = _sign(voucher); + + vm.prank(wallet); + vm.expectRevert( + abi.encodeWithSelector(IDosDomainPolicy.FirstClaimAlreadyUsed.selector, wallet) + ); + policy.commitClaim("bravo", voucher, signature, keccak256("another secret")); + } + + function test_claimRejectsSecondFreeClaimForLabel() external { + _claim("alice", wallet, 1); + address otherWallet = makeAddr("otherDosWallet"); + IDosDomainPolicy.Voucher memory voucher = + IDosDomainPolicy.Voucher({operation: IDosDomainPolicy.Operation.CLAIM, wallet: otherWallet, accountId: _accountId( + otherWallet + ), labelhash: keccak256(bytes("alice")), duration: CLAIM_DURATION, paymentToken: tokenUSDC, maxPrice: 0, nonce: 2, deadline: uint64( + block.timestamp + 1 days + )}); + bytes memory signature = _sign(voucher); + + vm.prank(otherWallet); + vm.expectRevert( + abi.encodeWithSelector( + IDosDomainPolicy.LabelAlreadyClaimed.selector, + keccak256(bytes("alice")) + ) + ); + policy.commitClaim("alice", voucher, signature, keccak256("another secret")); + } + + function test_claimRejectsSecondFreeClaimForAccountAfterWalletChange() external { + _claim("alice", wallet, 1); + address replacementWallet = makeAddr("replacementDosWallet"); + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("bravo", replacementWallet, 2); + voucher.accountId = _accountId(wallet); + bytes memory signature = _sign(voucher); + + vm.prank(replacementWallet); + vm.expectRevert( + abi.encodeWithSelector( + IDosDomainPolicy.AccountAlreadyClaimed.selector, + voucher.accountId + ) + ); + policy.commitClaim("bravo", voucher, signature, keccak256("replacement wallet secret")); + } + + function test_claimRespectsVoucherPriceCap() external { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("alice", wallet, 1); + voucher.maxPrice = 0; + bytes32 secret = keccak256("commitment secret"); + bytes memory signature = _sign(voucher); + + vm.prank(wallet); + policy.commitClaim("alice", voucher, signature, secret); + vm.warp(block.timestamp + registrar.MIN_COMMITMENT_AGE() + 1); + (, , uint256 actualPrice) = + policy.quote(IDosDomainPolicy.Operation.CLAIM, "alice", CLAIM_DURATION, tokenUSDC); + vm.expectRevert( + abi.encodeWithSelector(IDosDomainPolicy.PriceExceedsVoucher.selector, actualPrice, 0) + ); + vm.prank(wallet); + policy.claim("alice", voucher, signature, secret); + } + + function test_pauseBlocksClaimButNotEntitlementRead() external { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher("alice", wallet, 1); + bytes memory signature = _sign(voucher); + policy.pause(); + + assertFalse(policy.isDosMeEntitled("alice", wallet)); + vm.prank(wallet); + vm.expectRevert(Pausable.EnforcedPause.selector); + policy.commitClaim("alice", voucher, signature, keccak256("commitment secret")); + } + + function test_renewWorksDuringGracePeriod() external { + _claim("alice", wallet, 1); + bytes32 labelhash = keccak256(bytes("alice")); + uint64 previousPaidExpiry = policy.paidExpiry(labelhash); + vm.warp(previousPaidExpiry + 1); + + IDosDomainPolicy.Voucher memory voucher = + _voucher(IDosDomainPolicy.Operation.RENEW, "alice", wallet, 2, CLAIM_DURATION); + bytes memory signature = _sign(voucher); + _fundAndApprove(wallet, voucher.maxPrice); + + vm.prank(wallet); + policy.renew("alice", voucher, signature); + + assertEq(policy.paidExpiry(labelhash), previousPaidExpiry + CLAIM_DURATION); + assertTrue(policy.isDosMeEntitled("alice", wallet)); + } + + function test_renewRejectsPreviousOwnerAfterTransfer() external { + _claim("alice", wallet, 1); + IPermissionedRegistry.State memory state = ethRegistry.getState(LibLabel.id("alice")); + address buyer = makeAddr("buyer"); + vm.prank(wallet); + ethRegistry.safeTransferFrom(wallet, buyer, state.tokenId, 1, ""); + + IDosDomainPolicy.Voucher memory voucher = + _voucher(IDosDomainPolicy.Operation.RENEW, "alice", wallet, 2, CLAIM_DURATION); + bytes memory signature = _sign(voucher); + _fundAndApprove(wallet, voucher.maxPrice); + + vm.prank(wallet); + vm.expectRevert( + abi.encodeWithSelector(IDosDomainPolicy.NotCurrentOwner.selector, wallet, buyer) + ); + policy.renew("alice", voucher, signature); + } + + function test_transferredOwnerCanRenewWithAValidVoucher() external { + _claim("alice", wallet, 1); + IPermissionedRegistry.State memory state = ethRegistry.getState(LibLabel.id("alice")); + address buyer = makeAddr("buyerDosWallet"); + vm.prank(wallet); + ethRegistry.safeTransferFrom(wallet, buyer, state.tokenId, 1, ""); + + IDosDomainPolicy.Voucher memory voucher = + _voucher(IDosDomainPolicy.Operation.RENEW, "alice", buyer, 2, CLAIM_DURATION); + bytes memory signature = _sign(voucher); + _fundAndApprove(buyer, voucher.maxPrice); + + assertTrue(policy.isRenewalEligible("alice", buyer)); + vm.prank(buyer); + policy.renew("alice", voucher, signature); + + assertEq(policy.paidExpiry(keccak256(bytes("alice"))), state.expiry + CLAIM_DURATION); + } + + function test_entitlementRevokesForTransferAndAfterGrace() external { + _claim("alice", wallet, 1); + IPermissionedRegistry.State memory state = ethRegistry.getState(LibLabel.id("alice")); + address buyer = makeAddr("buyer"); + vm.prank(wallet); + ethRegistry.safeTransferFrom(wallet, buyer, state.tokenId, 1, ""); + + assertFalse(policy.isDosMeEntitled("alice", wallet)); + assertTrue(policy.isDosMeEntitled("alice", buyer)); + + vm.warp(policy.paidExpiry(keccak256(bytes("alice"))) + GRACE_PERIOD); + assertFalse(policy.isDosMeEntitled("alice", buyer)); + } + + function test_entitlementRemainsDuringGracePeriod() external { + _claim("alice", wallet, 1); + uint64 expiry = policy.paidExpiry(keccak256(bytes("alice"))); + + vm.warp(uint256(expiry) + 1); + + assertTrue(policy.isDosMeEntitled("alice", wallet)); + } + + function test_legacyNameCannotClaimFreeAndCanBeReclaimedAfterGrace() external { + address legacyWallet = makeAddr("legacyDosWallet"); + uint64 expiry = uint64(block.timestamp + 1 days); + _registerLegacy("legacy", legacyWallet, expiry); + vm.warp(uint256(expiry) + GRACE_PERIOD); + + bytes32 labelhash = keccak256(bytes("legacy")); + assertTrue(policy.hasHistoricalClaim(labelhash)); + assertTrue(policy.isReclaimEligible("legacy")); + + address newWallet = makeAddr("newDosWallet"); + IDosDomainPolicy.Voucher memory freeVoucher = _claimVoucher("legacy", newWallet, 1); + bytes memory freeSignature = _sign(freeVoucher); + vm.prank(newWallet); + vm.expectRevert( + abi.encodeWithSelector(IDosDomainPolicy.LabelAlreadyClaimed.selector, labelhash) + ); + policy.commitClaim("legacy", freeVoucher, freeSignature, keccak256("legacy free claim")); + + IDosDomainPolicy.Voucher memory reclaimVoucher = + _voucher(IDosDomainPolicy.Operation.RECLAIM, "legacy", newWallet, 2, CLAIM_DURATION); + bytes memory reclaimSignature = _sign(reclaimVoucher); + bytes32 secret = keccak256("legacy reclaim"); + _fundAndApprove(newWallet, reclaimVoucher.maxPrice); + + vm.prank(newWallet); + policy.commitReclaim("legacy", reclaimVoucher, reclaimSignature, secret); + vm.warp(block.timestamp + registrar.MIN_COMMITMENT_AGE() + 1); + vm.prank(newWallet); + policy.reclaim("legacy", reclaimVoucher, reclaimSignature, secret); + + assertTrue(policy.isDosMeEntitled("legacy", newWallet)); + } + + function test_legacyOwnerRenewsDuringGraceAndInitializesPolicyExpiry() external { + address legacyWallet = makeAddr("legacyDosWallet"); + uint64 expiry = uint64(block.timestamp + 1 days); + _registerLegacy("legacy", legacyWallet, expiry); + vm.warp(uint256(expiry) + 1); + + IDosDomainPolicy.Voucher memory voucher = + _voucher(IDosDomainPolicy.Operation.RENEW, "legacy", legacyWallet, 1, CLAIM_DURATION); + bytes memory signature = _sign(voucher); + _fundAndApprove(legacyWallet, voucher.maxPrice); + + vm.prank(legacyWallet); + policy.renew("legacy", voucher, signature); + + assertEq(policy.paidExpiry(keccak256(bytes("legacy"))), expiry + CLAIM_DURATION); + assertTrue(policy.accountEverClaimed(voucher.accountId)); + assertTrue(policy.isDosMeEntitled("legacy", legacyWallet)); + } + + function test_reclaimAfterGraceRequiresPaidVoucher() external { + _claim("alice", wallet, 1); + vm.warp(policy.paidExpiry(keccak256(bytes("alice"))) + GRACE_PERIOD); + + address newWallet = makeAddr("newDosWallet"); + IDosDomainPolicy.Voucher memory voucher = + _voucher(IDosDomainPolicy.Operation.RECLAIM, "alice", newWallet, 2, CLAIM_DURATION); + bytes32 secret = keccak256("reclaim secret"); + bytes memory signature = _sign(voucher); + _fundAndApprove(newWallet, voucher.maxPrice); + + vm.prank(newWallet); + policy.commitReclaim("alice", voucher, signature, secret); + vm.warp(block.timestamp + registrar.MIN_COMMITMENT_AGE() + 1); + vm.prank(newWallet); + policy.reclaim("alice", voucher, signature, secret); + + assertTrue(policy.isDosMeEntitled("alice", newWallet)); + } + + function test_registrarRejectsDirectRegistration() external { + bytes32 secret = keccak256("bypass secret"); + bytes32 commitment = + registrar.makeCommitment( + "alice", + wallet, + secret, + IRegistry(address(0)), + address(0), + CLAIM_DURATION, + bytes32(0) + ); + vm.prank(wallet); + registrar.commit(commitment); + vm.warp(block.timestamp + registrar.MIN_COMMITMENT_AGE() + 1); + + vm.prank(wallet); + vm.expectRevert( + abi.encodeWithSelector( + DOSPolicyRegistrar.UnauthorizedRegistrationController.selector, + wallet, + address(policy) + ) + ); + registrar.register( + "alice", + wallet, + secret, + IRegistry(address(0)), + address(0), + CLAIM_DURATION, + tokenUSDC, + bytes32(0) + ); + } + + function test_registrarRejectsDirectRenewal() external { + _claim("alice", wallet, 1); + + vm.prank(wallet); + vm.expectRevert( + abi.encodeWithSelector( + DOSPolicyRegistrar.UnauthorizedRegistrationController.selector, + wallet, + address(policy) + ) + ); + registrar.renew("alice", CLAIM_DURATION, tokenUSDC, bytes32(0)); + } + + function test_registryRejectsRegistrationOutsidePolicyRegistrar() external { + vm.expectRevert(); + ethRegistry.register( + "bypass", + wallet, + IRegistry(address(0)), + address(0), + 0, + uint64(block.timestamp + CLAIM_DURATION) + ); + } + + function test_registryRejectsRenewalOutsidePolicyRegistrar() external { + _claim("alice", wallet, 1); + IPermissionedRegistry.State memory state = ethRegistry.getState(LibLabel.id("alice")); + + vm.expectRevert(); + ethRegistry.renew(state.tokenId, state.expiry + CLAIM_DURATION); + } + + function _claim(string memory label, address owner, uint256 nonce) internal { + IDosDomainPolicy.Voucher memory voucher = _claimVoucher(label, owner, nonce); + bytes32 secret = keccak256(abi.encodePacked(label, owner, nonce)); + bytes memory signature = _sign(voucher); + + vm.prank(owner); + policy.commitClaim(label, voucher, signature, secret); + vm.warp(block.timestamp + registrar.MIN_COMMITMENT_AGE() + 1); + vm.prank(owner); + policy.claim(label, voucher, signature, secret); + } + + function _voucher( + IDosDomainPolicy.Operation operation, + string memory label, + address owner, + uint256 nonce, + uint64 duration + ) + internal + view + returns (IDosDomainPolicy.Voucher memory voucher) + { + (, , uint256 total) = policy.quote(operation, label, duration, tokenUSDC); + return + IDosDomainPolicy.Voucher({operation: operation, wallet: owner, accountId: _accountId( + owner + ), labelhash: keccak256(bytes(label)), duration: duration, paymentToken: tokenUSDC, maxPrice: total, nonce: nonce, deadline: uint64( + block.timestamp + 1 days + )}); + } + + function _fundAndApprove(address owner, uint256 amount) internal { + tokenUSDC.mint(owner, amount); + vm.prank(owner); + tokenUSDC.approve(address(policy), amount); + } + + function _claimVoucher(string memory label, address owner, uint256 nonce) + internal + view + returns (IDosDomainPolicy.Voucher memory voucher) + { + return _voucher(IDosDomainPolicy.Operation.CLAIM, label, owner, nonce, CLAIM_DURATION); + } + + function _registerLegacy(string memory label, address owner, uint64 expiry) internal { + uint256 roles = RegistryRolesLib.ROLE_REGISTRAR | RegistryRolesLib.ROLE_RENEW; + ethRegistry.grantRootRoles(roles, address(this)); + ethRegistry.register(label, owner, IRegistry(address(0)), address(0), 0, expiry); + ethRegistry.revokeRootRoles(roles, address(this)); + } + + function _accountId(address owner) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("dos-me-account", owner)); + } + + function _sign(IDosDomainPolicy.Voucher memory voucher) + internal + view + returns (bytes memory signature) + { + bytes32 digest = policy.hashVoucher(voucher); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(VOUCHER_SIGNER_KEY, digest); + return abi.encodePacked(r, s, v); + } +} diff --git a/subgraph/dos-names/package.json b/subgraph/dos-names/package.json index 439ab45e..45c978c0 100644 --- a/subgraph/dos-names/package.json +++ b/subgraph/dos-names/package.json @@ -10,7 +10,7 @@ "render:test": "node scripts/render-manifest.mjs tests/fixtures/dos-testnet-3939.json subgraph.template.yaml subgraph.yaml", "render:deployment": "node scripts/render-manifest.mjs ../../contracts/deployments/dos-testnet-3939.json subgraph.template.yaml subgraph.yaml", "render:mainnet": "node scripts/render-manifest.mjs ../../contracts/deployments/dos-mainnet-7979.json subgraph.template.yaml subgraph.yaml", - "test": "graph test", + "test": "graph test --version 0.5.2", "test:render": "node --test tests/render-manifest.test.mjs" }, "devDependencies": { diff --git a/subgraph/dos-names/schema.graphql b/subgraph/dos-names/schema.graphql index 6905cfb0..109d6427 100644 --- a/subgraph/dos-names/schema.graphql +++ b/subgraph/dos-names/schema.graphql @@ -354,3 +354,9 @@ type ResolverSource @entity { id: ID! address: Bytes! } + +"Tracks registrar addresses already registered as dynamic data sources" +type RegistrarSource @entity { + id: ID! + address: Bytes! +} diff --git a/subgraph/dos-names/src/registry.ts b/subgraph/dos-names/src/registry.ts index 4b8d82e9..bfa8387a 100644 --- a/subgraph/dos-names/src/registry.ts +++ b/subgraph/dos-names/src/registry.ts @@ -32,6 +32,7 @@ import { NewOwner, NewResolver, Registration, + RegistrarSource, Resolver, ResolverSource, TokenToDomain, @@ -39,7 +40,7 @@ import { WrappedDomain, WrappedTransfer, } from "./types/schema"; -import { ResolverTemplate } from "./types/templates"; +import { RegistrarTemplate, ResolverTemplate } from "./types/templates"; import { updateSubregistry } from "./subregistry"; import { @@ -82,6 +83,8 @@ export function handleLabelRegistered(event: LabelRegisteredEvent): void { return; } + discoverRegistrar(event.params.sender); + // Compute the domain node (namehash) = keccak256(dosNode || labelHash) let node = crypto.keccak256(concat(dosNode, labelHash)).toHexString(); @@ -167,6 +170,19 @@ export function handleLabelRegistered(event: LabelRegisteredEvent): void { domainEvent.save(); } +/** Adds each registrar only once so its pricing and renewal events are indexed. */ +function discoverRegistrar(registrarAddress: Address): void { + let sourceId = registrarAddress.toHexString(); + if (RegistrarSource.load(sourceId) !== null) { + return; + } + + let source = new RegistrarSource(sourceId); + source.address = registrarAddress; + source.save(); + RegistrarTemplate.create(registrarAddress); +} + /** * ResolverUpdated(uint256 tokenId, address resolver, address sender) */ diff --git a/subgraph/dos-names/subgraph.template.yaml b/subgraph/dos-names/subgraph.template.yaml index c8aa31f1..f45ce77a 100644 --- a/subgraph/dos-names/subgraph.template.yaml +++ b/subgraph/dos-names/subgraph.template.yaml @@ -24,6 +24,7 @@ dataSources: - RegistryChild - RegistryChildToken - RegistrySource + - RegistrarSource abis: - name: PermissionedRegistry file: ./abis/PermissionedRegistry.json @@ -189,3 +190,29 @@ templates: handler: handlePubkeyChanged - event: VersionChanged(indexed bytes32,uint64) handler: handleVersionChanged + + # Registrars are discovered from PermissionedRegistry.LabelRegistered.sender. + # Registry emits that event before the registrar's NameRegistered event, so the + # template captures fee and renewal metadata for the same registration transaction. + - kind: ethereum/contract + name: RegistrarTemplate + network: __NETWORK__ + source: + abi: DOSRegistrar + mapping: + kind: ethereum/events + apiVersion: 0.0.7 + language: wasm/assemblyscript + file: ./src/registrar.ts + entities: + - Registration + - NameRegistered + - NameRenewed + abis: + - name: DOSRegistrar + file: ./abis/DOSRegistrar.json + eventHandlers: + - event: NameRegistered(indexed uint256,string,address,address,address,uint64,address,indexed bytes32,uint256,uint256) + handler: handleNameRegisteredByRegistrar + - event: NameRenewed(indexed uint256,string,uint64,uint64,address,indexed bytes32,uint256) + handler: handleNameRenewedByRegistrar diff --git a/subgraph/dos-names/tests/registry.test.ts b/subgraph/dos-names/tests/registry.test.ts index c32c84f6..fcca3017 100644 --- a/subgraph/dos-names/tests/registry.test.ts +++ b/subgraph/dos-names/tests/registry.test.ts @@ -664,6 +664,14 @@ test("token regeneration preserves the canonical domain mapping", () => { ); }); +test("registrar discovery creates one source for repeated registrations", () => { + handleLabelRegistered(registration(123)); + handleLabelRegistered(registration(456)); + + assert.entityCount("RegistrarSource", 1); + assert.fieldEquals("RegistrarSource", OWNER, "address", OWNER); +}); + test("zero-value transfers do not change top-level ownership", () => { handleLabelRegistered(registration(123));