diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b20b9e0c..567ccc36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,6 +18,7 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -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 diff --git a/src/L2/discounts/SignatureDiscountValidator.sol b/src/L2/discounts/SignatureDiscountValidator.sol new file mode 100644 index 00000000..898f346a --- /dev/null +++ b/src/L2/discounts/SignatureDiscountValidator.sol @@ -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); + } +} diff --git a/src/lib/SybilResistanceVerifier.sol b/src/lib/SybilResistanceVerifier.sol index c0e883c5..e9139b10 100644 --- a/src/lib/SybilResistanceVerifier.sol +++ b/src/lib/SybilResistanceVerifier.sol @@ -16,7 +16,7 @@ library SybilResistanceVerifier { /// @param claimer The address that is calling the discounted registration. error ClaimerAddressMismatch(address expectedClaimer, address claimer); - /// @notice Thrown when the signature expiry date >= block.timestamp. + /// @notice Thrown when the signature expiry date < block.timestamp. error SignatureExpired(); /// @notice Generates a hash for signing/verifying. diff --git a/test/Fork/AbstractForkSuite.t.sol b/test/Fork/AbstractForkSuite.t.sol index dfdea0b4..eba87bba 100644 --- a/test/Fork/AbstractForkSuite.t.sol +++ b/test/Fork/AbstractForkSuite.t.sol @@ -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); @@ -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(); diff --git a/test/Fork/BaseMainnetConfig.t.sol b/test/Fork/BaseMainnetConfig.t.sol index e83c8129..013b79a6 100644 --- a/test/Fork/BaseMainnetConfig.t.sol +++ b/test/Fork/BaseMainnetConfig.t.sol @@ -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) { diff --git a/test/Fork/BaseSepoliaConfig.t.sol b/test/Fork/BaseSepoliaConfig.t.sol index ca5949fb..cdcf164c 100644 --- a/test/Fork/BaseSepoliaConfig.t.sol +++ b/test/Fork/BaseSepoliaConfig.t.sol @@ -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) { diff --git a/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol b/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol new file mode 100644 index 00000000..478b0d85 --- /dev/null +++ b/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol @@ -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())); + } +} diff --git a/test/discounts/SignatureDiscountValidator/SetSigner.t.sol b/test/discounts/SignatureDiscountValidator/SetSigner.t.sol new file mode 100644 index 00000000..a8daf8cc --- /dev/null +++ b/test/discounts/SignatureDiscountValidator/SetSigner.t.sol @@ -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)); + } +} diff --git a/test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol b/test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol new file mode 100644 index 00000000..01f37282 --- /dev/null +++ b/test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol @@ -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)); + } +}