From edbe86f1e47126432189a5a01ea0c209c25da18c Mon Sep 17 00:00:00 2001 From: katzman Date: Thu, 17 Oct 2024 14:12:14 -0700 Subject: [PATCH] Add coupon discount validator (#96) * lint * Remove unused state var * Add unit tests, add address as salt to signature hash * lint * Fix natspec * Add zero address checks * Remove redundant owner check --- src/L2/discounts/CouponDiscountValidator.sol | 70 +++++++++++++++++++ .../CouponDiscountValidatorBase.t.sol | 36 ++++++++++ .../IsValidDiscountRegistration.t.sol | 30 ++++++++ .../CouponDiscountValidator/SetSigner.t.sol | 20 ++++++ 4 files changed, 156 insertions(+) create mode 100644 src/L2/discounts/CouponDiscountValidator.sol create mode 100644 test/discounts/CouponDiscountValidator/CouponDiscountValidatorBase.t.sol create mode 100644 test/discounts/CouponDiscountValidator/IsValidDiscountRegistration.t.sol create mode 100644 test/discounts/CouponDiscountValidator/SetSigner.t.sol diff --git a/src/L2/discounts/CouponDiscountValidator.sol b/src/L2/discounts/CouponDiscountValidator.sol new file mode 100644 index 00000000..85a96338 --- /dev/null +++ b/src/L2/discounts/CouponDiscountValidator.sol @@ -0,0 +1,70 @@ +//SPDX-License-Identifier: MIT +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"; + +/// @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 { + /// @notice Thrown when setting a critical address to the zero-address. + error NoZeroAddress(); + + /// @dev The coupon service signer. + address signer; + + /// @notice Thrown when the signature expiry date < block.timestamp. + error SignatureExpired(); + + /// @notice Attestation Validator constructor + /// + /// @param owner_ The permissioned `owner` in the `Ownable` context. + /// @param signer_ The off-chain signer of the Coinbase sybil resistance service. + constructor(address owner_, address signer_) { + if (signer_ == address(0)) revert NoZeroAddress(); + _initializeOwner(owner_); + signer = signer_; + } + + /// @notice Allows the owner to update the expected signer. + /// + /// @param signer_ The address of the new signer. + function setSigner(address signer_) external onlyOwner { + if (signer_ == address(0)) revert NoZeroAddress(); + signer = signer_; + } + + /// @notice Required implementation for compatibility with IDiscountValidator. + /// + /// @dev The data must be encoded as `abi.encode(discountClaimerAddress, expiry, signature_bytes)`. + /// + /// @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) { + (uint64 expiry, bytes32 uuid, bytes memory sig) = abi.decode(validationData, (uint64, bytes32, bytes)); + if (expiry < block.timestamp) revert SignatureExpired(); + + address returnedSigner = ECDSA.recover(_makeSignatureHash(claimer, uuid, expiry), sig); + return returnedSigner == signer; + } + + /// @notice Generates a hash for signing/verifying. + /// + /// @dev The message hash should be dervied by: `keccak256(abi.encode(0x1900, trustedSignerAddress, discountClaimerAddress, couponUui, claimsPerUuid, expiry, salt))`. + /// Compliant with EIP-191 for `Data for intended validator`: https://eips.ethereum.org/EIPS/eip-191#version-0x00 . + /// + /// @param claimer Address of the coupon claimer. + /// @param couponUuid The Uuid of the coupon. + /// @param expires The date of the signature expiry. + /// + /// @return The EIP-191 compliant signature hash. + function _makeSignatureHash(address claimer, bytes32 couponUuid, uint64 expires) internal view returns (bytes32) { + return keccak256(abi.encodePacked(hex"1900", address(this), signer, claimer, couponUuid, expires)); + } +} diff --git a/test/discounts/CouponDiscountValidator/CouponDiscountValidatorBase.t.sol b/test/discounts/CouponDiscountValidator/CouponDiscountValidatorBase.t.sol new file mode 100644 index 00000000..dee171f2 --- /dev/null +++ b/test/discounts/CouponDiscountValidator/CouponDiscountValidatorBase.t.sol @@ -0,0 +1,36 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {Test} from "forge-std/Test.sol"; +import {CouponDiscountValidator} from "src/L2/discounts/CouponDiscountValidator.sol"; + +contract CouponDiscountValidatorBase is Test { + CouponDiscountValidator validator; + + address public owner = makeAddr("owner"); + address public user = makeAddr("user"); + address public signer; + uint256 public signerPk; + bytes32 uuid; + + uint64 time = 1717200000; + uint64 expires = 1893456000; + + function setUp() public { + vm.warp(time); + (signer, signerPk) = makeAddrAndKey("signer"); + uuid = keccak256("test_coupon"); + validator = new CouponDiscountValidator(owner, signer); + } + + function _getDefaultValidationData() internal virtual returns (bytes memory) { + bytes32 digest = _makeSignatureHash(user, uuid, expires); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest); + bytes memory sig = abi.encodePacked(r, s, v); + return abi.encode(expires, uuid, sig); + } + + function _makeSignatureHash(address claimer, bytes32 couponUuid, uint64 _expires) internal view returns (bytes32) { + return keccak256(abi.encodePacked(hex"1900", address(validator), signer, claimer, couponUuid, _expires)); + } +} diff --git a/test/discounts/CouponDiscountValidator/IsValidDiscountRegistration.t.sol b/test/discounts/CouponDiscountValidator/IsValidDiscountRegistration.t.sol new file mode 100644 index 00000000..dead5279 --- /dev/null +++ b/test/discounts/CouponDiscountValidator/IsValidDiscountRegistration.t.sol @@ -0,0 +1,30 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {CouponDiscountValidator} from "src/L2/discounts/CouponDiscountValidator.sol"; +import {CouponDiscountValidatorBase} from "./CouponDiscountValidatorBase.t.sol"; + +contract IsValidDiscountRegistration is CouponDiscountValidatorBase { + function test_reverts_whenTheSignatureIsExpired() public { + bytes memory validationData = _getDefaultValidationData(); + (, bytes32 _uuid, bytes memory sig) = abi.decode(validationData, (uint64, bytes32, bytes)); + bytes memory expiredSignatureData = abi.encode((block.timestamp - 1), _uuid, sig); + + vm.expectRevert(abi.encodeWithSelector(CouponDiscountValidator.SignatureExpired.selector)); + validator.isValidDiscountRegistration(user, expiredSignatureData); + } + + function test_returnsFalse_whenTheExpectedSignerMismatches(uint256 pk) public view { + vm.assume(pk != signerPk && pk != 0 && pk < type(uint128).max); + bytes32 digest = _makeSignatureHash(user, uuid, expires); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + bytes memory sig = abi.encodePacked(r, s, v); + bytes memory badSignerValidationData = abi.encode(expires, uuid, sig); + + assertFalse(validator.isValidDiscountRegistration(user, badSignerValidationData)); + } + + function test_returnsTrue_whenEverythingIsHappy() public { + assertTrue(validator.isValidDiscountRegistration(user, _getDefaultValidationData())); + } +} diff --git a/test/discounts/CouponDiscountValidator/SetSigner.t.sol b/test/discounts/CouponDiscountValidator/SetSigner.t.sol new file mode 100644 index 00000000..6e3304e4 --- /dev/null +++ b/test/discounts/CouponDiscountValidator/SetSigner.t.sol @@ -0,0 +1,20 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +import {CouponDiscountValidatorBase} from "./CouponDiscountValidatorBase.t.sol"; +import {Ownable} from "solady/auth/Ownable.sol"; + +contract SetSigner is CouponDiscountValidatorBase { + function test_reverts_whenCalledByNonOwner(address caller) public { + vm.assume(caller != owner && caller != address(0)); + vm.expectRevert(Ownable.Unauthorized.selector); + vm.prank(caller); + validator.setSigner(caller); + } + + function test_allowsTheOwner_toUpdateTheSigner() public { + vm.prank(owner); + address newSigner = makeAddr("new"); + validator.setSigner(newSigner); + } +}