Skip to content

Commit

Permalink
Add coupon discount validator (#96)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
stevieraykatz authored Oct 17, 2024
1 parent b2f909f commit edbe86f
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 0 deletions.
70 changes: 70 additions & 0 deletions src/L2/discounts/CouponDiscountValidator.sol
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Original file line number Diff line number Diff line change
@@ -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()));
}
}
20 changes: 20 additions & 0 deletions test/discounts/CouponDiscountValidator/SetSigner.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}

0 comments on commit edbe86f

Please sign in to comment.