From 302fb2d498f54c2b89f3facea2c2b63c8867628f Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 22 Oct 2025 14:33:29 -0700 Subject: [PATCH 01/18] Add new discount validator contract --- .../discounts/SignatureDiscountValidator.sol | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/L2/discounts/SignatureDiscountValidator.sol diff --git a/src/L2/discounts/SignatureDiscountValidator.sol b/src/L2/discounts/SignatureDiscountValidator.sol new file mode 100644 index 00000000..b59c7f99 --- /dev/null +++ b/src/L2/discounts/SignatureDiscountValidator.sol @@ -0,0 +1,46 @@ +//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 validatio schema which performs signature verification to valiate +/// 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; + + /// @notice constructor + /// + /// @param owner_ The permissioned `owner` in the `Ownable` context. + /// @param signer_ The off-chain signer of the base signers service. + constructor(address owner_, address signer_) { + _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 { + 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); + } +} From f74c1bd317b3c3f66d7424f4b70bbccee95c1cba Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 22 Oct 2025 14:34:56 -0700 Subject: [PATCH 02/18] Add unit tests --- .../IsValidDiscountRegistration.t.sol | 41 +++++++++++++++++++ .../SetSigner.t.sol | 20 +++++++++ .../SignatureDiscountValidatorBase.t.sol | 32 +++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol create mode 100644 test/discounts/SignatureDiscountValidator/SetSigner.t.sol create mode 100644 test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol diff --git a/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol b/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol new file mode 100644 index 00000000..32537cf8 --- /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() public { + address notUser = makeAddr("anon"); + 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..3e803b89 --- /dev/null +++ b/test/discounts/SignatureDiscountValidator/SetSigner.t.sol @@ -0,0 +1,20 @@ +//SPDX-License-Identifier: MIT +pragma solidity ^0.8.23; + +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() public { + vm.prank(owner); + address newSigner = makeAddr("new"); + validator.setSigner(newSigner); + } +} diff --git a/test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol b/test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol new file mode 100644 index 00000000..48c3659b --- /dev/null +++ b/test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol @@ -0,0 +1,32 @@ +//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); + } +} From 41db42d36011a686ffa3952050af81fc0f4f7e5b Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 22 Oct 2025 14:35:25 -0700 Subject: [PATCH 03/18] Update ensip-19 fork tests to be compatible with a post-ensip-19 world --- test/Fork/AbstractForkSuite.t.sol | 5 +++-- test/Fork/BaseMainnetConfig.t.sol | 4 ++-- test/Fork/BaseSepoliaConfig.t.sol | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) 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) { From 39f6da6dcdc347df7b088a984acdd0f595215352 Mon Sep 17 00:00:00 2001 From: katzman Date: Wed, 22 Oct 2025 15:40:20 -0700 Subject: [PATCH 04/18] Temporarily add test script --- script/TestSignatureDiscountValidator.s.sol | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 script/TestSignatureDiscountValidator.s.sol diff --git a/script/TestSignatureDiscountValidator.s.sol b/script/TestSignatureDiscountValidator.s.sol new file mode 100644 index 00000000..25ee8cbd --- /dev/null +++ b/script/TestSignatureDiscountValidator.s.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.23; + +import {Script} from "forge-std/Script.sol"; +import {console} from "forge-std/console.sol"; +import {SignatureDiscountValidator} from "src/L2/discounts/SignatureDiscountValidator.sol"; +import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol"; +import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; + +contract TestSDV is Script { + + address SIGNER = 0x7d478B0b34d66c7bE28f01B6E865eFd395594794; + address USER = 0xE6Cec78310ADeC1D6642CfbE8827745bCa141070; + address OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; + + SignatureDiscountValidator validator; + + function deploy() public { + vm.prank(OWNER); + validator = new SignatureDiscountValidator(OWNER, SIGNER); + console.logAddress(address(validator)); + } + + function validate(bytes calldata data) public { + deploy(); + bool ret = validator.isValidDiscountRegistration(USER, data); + console.log(ret); + } + + // function signature(uint64 expiry, bytes calldata sig) public { + // vm.warp(expiry-1); + // bytes memory validationData = abi.encode(USER, expiry, sig); + // (, bytes memory ret) = address(this).staticcall(abi.encodeWithSelector(I.verifier.selector, validationData)); + // console.logBytes(ret); + // } +} \ No newline at end of file From 95805f754ab5bc9a01f693d648f6add7b984b34f Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 28 Oct 2025 10:56:16 -0700 Subject: [PATCH 05/18] remove test script --- script/TestSignatureDiscountValidator.s.sol | 36 --------------------- 1 file changed, 36 deletions(-) delete mode 100644 script/TestSignatureDiscountValidator.s.sol diff --git a/script/TestSignatureDiscountValidator.s.sol b/script/TestSignatureDiscountValidator.s.sol deleted file mode 100644 index 25ee8cbd..00000000 --- a/script/TestSignatureDiscountValidator.s.sol +++ /dev/null @@ -1,36 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED -pragma solidity ^0.8.23; - -import {Script} from "forge-std/Script.sol"; -import {console} from "forge-std/console.sol"; -import {SignatureDiscountValidator} from "src/L2/discounts/SignatureDiscountValidator.sol"; -import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol"; -import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol"; - -contract TestSDV is Script { - - address SIGNER = 0x7d478B0b34d66c7bE28f01B6E865eFd395594794; - address USER = 0xE6Cec78310ADeC1D6642CfbE8827745bCa141070; - address OWNER = 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266; - - SignatureDiscountValidator validator; - - function deploy() public { - vm.prank(OWNER); - validator = new SignatureDiscountValidator(OWNER, SIGNER); - console.logAddress(address(validator)); - } - - function validate(bytes calldata data) public { - deploy(); - bool ret = validator.isValidDiscountRegistration(USER, data); - console.log(ret); - } - - // function signature(uint64 expiry, bytes calldata sig) public { - // vm.warp(expiry-1); - // bytes memory validationData = abi.encode(USER, expiry, sig); - // (, bytes memory ret) = address(this).staticcall(abi.encodeWithSelector(I.verifier.selector, validationData)); - // console.logBytes(ret); - // } -} \ No newline at end of file From eeba32cedd23845103ec23a529ebd9829e75ae15 Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 28 Oct 2025 10:57:30 -0700 Subject: [PATCH 06/18] Fix ci/cd to only lint files changed in the pr --- .github/workflows/test.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b20b9e0c..d7b46f25 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,22 @@ jobs: forge test -vvv --ffi id: test - - name: Check formatting + - name: Get changed Solidity files + id: changed-files run: | - forge fmt --check + # Determine the range of commits to analyze + 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 + echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT + echo "Changed Solidity files: $CHANGED_FILES" + + - name: Check formatting on changed files + if: steps.changed-files.outputs.files != '' + run: | + echo "${{ steps.changed-files.outputs.files }}" | xargs forge fmt --check id: fmt From 8a84ac9be14ef95b1a2b6fe6de6c3a1cb57e941f Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 28 Oct 2025 11:08:34 -0700 Subject: [PATCH 07/18] Use more targeted checkout strategy --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7b46f25..988e5091 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,6 @@ jobs: - uses: actions/checkout@v4 with: submodules: recursive - fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 @@ -48,8 +47,9 @@ jobs: - name: Get changed Solidity files id: changed-files run: | - # Determine the range of commits to analyze if [ "${{ github.event_name }}" == "pull_request" ]; then + # Fetch only the base commit we need + git fetch origin ${{ github.event.pull_request.base.sha }} --depth=1 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) From 38e4da6a217787e5ce06b5efdc4adda313a94abc Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 28 Oct 2025 11:20:05 -0700 Subject: [PATCH 08/18] Fix checkout depth --- .github/workflows/test.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 988e5091..0bc6041d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,10 +48,12 @@ jobs: id: changed-files run: | if [ "${{ github.event_name }}" == "pull_request" ]; then - # Fetch only the base commit we need - git fetch origin ${{ github.event.pull_request.base.sha }} --depth=1 - BASE_COMMIT=${{ github.event.pull_request.base.sha }} - HEAD_COMMIT=${{ github.event.pull_request.head.sha }} + git fetch origin ${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }} --depth=50 + BASE_COMMIT=$(git merge-base HEAD origin/${{ github.event.pull_request.base.ref }}) + HEAD_COMMIT=HEAD + echo "Base branch: ${{ github.event.pull_request.base.ref }}" + echo "Base commit (merge-base): $BASE_COMMIT" + echo "Head commit: $HEAD_COMMIT" 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) From df6afdc5f9d532ce58295aa19619454c9432713a Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 28 Oct 2025 11:25:58 -0700 Subject: [PATCH 09/18] go back to less sophisticated checkout depth --- .github/workflows/test.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0bc6041d..71519a8c 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 @@ -48,12 +49,8 @@ jobs: id: changed-files run: | if [ "${{ github.event_name }}" == "pull_request" ]; then - git fetch origin ${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }} --depth=50 - BASE_COMMIT=$(git merge-base HEAD origin/${{ github.event.pull_request.base.ref }}) - HEAD_COMMIT=HEAD - echo "Base branch: ${{ github.event.pull_request.base.ref }}" - echo "Base commit (merge-base): $BASE_COMMIT" - echo "Head commit: $HEAD_COMMIT" + 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) From 0f40ddfef2b4561510b71b083f17f2496c79ab65 Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 28 Oct 2025 11:29:32 -0700 Subject: [PATCH 10/18] iterate through files for forge fmt checks --- .github/workflows/test.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 71519a8c..2083a2e3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,5 +61,15 @@ jobs: - name: Check formatting on changed files if: steps.changed-files.outputs.files != '' run: | - echo "${{ steps.changed-files.outputs.files }}" | xargs forge fmt --check + # Convert the file list to an array and pass each file individually + FILES="${{ steps.changed-files.outputs.files }}" + if [ -n "$FILES" ]; then + echo "Checking formatting for files: $FILES" + echo "$FILES" | while IFS= read -r file; do + if [ -n "$file" ]; then + echo "Checking: $file" + forge fmt --check "$file" + fi + done + fi id: fmt From 3e38d886c92877a7f0d86d58e1e72019c249bea5 Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 28 Oct 2025 11:34:04 -0700 Subject: [PATCH 11/18] Fix iterator source --- .github/workflows/test.yml | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2083a2e3..567ccc36 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,21 +55,32 @@ jobs: else CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR HEAD^..HEAD | grep '\.sol$' || true) fi - echo "files=$CHANGED_FILES" >> $GITHUB_OUTPUT - echo "Changed Solidity files: $CHANGED_FILES" + + # 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.files != '' + if: steps.changed-files.outputs.has_files == 'true' run: | - # Convert the file list to an array and pass each file individually - FILES="${{ steps.changed-files.outputs.files }}" - if [ -n "$FILES" ]; then - echo "Checking formatting for files: $FILES" - echo "$FILES" | while IFS= read -r file; do + # 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 + done < /tmp/changed_files.txt + else + echo "No changed files found" fi id: fmt From 4d858351f7a0c6b056d6e332ce03280783590cc1 Mon Sep 17 00:00:00 2001 From: katzman Date: Thu, 30 Oct 2025 14:08:19 -0700 Subject: [PATCH 12/18] Update src/L2/discounts/SignatureDiscountValidator.sol Co-authored-by: Amie --- src/L2/discounts/SignatureDiscountValidator.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/L2/discounts/SignatureDiscountValidator.sol b/src/L2/discounts/SignatureDiscountValidator.sol index b59c7f99..cbd20fe2 100644 --- a/src/L2/discounts/SignatureDiscountValidator.sol +++ b/src/L2/discounts/SignatureDiscountValidator.sol @@ -8,7 +8,7 @@ import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol"; /// @title Discount Validator for: Signature Discount Validator /// -/// @notice Implements a simple signature validatio schema which performs signature verification to valiate +/// @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) From ed95c093114d5a49d5a38e0a5f3d1391c9ccd303 Mon Sep 17 00:00:00 2001 From: katzman Date: Thu, 30 Oct 2025 14:08:32 -0700 Subject: [PATCH 13/18] Update src/L2/discounts/SignatureDiscountValidator.sol Co-authored-by: Amie --- src/L2/discounts/SignatureDiscountValidator.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/L2/discounts/SignatureDiscountValidator.sol b/src/L2/discounts/SignatureDiscountValidator.sol index cbd20fe2..41b4f016 100644 --- a/src/L2/discounts/SignatureDiscountValidator.sol +++ b/src/L2/discounts/SignatureDiscountValidator.sol @@ -19,7 +19,7 @@ contract SignatureDiscountValidator is Ownable, IDiscountValidator { /// @notice constructor /// /// @param owner_ The permissioned `owner` in the `Ownable` context. - /// @param signer_ The off-chain signer of the base signers service. + /// @param signer_ The off-chain signer of the Base Signer Service. constructor(address owner_, address signer_) { _initializeOwner(owner_); signer = signer_; From ba3679e62aec122182afe02fbea8ec6940509eed Mon Sep 17 00:00:00 2001 From: katzman Date: Thu, 30 Oct 2025 14:15:23 -0700 Subject: [PATCH 14/18] Add fuzz tests --- .../IsValidDiscountRegistration.t.sol | 4 ++-- test/discounts/SignatureDiscountValidator/SetSigner.t.sol | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol b/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol index 32537cf8..478b0d85 100644 --- a/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol +++ b/test/discounts/SignatureDiscountValidator/IsValidDiscountRegistration.t.sol @@ -5,8 +5,8 @@ import {SignatureDiscountValidatorBase} from "./SignatureDiscountValidatorBase.t import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol"; contract IsValidDiscountRegistration is SignatureDiscountValidatorBase { - function test_reverts_whenTheValidationData_claimerAddressMismatch() public { - address notUser = makeAddr("anon"); + 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); diff --git a/test/discounts/SignatureDiscountValidator/SetSigner.t.sol b/test/discounts/SignatureDiscountValidator/SetSigner.t.sol index 3e803b89..6cf646f3 100644 --- a/test/discounts/SignatureDiscountValidator/SetSigner.t.sol +++ b/test/discounts/SignatureDiscountValidator/SetSigner.t.sol @@ -12,9 +12,9 @@ contract SetSigner is SignatureDiscountValidatorBase { validator.setSigner(caller); } - function test_allowsTheOwner_toUpdateTheSigner() public { + function test_allowsTheOwner_toUpdateTheSigner(address newSigner) public { + vm.assume(newSigner != signer && newSigner != address(0)); vm.prank(owner); - address newSigner = makeAddr("new"); validator.setSigner(newSigner); } } From 839e426e4384af4f0accbeab2f528ee8729c6219 Mon Sep 17 00:00:00 2001 From: katzman Date: Thu, 30 Oct 2025 14:15:50 -0700 Subject: [PATCH 15/18] Update src/L2/discounts/SignatureDiscountValidator.sol Co-authored-by: Amie --- src/L2/discounts/SignatureDiscountValidator.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/L2/discounts/SignatureDiscountValidator.sol b/src/L2/discounts/SignatureDiscountValidator.sol index 41b4f016..a8f327a6 100644 --- a/src/L2/discounts/SignatureDiscountValidator.sol +++ b/src/L2/discounts/SignatureDiscountValidator.sol @@ -13,7 +13,7 @@ import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol"; /// /// @author Coinbase (https://github.com/base-org/basenames) contract SignatureDiscountValidator is Ownable, IDiscountValidator { - /// @dev The base signer service signer address. + /// @dev The Base Signer Service signer address. address signer; /// @notice constructor From a47d29fb9bb55dae09f0fe7a601e3f2a6391573e Mon Sep 17 00:00:00 2001 From: katzman Date: Tue, 4 Nov 2025 13:31:38 -0800 Subject: [PATCH 16/18] Add zero address checks --- src/L2/discounts/SignatureDiscountValidator.sol | 6 ++++++ test/discounts/SignatureDiscountValidator/SetSigner.t.sol | 7 +++++++ .../SignatureDiscountValidatorBase.t.sol | 8 ++++++++ 3 files changed, 21 insertions(+) diff --git a/src/L2/discounts/SignatureDiscountValidator.sol b/src/L2/discounts/SignatureDiscountValidator.sol index 41b4f016..f7e112da 100644 --- a/src/L2/discounts/SignatureDiscountValidator.sol +++ b/src/L2/discounts/SignatureDiscountValidator.sol @@ -16,11 +16,16 @@ 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_; } @@ -29,6 +34,7 @@ contract SignatureDiscountValidator is Ownable, IDiscountValidator { /// /// @param signer_ The address of the new signer. function setSigner(address signer_) external onlyOwner { + if (signer_ == address(0)) revert NoZeroAddress(); signer = signer_; } diff --git a/test/discounts/SignatureDiscountValidator/SetSigner.t.sol b/test/discounts/SignatureDiscountValidator/SetSigner.t.sol index 6cf646f3..a8daf8cc 100644 --- a/test/discounts/SignatureDiscountValidator/SetSigner.t.sol +++ b/test/discounts/SignatureDiscountValidator/SetSigner.t.sol @@ -1,6 +1,7 @@ //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"; @@ -17,4 +18,10 @@ contract SetSigner is SignatureDiscountValidatorBase { 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 index 48c3659b..01f37282 100644 --- a/test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol +++ b/test/discounts/SignatureDiscountValidator/SignatureDiscountValidatorBase.t.sol @@ -29,4 +29,12 @@ contract SignatureDiscountValidatorBase is Test { 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)); + } } From fb15e223ab95e732fedcb1fd9a1e8ea32ac2e2a2 Mon Sep 17 00:00:00 2001 From: katzman Date: Thu, 30 Oct 2025 14:15:50 -0700 Subject: [PATCH 17/18] Update src/L2/discounts/SignatureDiscountValidator.sol Co-authored-by: Amie --- src/L2/discounts/SignatureDiscountValidator.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/L2/discounts/SignatureDiscountValidator.sol b/src/L2/discounts/SignatureDiscountValidator.sol index f7e112da..898f346a 100644 --- a/src/L2/discounts/SignatureDiscountValidator.sol +++ b/src/L2/discounts/SignatureDiscountValidator.sol @@ -13,7 +13,7 @@ import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol"; /// /// @author Coinbase (https://github.com/base-org/basenames) contract SignatureDiscountValidator is Ownable, IDiscountValidator { - /// @dev The base signer service signer address. + /// @dev The Base Signer Service signer address. address signer; /// @dev Thrown when setting the zero address as `owner` or `signer`. From 791f5a01292826764d147ffce4762e00069496b1 Mon Sep 17 00:00:00 2001 From: katzman Date: Mon, 10 Nov 2025 09:19:40 -0800 Subject: [PATCH 18/18] Fix comment in lib --- src/lib/SybilResistanceVerifier.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.