Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive
fetch-depth: 0

- name: Set up Python
uses: actions/setup-python@v5
Expand All @@ -44,7 +45,42 @@ jobs:
forge test -vvv --ffi
id: test

- name: Check formatting
- name: Get changed Solidity files
id: changed-files
run: |
forge fmt --check
if [ "${{ github.event_name }}" == "pull_request" ]; then
BASE_COMMIT=${{ github.event.pull_request.base.sha }}
HEAD_COMMIT=${{ github.event.pull_request.head.sha }}
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR $BASE_COMMIT..$HEAD_COMMIT | grep '\.sol$' || true)
else
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR HEAD^..HEAD | grep '\.sol$' || true)
fi

# Store files in a way that GitHub Actions can handle properly
if [ -n "$CHANGED_FILES" ]; then
echo "has_files=true" >> $GITHUB_OUTPUT
# Save files to a temporary file for the next step
echo "$CHANGED_FILES" > /tmp/changed_files.txt
echo "Changed Solidity files:"
echo "$CHANGED_FILES"
else
echo "has_files=false" >> $GITHUB_OUTPUT
echo "No Solidity files changed"
fi

- name: Check formatting on changed files
if: steps.changed-files.outputs.has_files == 'true'
run: |
# Read files from the temporary file created in the previous step
if [ -f /tmp/changed_files.txt ]; then
echo "Checking formatting for changed Solidity files..."
while IFS= read -r file; do
if [ -n "$file" ]; then
echo "Checking: $file"
forge fmt --check "$file"
fi
done < /tmp/changed_files.txt
else
echo "No changed files found"
fi
id: fmt
52 changes: 52 additions & 0 deletions src/L2/discounts/SignatureDiscountValidator.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {Ownable} from "solady/auth/Ownable.sol";

import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol";
import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol";

/// @title Discount Validator for: Signature Discount Validator
///
/// @notice Implements a simple signature validation schema which performs signature verification to validate
/// signatures were generated from the Base Signer Service.
///
/// @author Coinbase (https://github.com/base-org/basenames)
contract SignatureDiscountValidator is Ownable, IDiscountValidator {
/// @dev The Base Signer Service signer address.
address signer;

/// @dev Thrown when setting the zero address as `owner` or `signer`.
error NoZeroAddress();

/// @notice constructor
///
/// @param owner_ The permissioned `owner` in the `Ownable` context.
/// @param signer_ The off-chain signer of the Base Signer Service.
constructor(address owner_, address signer_) {
if (owner_ == address(0)) revert NoZeroAddress();
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 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) external view returns (bool) {
return SybilResistanceVerifier.verifySignature(signer, claimer, validationData);
}
}
5 changes: 3 additions & 2 deletions test/Fork/AbstractForkSuite.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {BASE_ETH_NODE} from "src/util/Constants.sol";

