Skip to content

Commit b43d20c

Browse files
feat(TBAEF-1634): Add signature discount validator (#146)
Co-authored-by: Amie <[email protected]>
1 parent 996ec91 commit b43d20c

File tree

9 files changed

+206
-9
lines changed

9 files changed

+206
-9
lines changed

.github/workflows/test.yml

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jobs:
1818
- uses: actions/checkout@v4
1919
with:
2020
submodules: recursive
21+
fetch-depth: 0
2122

2223
- name: Set up Python
2324
uses: actions/setup-python@v5
@@ -44,7 +45,42 @@ jobs:
4445
forge test -vvv --ffi
4546
id: test
4647

47-
- name: Check formatting
48+
- name: Get changed Solidity files
49+
id: changed-files
4850
run: |
49-
forge fmt --check
51+
if [ "${{ github.event_name }}" == "pull_request" ]; then
52+
BASE_COMMIT=${{ github.event.pull_request.base.sha }}
53+
HEAD_COMMIT=${{ github.event.pull_request.head.sha }}
54+
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR $BASE_COMMIT..$HEAD_COMMIT | grep '\.sol$' || true)
55+
else
56+
CHANGED_FILES=$(git diff --name-only --diff-filter=ACMR HEAD^..HEAD | grep '\.sol$' || true)
57+
fi
58+
59+
# Store files in a way that GitHub Actions can handle properly
60+
if [ -n "$CHANGED_FILES" ]; then
61+
echo "has_files=true" >> $GITHUB_OUTPUT
62+
# Save files to a temporary file for the next step
63+
echo "$CHANGED_FILES" > /tmp/changed_files.txt
64+
echo "Changed Solidity files:"
65+
echo "$CHANGED_FILES"
66+
else
67+
echo "has_files=false" >> $GITHUB_OUTPUT
68+
echo "No Solidity files changed"
69+
fi
70+
71+
- name: Check formatting on changed files
72+
if: steps.changed-files.outputs.has_files == 'true'
73+
run: |
74+
# Read files from the temporary file created in the previous step
75+
if [ -f /tmp/changed_files.txt ]; then
76+
echo "Checking formatting for changed Solidity files..."
77+
while IFS= read -r file; do
78+
if [ -n "$file" ]; then
79+
echo "Checking: $file"
80+
forge fmt --check "$file"
81+
fi
82+
done < /tmp/changed_files.txt
83+
else
84+
echo "No changed files found"
85+
fi
5086
id: fmt
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
//SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.23;
3+
4+
import {Ownable} from "solady/auth/Ownable.sol";
5+
6+
import {IDiscountValidator} from "src/L2/interface/IDiscountValidator.sol";
7+
import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol";
8+
9+
/// @title Discount Validator for: Signature Discount Validator
10+
///
11+
/// @notice Implements a simple signature validation schema which performs signature verification to validate
12+
/// signatures were generated from the Base Signer Service.
13+
///
14+
/// @author Coinbase (https://github.com/base-org/basenames)
15+
contract SignatureDiscountValidator is Ownable, IDiscountValidator {
16+
/// @dev The Base Signer Service signer address.
17+
address signer;
18+
19+
/// @dev Thrown when setting the zero address as `owner` or `signer`.
20+
error NoZeroAddress();
21+
22+
/// @notice constructor
23+
///
24+
/// @param owner_ The permissioned `owner` in the `Ownable` context.
25+
/// @param signer_ The off-chain signer of the Base Signer Service.
26+
constructor(address owner_, address signer_) {
27+
if (owner_ == address(0)) revert NoZeroAddress();
28+
if (signer_ == address(0)) revert NoZeroAddress();
29+
_initializeOwner(owner_);
30+
signer = signer_;
31+
}
32+
33+
/// @notice Allows the owner to update the expected signer.
34+
///
35+
/// @param signer_ The address of the new signer.
36+
function setSigner(address signer_) external onlyOwner {
37+
if (signer_ == address(0)) revert NoZeroAddress();
38+
signer = signer_;
39+
}
40+
41+
/// @notice Required implementation for compatibility with IDiscountValidator.
42+
///
43+
/// @dev The data must be encoded as `abi.encode(discountClaimerAddress, expiry, signature_bytes)`.
44+
///
45+
/// @param claimer the discount claimer's address.
46+
/// @param validationData opaque bytes for performing the validation.
47+
///
48+
/// @return `true` if the validation data provided is determined to be valid for the specified claimer, else `false`.
49+
function isValidDiscountRegistration(address claimer, bytes calldata validationData) external view returns (bool) {
50+
return SybilResistanceVerifier.verifySignature(signer, claimer, validationData);
51+
}
52+
}

