diff --git a/src/L2/RegistrarController.sol b/src/L2/RegistrarController.sol index 4c35c9b5..ab8a3ce0 100644 --- a/src/L2/RegistrarController.sol +++ b/src/L2/RegistrarController.sol @@ -9,7 +9,7 @@ import {StringUtils} from "ens-contracts/ethregistrar/StringUtils.sol"; import {BASE_ETH_NODE, GRACE_PERIOD} from "src/util/Constants.sol"; import {BaseRegistrar} from "./BaseRegistrar.sol"; -import {IDiscountValidator} from "./interface/IDiscountValidator.sol"; +import {DiscountValidator} from "./discounts/DiscountValidator.sol"; import {IPriceOracle} from "./interface/IPriceOracle.sol"; import {L2Resolver} from "./L2Resolver.sol"; import {IReverseRegistrar} from "./interface/IReverseRegistrar.sol"; @@ -132,12 +132,6 @@ contract RegistrarController is Ownable { /// @notice Thrown when the payment received is less than the price. error InsufficientValue(); - /// @notice Thrown when the specified discount's validator does not accept the discount for the sender. - /// - /// @param key The discount being accessed. - /// @param data The associated `validationData`. - error InvalidDiscount(bytes32 key, bytes data); - /// @notice Thrown when the discount amount is 0. /// /// @param key The discount being set. @@ -232,25 +226,11 @@ contract RegistrarController is Ownable { _; } - /// @notice Decorator for validating discounted registrations. - /// - /// @dev Validates that: - /// 1. That the registrant has not already registered with a discount - /// 2. That the discount is `active` - /// 3. That the associated `discountValidator` returns true when `isValidDiscountRegistration` is called. + /// @notice Decorator for validating a user for discounted registration. /// - /// @param discountKey The uuid of the discount. - /// @param validationData The associated validation data for this discount registration. - modifier validDiscount(bytes32 discountKey, bytes calldata validationData) { + /// @dev Validates that that the registrant has not already registered with a discount + modifier discountAvailable() { if (discountedRegistrants[msg.sender]) revert AlreadyRegisteredWithDiscount(msg.sender); - DiscountDetails memory details = discounts[discountKey]; - - if (!details.active) revert InactiveDiscount(discountKey); - - IDiscountValidator validator = IDiscountValidator(details.discountValidator); - if (!validator.isValidDiscountRegistration(msg.sender, validationData)) { - revert InvalidDiscount(discountKey, validationData); - } _; } @@ -459,9 +439,11 @@ contract RegistrarController is Ownable { function discountedRegister(RegisterRequest calldata request, bytes32 discountKey, bytes calldata validationData) public payable - validDiscount(discountKey, validationData) validRegistration(request) + discountAvailable { + _validateDiscount(discountKey, validationData); + uint256 price = discountedRegisterPrice(request.name, request.duration, discountKey); _validatePayment(price); @@ -593,6 +575,20 @@ contract RegistrarController is Ownable { active ? activeDiscounts.add(key) : activeDiscounts.remove(key); } + /// @notice Calls the associated discount validator with `msg.sender` and `validationData`. + /// + /// @dev This method calls `validateDiscountRegistration` which may revert with `DiscountValidator.InvalidDiscount`. + /// + /// @param discountKey unique identifier for the discount. + /// @param validationData validation data required for discount. + function _validateDiscount(bytes32 discountKey, bytes calldata validationData) internal { + DiscountDetails memory details = discounts[discountKey]; + if(!details.active) revert InactiveDiscount(discountKey); + + DiscountValidator validator = DiscountValidator(details.discountValidator); + validator.validateDiscountRegistration(msg.sender, validationData); + } + /// @notice Allows anyone to withdraw the eth accumulated on this contract back to the `paymentReceiver`. function withdrawETH() public { (bool sent,) = payable(paymentReceiver).call{value: (address(this).balance)}(""); diff --git a/src/L2/discounts/AttestationValidator.sol b/src/L2/discounts/AttestationValidator.sol index b841c9b5..8c66c67b 100644 --- a/src/L2/discounts/AttestationValidator.sol +++ b/src/L2/discounts/AttestationValidator.sol @@ -6,7 +6,7 @@ import {AttestationVerifier} from "verifications/libraries/AttestationVerifier.s import {IAttestationIndexer} from "verifications/interfaces/IAttestationIndexer.sol"; import {Ownable} from "solady/auth/Ownable.sol"; -import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; +import {DiscountValidator} from "./DiscountValidator.sol"; import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol"; /// @title Discount Validator for: Coinbase Attestation Validator @@ -17,7 +17,7 @@ import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol"; /// https://github.com/coinbase/verifications /// /// @author Coinbase (https://github.com/base-org/usernames) -contract AttestationValidator is Ownable, AttestationAccessControl, IDiscountValidator { +contract AttestationValidator is Ownable, AttestationAccessControl, DiscountValidator { /// @dev The attestation service signer. address signer; @@ -52,7 +52,7 @@ contract AttestationValidator is Ownable, AttestationAccessControl, IDiscountVal /// @param validationData opaque bytes for performing the validation. /// /// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`. - function isValidDiscountRegistration(address claimer, bytes calldata validationData) external view returns (bool) { + function isValidDiscountRegistration(address claimer, bytes calldata validationData) public override view returns (bool) { AttestationVerifier.verifyAttestation(_getAttestation(claimer, schemaID)); return SybilResistanceVerifier.verifySignature(signer, claimer, validationData); diff --git a/src/L2/discounts/CBIdDiscountValidator.sol b/src/L2/discounts/CBIdDiscountValidator.sol index f5a8a160..3c5b14b3 100644 --- a/src/L2/discounts/CBIdDiscountValidator.sol +++ b/src/L2/discounts/CBIdDiscountValidator.sol @@ -4,14 +4,14 @@ pragma solidity ^0.8.23; import {MerkleProofLib} from "lib/solady/src/utils/MerkleProofLib.sol"; import {Ownable} from "solady/auth/Ownable.sol"; -import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; +import {DiscountValidator} from "./DiscountValidator.sol"; /// @title Discount Validator for: cb.id /// /// @notice Implements a simple Merkle Proof validator checking that the claimant is in the stored merkle tree. /// /// @author Coinbase -contract CBIdDiscountValidator is Ownable, IDiscountValidator { +contract CBIdDiscountValidator is Ownable, DiscountValidator { /// @dev merkle tree root bytes32 public root; @@ -35,7 +35,7 @@ contract CBIdDiscountValidator is Ownable, IDiscountValidator { /// @param validationData opaque bytes for performing the validation. /// /// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`. - function isValidDiscountRegistration(address claimer, bytes calldata validationData) external view returns (bool) { + function isValidDiscountRegistration(address claimer, bytes calldata validationData) public view override returns (bool) { (bytes32[] memory proof) = abi.decode(validationData, (bytes32[])); return MerkleProofLib.verify(proof, root, keccak256(abi.encodePacked(claimer))); } diff --git a/src/L2/discounts/CouponDiscountValidator.sol b/src/L2/discounts/CouponDiscountValidator.sol index 85a96338..24c1f0f7 100644 --- a/src/L2/discounts/CouponDiscountValidator.sol +++ b/src/L2/discounts/CouponDiscountValidator.sol @@ -4,14 +4,14 @@ pragma solidity ^0.8.23; import {ECDSA} from "solady/utils/ECDSA.sol"; import {Ownable} from "solady/auth/Ownable.sol"; -import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; +import {DiscountValidator} from "./DiscountValidator.sol"; /// @title Discount Validator for: Coupons /// /// @notice Implements a signature-based discount validation on unique coupon codes. /// /// @author Coinbase (https://github.com/base-org/usernames) -contract CouponDiscountValidator is Ownable, IDiscountValidator { +contract CouponDiscountValidator is Ownable, DiscountValidator { /// @notice Thrown when setting a critical address to the zero-address. error NoZeroAddress(); @@ -46,7 +46,7 @@ contract CouponDiscountValidator is Ownable, IDiscountValidator { /// @param validationData opaque bytes for performing the validation. /// /// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`. - function isValidDiscountRegistration(address claimer, bytes calldata validationData) external view returns (bool) { + function isValidDiscountRegistration(address claimer, bytes calldata validationData) public view override returns (bool) { (uint64 expiry, bytes32 uuid, bytes memory sig) = abi.decode(validationData, (uint64, bytes32, bytes)); if (expiry < block.timestamp) revert SignatureExpired(); diff --git a/src/L2/discounts/DiscountValidator.sol b/src/L2/discounts/DiscountValidator.sol new file mode 100644 index 00000000..1dd4d6bb --- /dev/null +++ b/src/L2/discounts/DiscountValidator.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +/// @title Discount Validato +/// +/// @notice Discount Validator base contract which must be inherited by implementing validators. +/// The logic specific to each integration must ultimately be consumable as: +/// 1. A `bool` returned from `isValidDiscountRegistration` for offchain pre-tx validation, and +/// 2. A call to `validateDiscountRegistration` which will revert if validation fails +abstract contract DiscountValidator { + /// @notice Thrown when the specified discount's validator does not accept the discount for the sender. + /// + /// @param claimer The address of the claiming user. + /// @param data The associated `validationData`. + error InvalidDiscount(address claimer, bytes data); + + /// @notice Required implementation for compatibility with DiscountValidator. + /// + /// @dev Each implementation will have unique requirements for the data necessary to perform + /// a meaningul validation. Implementations must describe here how to pack relevant `validationData`. + /// Ex: `bytes validationData = abi.encode(bytes32 key, bytes32[] proof)` + /// + /// @param claimer the discount claimer's address. + /// @param validationData opaque bytes for performing the validation. + /// + /// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`. + function isValidDiscountRegistration(address claimer, bytes calldata validationData) public virtual view returns (bool); + + + /// @notice Required implementation for compaibility with DiscountValidator. + /// + /// @dev This method reverts with `InvalidDiscount` if called with for an invalid combination of `claimer` and `validationData`. + /// By default, it simply calls `isValidDiscountRegistration`. If more sophisticated state tracking is required, overwrite this + /// method. Overwriten methods MUST still revert with `InvalidDiscount` should the data fail the validation step. + /// + /// @param claimer the discount claimer's address. + /// @param validationData opaque bytes for performing the validation. + function validateDiscountRegistration(address claimer, bytes calldata validationData) external virtual { + if(!isValidDiscountRegistration(claimer, validationData)) revert InvalidDiscount(claimer, validationData); + } +} diff --git a/src/L2/discounts/ERC1155DiscountValidator.sol b/src/L2/discounts/ERC1155DiscountValidator.sol index 5deeb122..a097be7e 100644 --- a/src/L2/discounts/ERC1155DiscountValidator.sol +++ b/src/L2/discounts/ERC1155DiscountValidator.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.23; import {IERC1155} from "@openzeppelin/contracts/token/ERC1155/IERC1155.sol"; -import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; +import {DiscountValidator} from "./DiscountValidator.sol"; /// @title Discount Validator for: ERC1155 NFTs /// @@ -11,7 +11,7 @@ import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; /// This discount validator should only be used for "soul-bound" tokens. /// /// @author Coinbase (https://github.com/base-org/usernames) -contract ERC1155DiscountValidator is IDiscountValidator { +contract ERC1155DiscountValidator is DiscountValidator { /// @notice The ERC1155 token contract to validate against. IERC1155 immutable token; @@ -35,7 +35,7 @@ contract ERC1155DiscountValidator is IDiscountValidator { /// @param claimer the discount claimer's address. /// /// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`. - function isValidDiscountRegistration(address claimer, bytes calldata) external view returns (bool) { + function isValidDiscountRegistration(address claimer, bytes calldata) public view override returns (bool) { return (token.balanceOf(claimer, tokenId) > 0); } } diff --git a/src/L2/discounts/ERC721DiscountValidator.sol b/src/L2/discounts/ERC721DiscountValidator.sol index baea7c87..58179cf3 100644 --- a/src/L2/discounts/ERC721DiscountValidator.sol +++ b/src/L2/discounts/ERC721DiscountValidator.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.23; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; -import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; +import {DiscountValidator} from "./DiscountValidator.sol"; /// @title Discount Validator for: ERC721 NFTs /// @@ -11,7 +11,7 @@ import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; /// This discount validator should only be used for "soul-bound" tokens. /// /// @author Coinbase (https://github.com/base-org/usernames) -contract ERC721DiscountValidator is IDiscountValidator { +contract ERC721DiscountValidator is DiscountValidator { /// @notice The ERC721 token contract to validate against. IERC721 immutable token; @@ -30,7 +30,7 @@ contract ERC721DiscountValidator is IDiscountValidator { /// @param claimer the discount claimer's address. /// /// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`. - function isValidDiscountRegistration(address claimer, bytes calldata) external view returns (bool) { + function isValidDiscountRegistration(address claimer, bytes calldata) public view override returns (bool) { return (token.balanceOf(claimer) > 0); } } diff --git a/src/L2/discounts/TalentProtocolDiscountValidator.sol b/src/L2/discounts/TalentProtocolDiscountValidator.sol index 06679891..8d405b6c 100644 --- a/src/L2/discounts/TalentProtocolDiscountValidator.sol +++ b/src/L2/discounts/TalentProtocolDiscountValidator.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.23; import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; import {Ownable} from "solady/auth/Ownable.sol"; -import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; +import {DiscountValidator} from "./DiscountValidator.sol"; /// @title Discount Validator for: Talent Protocol Builder Score /// @@ -12,7 +12,7 @@ import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; /// Discounts are granted based on the claimer having some score higher than this contract's `threshold`. /// /// @author Coinbase (https://github.com/base-org/usernames) -contract TalentProtocolDiscountValidator is IDiscountValidator, Ownable { +contract TalentProtocolDiscountValidator is DiscountValidator, Ownable { /// @notice Thrown when setting a critical address to the zero-address. error NoZeroAddress(); @@ -54,7 +54,7 @@ contract TalentProtocolDiscountValidator is IDiscountValidator, Ownable { /// @param claimer the discount claimer's address. /// /// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`. - function isValidDiscountRegistration(address claimer, bytes calldata) external view returns (bool) { + function isValidDiscountRegistration(address claimer, bytes calldata) public view override returns (bool) { return (talentProtocol.getScoreByAddress(claimer) >= threshold); } } diff --git a/test/RegistrarController/DiscountedRegister.t.sol b/test/RegistrarController/DiscountedRegister.t.sol index b4498d6e..031330e2 100644 --- a/test/RegistrarController/DiscountedRegister.t.sol +++ b/test/RegistrarController/DiscountedRegister.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.23; +import {DiscountValidator} from "src/L2/discounts/DiscountValidator.sol"; import {RegistrarControllerBase} from "./RegistrarControllerBase.t.sol"; import {RegistrarController} from "src/L2/RegistrarController.sol"; import {IPriceOracle} from "src/L2/interface/IPriceOracle.sol"; @@ -11,6 +12,7 @@ contract DiscountedRegister is RegistrarControllerBase { vm.deal(user, 1 ether); inactiveDiscount.active = false; + base.setAvailable(uint256(nameLabel), true); vm.prank(owner); controller.setDiscountDetails(inactiveDiscount); uint256 price = controller.discountedRegisterPrice(name, duration, discountKey); @@ -26,8 +28,9 @@ contract DiscountedRegister is RegistrarControllerBase { controller.setDiscountDetails(_getDefaultDiscount()); validator.setReturnValue(false); uint256 price = controller.discountedRegisterPrice(name, duration, discountKey); + base.setAvailable(uint256(nameLabel), true); - vm.expectRevert(abi.encodeWithSelector(RegistrarController.InvalidDiscount.selector, discountKey, "")); + vm.expectRevert(abi.encodeWithSelector(DiscountValidator.InvalidDiscount.selector, user, "")); vm.prank(user); controller.discountedRegister{value: price}(_getDefaultRegisterRequest(), discountKey, ""); } @@ -136,8 +139,9 @@ contract DiscountedRegister is RegistrarControllerBase { vm.prank(user); controller.discountedRegister{value: price}(request, discountKey, ""); - vm.expectRevert(abi.encodeWithSelector(RegistrarController.AlreadyRegisteredWithDiscount.selector, user)); request.name = "newname"; + base.setAvailable(uint256(keccak256(bytes(request.name))),true); + vm.expectRevert(abi.encodeWithSelector(RegistrarController.AlreadyRegisteredWithDiscount.selector, user)); vm.prank(user); controller.discountedRegister{value: price}(request, discountKey, ""); } diff --git a/test/mocks/MockDiscountValidator.sol b/test/mocks/MockDiscountValidator.sol index be7de9a1..1d7432b7 100644 --- a/test/mocks/MockDiscountValidator.sol +++ b/test/mocks/MockDiscountValidator.sol @@ -1,12 +1,12 @@ //SPDX-License-Identifier: MIT pragma solidity ^0.8.23; -import "src/L2/interface/IDiscountValidator.sol"; +import "src/L2/discounts/DiscountValidator.sol"; -contract MockDiscountValidator is IDiscountValidator { +contract MockDiscountValidator is DiscountValidator { bool returnValue = true; - function isValidDiscountRegistration(address, bytes calldata) external view returns (bool) { + function isValidDiscountRegistration(address, bytes calldata) public view override returns (bool) { return returnValue; }