Skip to content

Commit

Permalink
fix(evm): make SignerValidator utilize EIP712
Browse files Browse the repository at this point in the history
This prevents cross-chain replays.

The tests can still be streamlined quite a bit, but I'll do this
after/while I implement multi-claims with a valid signature
  • Loading branch information
topocount committed Aug 29, 2024
1 parent c023da0 commit 0e205d1
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 78 deletions.
2 changes: 1 addition & 1 deletion packages/evm/contracts/BoostCore.sol
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ contract BoostCore is Ownable, ReentrancyGuard {
_routeClaimFee(boost, referrer_);

// wake-disable-next-line reentrancy (false positive, function is nonReentrant)
if (!boost.validator.validate(data_)) revert BoostError.Unauthorized();
if (!boost.validator.validate(boostId_, incentiveId_, msg.sender, data_)) revert BoostError.Unauthorized();
if (
!boost.incentives[incentiveId_].claim(abi.encode(Incentive.ClaimPayload({target: msg.sender, data: data_})))
) revert BoostError.ClaimFailed(msg.sender, data_);
Expand Down
10 changes: 8 additions & 2 deletions packages/evm/contracts/actions/AERC721MintAction.sol
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

import {Ownable as AOwnable} from "@solady/auth/Ownable.sol";
import {ERC721} from "@solady/tokens/ERC721.sol";

import {BoostError} from "contracts/shared/BoostError.sol";
Expand All @@ -16,7 +17,7 @@ import {AValidator} from "contracts/validators/AValidator.sol";
/// @dev The action is expected to be prepared with the data payload for the minting of the token
/// @dev This a minimal generic implementation that should be extended if additional functionality or customizations are required
/// @dev It is expected that the target contract has an externally accessible mint function whose selector
abstract contract AERC721MintAction is ContractAction, AValidator {
abstract contract AERC721MintAction is ContractAction, AValidator, AOwnable {
/// @notice The set of validated tokens
/// @dev This is intended to prevent multiple validations against the same token ID
mapping(uint256 => bool) public validated;
Expand Down Expand Up @@ -49,7 +50,12 @@ abstract contract AERC721MintAction is ContractAction, AValidator {
/// @return success True if the action has been validated for the user
/// @dev The first 20 bytes of the payload must be the holder address and the remaining bytes must be an encoded token ID (uint256)
/// @dev Example: `abi.encode(address(holder), abi.encode(uint256(tokenId)))`
function validate(bytes calldata data_, uint256 /* unused */) external virtual override returns (bool success) {
function validate(uint256, /*unused*/ uint256, /* unused */ address, /*unused*/ bytes calldata data_)
external
virtual
override
returns (bool success)
{
(address holder, bytes memory payload) = abi.decode(data_, (address, bytes));
uint256 tokenId = uint256(bytes32(payload));

Expand Down
10 changes: 10 additions & 0 deletions packages/evm/contracts/shared/IBoostClaim.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

interface IBoostClaim {
/// @notice A higher order struct for encoding and decoding arbitrary claims
struct BoostClaimData {
bytes validatorData;
bytes incentiveData;
}
}
47 changes: 17 additions & 30 deletions packages/evm/contracts/validators/ASignerValidator.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,50 +3,37 @@ pragma solidity ^0.8.24;

import {SignatureCheckerLib} from "@solady/utils/SignatureCheckerLib.sol";

import {Cloneable} from "contracts/shared/Cloneable.sol";
import {BoostError} from "contracts/shared/BoostError.sol";
import {Cloneable} from "contracts/shared/Cloneable.sol";
import {IBoostClaim} from "contracts/shared/IBoostClaim.sol";

import {AValidator} from "contracts/validators/AValidator.sol";

/// @title Signer Validator
/// @notice A simple implementation of a Validator that verifies a given signature and checks the recovered address against a set of authorized signers
abstract contract ASignerValidator is AValidator {
abstract contract ASignerValidator is IBoostClaim, AValidator {
using SignatureCheckerLib for address;

/// @dev The set of authorized signers
mapping(address => bool) public signers;

/// @dev The set of used hashes (for replay protection)
mapping(bytes32 => bool) internal _used;

/// @notice Validate that the action has been completed successfully
/// @param data_ The data payload for the validation check
/// @return True if the action has been validated based on the data payload
/// @dev The data payload is expected to be a tuple of (address signer, bytes32 hash, bytes signature)
/// @dev The signature is expected to be a valid ECDSA or EIP-1271 signature of a unique hash by an authorized signer
function validate(bytes calldata data_) external override returns (bool) {
(address signer_, bytes32 hash_, bytes memory signature_) = abi.decode(data_, (address, bytes32, bytes));

if (!signers[signer_]) revert BoostError.Unauthorized();
if (_used[hash_]) revert BoostError.Replayed(signer_, hash_, signature_);

// Mark the hash as used to prevent replays
_used[hash_] = true;
struct SignerValidatorInputParams {
address signer;
bytes signature;
uint8 incentiveQuantity;
}

// Return the result of the signature check
return signer_.isValidSignatureNow(SignatureCheckerLib.toEthSignedMessageHash(hash_), signature_);
struct SignerValidatorData {
uint8 incentiveQuantity;
address claimant;
uint256 boostId;
bytes incentiveData;
}

/// @dev The set of authorized signers
mapping(address => bool) public signers;

/// @notice Set the authorized status of a signer
/// @param signers_ The list of signers to update
/// @param authorized_ The authorized status of each signer
function setAuthorized(address[] calldata signers_, bool[] calldata authorized_) external onlyOwner {
if (signers_.length != authorized_.length) revert BoostError.LengthMismatch();

for (uint256 i = 0; i < signers_.length; i++) {
signers[signers_[i]] = authorized_[i];
}
}
function setAuthorized(address[] calldata signers_, bool[] calldata authorized_) external virtual;

/// @inheritdoc Cloneable
function getComponentInterface() public pure virtual override(AValidator) returns (bytes4) {
Expand Down
21 changes: 9 additions & 12 deletions packages/evm/contracts/validators/AValidator.sol
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

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

import {Cloneable} from "contracts/shared/Cloneable.sol";

/// @title Boost Validator
/// @notice Abstract contract for a generic Validator within the Boost protocol
/// @dev Validator classes are expected to decode the calldata for implementation-specific handling. If no data is required, calldata should be empty.
abstract contract AValidator is Ownable, Cloneable {
struct ValidatePayload {
address target;
bytes data;
}

abstract contract AValidator is Cloneable {
/// @notice Validate that a given user has completed an acction successfully
/// @param data The compressed {ValidatePayload} to be validated
/// @param boostId The Id from the available boosts
/// @param incentiveId The Id from the available boost incentives to be claimed
/// @param claimant The address of the user claiming the incentive
/// @param data The encoded payload to be validated
/// @return True if the action has been validated based on the data payload
/// @dev The decompressed payload contains the address of the user being validated along with freeform bytes that are entirely implementation-specific
/// @dev For example, to validate a tuple of `(bytes32 messageHash, bytes signature)` on behalf of `address holder`, the payload should be `ValidatePayload({target: holder, data: abi.encode(messageHash, signature)})`, ABI-encoded and compressed with {LibZip-cdCompress}
function validate(bytes calldata data, uint256 incentiveId) external virtual returns (bool);
/// @dev The decompressed payload contains freeform bytes that are entirely implementation-specific
function validate(uint256 boostId, uint256 incentiveId, address claimant, bytes calldata data)
external
virtual
returns (bool);

/// @inheritdoc Cloneable
function supportsInterface(bytes4 interfaceId) public view virtual override(Cloneable) returns (bool) {
Expand Down
74 changes: 73 additions & 1 deletion packages/evm/contracts/validators/SignerValidator.sol
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.24;

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

import {SignatureCheckerLib} from "@solady/utils/SignatureCheckerLib.sol";
import {EIP712} from "@solady/utils/EIP712.sol";

import {Cloneable} from "contracts/shared/Cloneable.sol";
import {BoostError} from "contracts/shared/BoostError.sol";

import {AValidator} from "contracts/validators/AValidator.sol";
import {ASignerValidator} from "contracts/validators/ASignerValidator.sol";

/// @title Signer Validator
/// @notice A simple implementation of a Validator that verifies a given signature and checks the recovered address against a set of authorized signers
contract SignerValidator is ASignerValidator {
contract SignerValidator is ASignerValidator, Ownable, EIP712 {
using SignatureCheckerLib for address;

/// @dev The set of used hashes (for replay protection)
mapping(bytes32 => bool) internal _used;

bytes32 internal constant _SIGNER_VALIDATOR_TYPEHASH =
keccak256("SignerValidatorData(uint8 incentiveQuantity,address claimant,uint256 boostId,bytes incentiveData)");

/// @notice Construct a new SignerValidator
/// @dev Because this contract is a base implementation, it should not be initialized through the constructor. Instead, it should be cloned and initialized using the {initialize} function.
constructor() {
Expand All @@ -22,4 +39,59 @@ contract SignerValidator is ASignerValidator {
signers[signers_[i]] = true;
}
}

/// Validate that the action has been completed successfully by constructing a payload and checking the signature against it
/// @inheritdoc AValidator
function validate(uint256 boostId, uint256 incentiveId, address claimant, bytes calldata claimData)
external
override
returns (bool)
{
(BoostClaimData memory claim) = abi.decode(claimData, (BoostClaimData));
(SignerValidatorInputParams memory validatorData) =
abi.decode(claim.validatorData, (SignerValidatorInputParams));

bytes32 hash = hashSignerData(boostId, validatorData.incentiveQuantity, claimant, claim.incentiveData);
if (!signers[validatorData.signer]) revert BoostError.Unauthorized();
if (_used[hash]) revert BoostError.Replayed(validatorData.signer, hash, validatorData.signature);

// Mark the hash as used to prevent replays
_used[hash] = true;

// Return the result of the signature check
// no need for a sig prefix since it's encoded by the EIP712 lib
return validatorData.signer.isValidSignatureNow(hash, validatorData.signature);
}

/// @notice Set the authorized status of a signer
/// @param signers_ The list of signers to update
/// @param authorized_ The authorized status of each signer
function setAuthorized(address[] calldata signers_, bool[] calldata authorized_) external override onlyOwner {
if (signers_.length != authorized_.length) revert BoostError.LengthMismatch();

for (uint256 i = 0; i < signers_.length; i++) {
signers[signers_[i]] = authorized_[i];
}
}

function hashSignerData(uint256 boostId, uint8 incentiveQuantity, address claimant, bytes memory incentiveData)
public
view
returns (bytes32 hashedSignerData)
{
return _hashTypedData(
keccak256(
abi.encode(_SIGNER_VALIDATOR_TYPEHASH, incentiveQuantity, claimant, boostId, keccak256(incentiveData))
)
);
}

function _domainNameAndVersion() internal pure override returns (string memory name, string memory version) {
name = "SignerValidator";
version = "1";
}

function _domainNameAndVersionMayChange() internal pure override returns (bool result) {
result = true;
}
}
8 changes: 4 additions & 4 deletions packages/evm/test/actions/ERC721MintAction.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,10 @@ contract ERC721MintActionTest is Test {
assertTrue(mockAsset.ownerOf(1) == address(this));

// Validate the action
assertTrue(action.validate(0,0, address(0), abi.encode(address(this), abi.encode(1))));
assertTrue(action.validate(0, 0, address(0), abi.encode(address(this), abi.encode(1))));

// Validate the action again => false
assertFalse(action.validate(0,0, address(0), abi.encode(address(this), abi.encode(1))));
assertFalse(action.validate(0, 0, address(0), abi.encode(address(this), abi.encode(1))));
}

function testValidate_NonExistentToken() public {
Expand All @@ -124,7 +124,7 @@ contract ERC721MintActionTest is Test {

// Validate the action with a non-existent token
vm.expectRevert(ERC721.TokenDoesNotExist.selector);
action.validate(0,0, address(0), abi.encode(address(this), abi.encode(1)));
action.validate(0, 0, address(0), abi.encode(address(this), abi.encode(1)));
}

////////////////////////////////
Expand All @@ -140,7 +140,7 @@ contract ERC721MintActionTest is Test {
assertTrue(mockAsset.ownerOf(1) == address(this));

// Validate the action
assertTrue(action.validate(0,0, address(0), abi.encode(address(this), abi.encode(1))));
assertTrue(action.validate(0, 0, address(0), abi.encode(address(this), abi.encode(1))));

// Check the validation status of the token
assertTrue(action.validated(1));
Expand Down
Loading

0 comments on commit 0e205d1

Please sign in to comment.