abstract contract AbstractForkSuite is Test {
// Network configuration hooks
function forkAlias() internal pure virtual returns (string memory);
function forkAlias() internal pure virtual returns (string memory, uint256);

function registry() internal pure virtual returns (address);
function baseRegistrar() internal pure virtual returns (address);
Expand Down Expand Up @@ -55,7 +55,8 @@ abstract contract AbstractForkSuite is Test {
address internal MIGRATION_CONTROLLER;

function setUp() public virtual {
vm.createSelectFork(forkAlias());
(string memory forkUrl, uint256 blockNumber) = forkAlias();
vm.createSelectFork(forkUrl, blockNumber);

// Bind constants
REGISTRY = registry();
Expand Down
4 changes: 2 additions & 2 deletions test/Fork/BaseMainnetConfig.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {AbstractForkSuite} from "./AbstractForkSuite.t.sol";
import {BaseMainnet as C} from "./BaseMainnetConstants.sol";

abstract contract BaseMainnetConfig is AbstractForkSuite {
function forkAlias() internal pure override returns (string memory) {
return "base-mainnet";
function forkAlias() internal pure override returns (string memory, uint256) {
return ("base-mainnet", 35_370_443); // Last ENSIP-19 setup config was run here: https://basescan.org/block/35370442. Increment one block.
}

function registry() internal pure override returns (address) {
Expand Down
4 changes: 2 additions & 2 deletions test/Fork/BaseSepoliaConfig.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {AbstractForkSuite} from "./AbstractForkSuite.t.sol";
import {BaseSepolia as C} from "./BaseSepoliaConstants.sol";

abstract contract BaseSepoliaConfig is AbstractForkSuite {
function forkAlias() internal pure override returns (string memory) {
return "base-sepolia";
function forkAlias() internal pure override returns (string memory, uint256) {
return ("base-sepolia", 30_967_867); // Last ENSIP-19 setup config was run here: https://sepolia.basescan.org/block/30967866. Incremement one block.
}

function registry() internal pure override returns (address) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {SignatureDiscountValidatorBase} from "./SignatureDiscountValidatorBase.t.sol";
import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol";

contract IsValidDiscountRegistration is SignatureDiscountValidatorBase {
function test_reverts_whenTheValidationData_claimerAddressMismatch(address notUser) public {
vm.assume(notUser != user && notUser != address(0));
bytes memory validationData = _getDefaultValidationData();
(, uint64 expires, bytes memory sig) = abi.decode(validationData, (address, uint64, bytes));
bytes memory claimerMismatchValidationData = abi.encode(notUser, expires, sig);

vm.expectRevert(abi.encodeWithSelector(SybilResistanceVerifier.ClaimerAddressMismatch.selector, notUser, user));
validator.isValidDiscountRegistration(user, claimerMismatchValidationData);
}

function test_reverts_whenTheValidationData_signatureIsExpired() public {
bytes memory validationData = _getDefaultValidationData();
(address expectedClaimer,, bytes memory sig) = abi.decode(validationData, (address, uint64, bytes));
bytes memory claimerMismatchValidationData = abi.encode(expectedClaimer, (block.timestamp - 1), sig);

vm.expectRevert(abi.encodeWithSelector(SybilResistanceVerifier.SignatureExpired.selector));
validator.isValidDiscountRegistration(user, claimerMismatchValidationData);
}

function test_returnsFalse_whenTheExpectedSignerMismatches(uint256 pk) public view {
vm.assume(pk != signerPk && pk != 0 && pk < type(uint128).max);
address badSigner = vm.addr(pk);
bytes32 digest = SybilResistanceVerifier._makeSignatureHash(address(validator), badSigner, user, expires);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest);
bytes memory sig = abi.encodePacked(r, s, v);
bytes memory badSignerValidationData = abi.encode(user, expires, sig);

assertFalse(validator.isValidDiscountRegistration(user, badSignerValidationData));
}

function test_returnsTrue_whenEverythingIsHappy() public {
assertTrue(validator.isValidDiscountRegistration(user, _getDefaultValidationData()));
}
}
27 changes: 27 additions & 0 deletions test/discounts/SignatureDiscountValidator/SetSigner.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {SignatureDiscountValidator} from "src/L2/discounts/SignatureDiscountValidator.sol";
import {SignatureDiscountValidatorBase} from "./SignatureDiscountValidatorBase.t.sol";
import {Ownable} from "solady/auth/Ownable.sol";

contract SetSigner is SignatureDiscountValidatorBase {
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(address newSigner) public {
vm.assume(newSigner != signer && newSigner != address(0));
vm.prank(owner);
validator.setSigner(newSigner);
}

function test_revertWhen_settingSignerToZeroAddress() public {
vm.expectRevert(SignatureDiscountValidator.NoZeroAddress.selector);
vm.prank(owner);
validator.setSigner(address(0));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//SPDX-License-Identifier: MIT
pragma solidity ^0.8.23;

import {Test, console} from "forge-std/Test.sol";
import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol";

import {SignatureDiscountValidator} from "src/L2/discounts/SignatureDiscountValidator.sol";

contract SignatureDiscountValidatorBase is Test {
address public owner = makeAddr("owner");
address public signer;
uint256 public signerPk;
address public user = makeAddr("user");
uint64 time = 1717200000;
uint64 expires = 1893456000;

SignatureDiscountValidator validator;

function setUp() public {
vm.warp(time);
(signer, signerPk) = makeAddrAndKey("signer");

validator = new SignatureDiscountValidator(owner, signer);
}

function _getDefaultValidationData() internal virtual returns (bytes memory) {
bytes32 digest = SybilResistanceVerifier._makeSignatureHash(address(validator), signer, user, expires);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest);
bytes memory sig = abi.encodePacked(r, s, v);
return abi.encode(user, expires, sig);
}

function test_constructor() public {
vm.expectRevert(SignatureDiscountValidator.NoZeroAddress.selector);
new SignatureDiscountValidator(address(0), signer);

vm.expectRevert(SignatureDiscountValidator.NoZeroAddress.selector);
new SignatureDiscountValidator(owner, address(0));
}
}