src/lib/SybilResistanceVerifier.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ library SybilResistanceVerifier {
1616
/// @param claimer The address that is calling the discounted registration.
1717
error ClaimerAddressMismatch(address expectedClaimer, address claimer);
1818

19-
/// @notice Thrown when the signature expiry date >= block.timestamp.
19+
/// @notice Thrown when the signature expiry date < block.timestamp.
2020
error SignatureExpired();
2121

2222
/// @notice Generates a hash for signing/verifying.

test/Fork/AbstractForkSuite.t.sol

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {BASE_ETH_NODE} from "src/util/Constants.sol";
1414

1515
abstract contract AbstractForkSuite is Test {
1616
// Network configuration hooks
17-
function forkAlias() internal pure virtual returns (string memory);
17+
function forkAlias() internal pure virtual returns (string memory, uint256);
1818

1919
function registry() internal pure virtual returns (address);
2020
function baseRegistrar() internal pure virtual returns (address);
@@ -55,7 +55,8 @@ abstract contract AbstractForkSuite is Test {
5555
address internal MIGRATION_CONTROLLER;
5656

5757
function setUp() public virtual {
58-
vm.createSelectFork(forkAlias());
58+
(string memory forkUrl, uint256 blockNumber) = forkAlias();
59+
vm.createSelectFork(forkUrl, blockNumber);
5960

6061
// Bind constants
6162
REGISTRY = registry();

test/Fork/BaseMainnetConfig.t.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {AbstractForkSuite} from "./AbstractForkSuite.t.sol";
55
import {BaseMainnet as C} from "./BaseMainnetConstants.sol";
66

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

1212
function registry() internal pure override returns (address) {

test/Fork/BaseSepoliaConfig.t.sol

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {AbstractForkSuite} from "./AbstractForkSuite.t.sol";
55
import {BaseSepolia as C} from "./BaseSepoliaConstants.sol";
66

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

1212
function registry() internal pure override returns (address) {
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.23;
3+
4+
import {SignatureDiscountValidatorBase} from "./SignatureDiscountValidatorBase.t.sol";
5+
import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol";
6+
7+
contract IsValidDiscountRegistration is SignatureDiscountValidatorBase {
8+
function test_reverts_whenTheValidationData_claimerAddressMismatch(address notUser) public {
9+
vm.assume(notUser != user && notUser != address(0));
10+
bytes memory validationData = _getDefaultValidationData();
11+
(, uint64 expires, bytes memory sig) = abi.decode(validationData, (address, uint64, bytes));
12+
bytes memory claimerMismatchValidationData = abi.encode(notUser, expires, sig);
13+
14+
vm.expectRevert(abi.encodeWithSelector(SybilResistanceVerifier.ClaimerAddressMismatch.selector, notUser, user));
15+
validator.isValidDiscountRegistration(user, claimerMismatchValidationData);
16+
}
17+
18+
function test_reverts_whenTheValidationData_signatureIsExpired() public {
19+
bytes memory validationData = _getDefaultValidationData();
20+
(address expectedClaimer,, bytes memory sig) = abi.decode(validationData, (address, uint64, bytes));
21+
bytes memory claimerMismatchValidationData = abi.encode(expectedClaimer, (block.timestamp - 1), sig);
22+
23+
vm.expectRevert(abi.encodeWithSelector(SybilResistanceVerifier.SignatureExpired.selector));
24+
validator.isValidDiscountRegistration(user, claimerMismatchValidationData);
25+
}
26+
27+
function test_returnsFalse_whenTheExpectedSignerMismatches(uint256 pk) public view {
28+
vm.assume(pk != signerPk && pk != 0 && pk < type(uint128).max);
29+
address badSigner = vm.addr(pk);
30+
bytes32 digest = SybilResistanceVerifier._makeSignatureHash(address(validator), badSigner, user, expires);
31+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest);
32+
bytes memory sig = abi.encodePacked(r, s, v);
33+
bytes memory badSignerValidationData = abi.encode(user, expires, sig);
34+
35+
assertFalse(validator.isValidDiscountRegistration(user, badSignerValidationData));
36+
}
37+
38+
function test_returnsTrue_whenEverythingIsHappy() public {
39+
assertTrue(validator.isValidDiscountRegistration(user, _getDefaultValidationData()));
40+
}
41+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.23;
3+
4+
import {SignatureDiscountValidator} from "src/L2/discounts/SignatureDiscountValidator.sol";
5+
import {SignatureDiscountValidatorBase} from "./SignatureDiscountValidatorBase.t.sol";
6+
import {Ownable} from "solady/auth/Ownable.sol";
7+
8+
contract SetSigner is SignatureDiscountValidatorBase {
9+
function test_reverts_whenCalledByNonOwner(address caller) public {
10+
vm.assume(caller != owner && caller != address(0));
11+
vm.expectRevert(Ownable.Unauthorized.selector);
12+
vm.prank(caller);
13+
validator.setSigner(caller);
14+
}
15+
16+
function test_allowsTheOwner_toUpdateTheSigner(address newSigner) public {
17+
vm.assume(newSigner != signer && newSigner != address(0));
18+
vm.prank(owner);
19+
validator.setSigner(newSigner);
20+
}
21+
22+
function test_revertWhen_settingSignerToZeroAddress() public {
23+
vm.expectRevert(SignatureDiscountValidator.NoZeroAddress.selector);
24+
vm.prank(owner);
25+
validator.setSigner(address(0));
26+
}
27+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
//SPDX-License-Identifier: MIT
2+
pragma solidity ^0.8.23;
3+
4+
import {Test, console} from "forge-std/Test.sol";
5+
import {SybilResistanceVerifier} from "src/lib/SybilResistanceVerifier.sol";
6+
7+
import {SignatureDiscountValidator} from "src/L2/discounts/SignatureDiscountValidator.sol";
8+
9+
contract SignatureDiscountValidatorBase is Test {
10+
address public owner = makeAddr("owner");
11+
address public signer;
12+
uint256 public signerPk;
13+
address public user = makeAddr("user");
14+
uint64 time = 1717200000;
15+
uint64 expires = 1893456000;
16+
17+
SignatureDiscountValidator validator;
18+
19+
function setUp() public {
20+
vm.warp(time);
21+
(signer, signerPk) = makeAddrAndKey("signer");
22+
23+
validator = new SignatureDiscountValidator(owner, signer);
24+
}
25+
26+
function _getDefaultValidationData() internal virtual returns (bytes memory) {
27+
bytes32 digest = SybilResistanceVerifier._makeSignatureHash(address(validator), signer, user, expires);
28+
(uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPk, digest);
29+
bytes memory sig = abi.encodePacked(r, s, v);
30+
return abi.encode(user, expires, sig);
31+
}
32+
33+
function test_constructor() public {
34+
vm.expectRevert(SignatureDiscountValidator.NoZeroAddress.selector);
35+
new SignatureDiscountValidator(address(0), signer);
36+
37+
vm.expectRevert(SignatureDiscountValidator.NoZeroAddress.selector);
38+
new SignatureDiscountValidator(owner, address(0));
39+
}
40+
}

0 commit comments

Comments
 (0)