From 423a1c8337e5f41114de20f24b6816cd4f4ac39c Mon Sep 17 00:00:00 2001 From: Matias Date: Mon, 6 Jan 2025 15:17:25 -0300 Subject: [PATCH] partial: indexing payments [DO NOT MERGE] Adds Authorizable and IPCollector. --- .../contracts/interfaces/IAuthorizable.sol | 155 +++++++ .../contracts/interfaces/IIPCollector.sol | 83 ++++ .../contracts/mocks/ControllerMock.sol | 2 +- .../payments/collectors/IPCollector.sol | 95 ++++ .../contracts/utilities/Authorizable.sol | 119 +++++ .../payments/ip-collector/IPCollector.t.sol | 208 +++++++++ .../horizon/test/utilities/Authorizable.t.sol | 421 ++++++++++++++++++ packages/horizon/test/utils/Bounder.t.sol | 31 ++ 8 files changed, 1113 insertions(+), 1 deletion(-) create mode 100644 packages/horizon/contracts/interfaces/IAuthorizable.sol create mode 100644 packages/horizon/contracts/interfaces/IIPCollector.sol create mode 100644 packages/horizon/contracts/payments/collectors/IPCollector.sol create mode 100644 packages/horizon/contracts/utilities/Authorizable.sol create mode 100644 packages/horizon/test/payments/ip-collector/IPCollector.t.sol create mode 100644 packages/horizon/test/utilities/Authorizable.t.sol create mode 100644 packages/horizon/test/utils/Bounder.t.sol diff --git a/packages/horizon/contracts/interfaces/IAuthorizable.sol b/packages/horizon/contracts/interfaces/IAuthorizable.sol new file mode 100644 index 000000000..08f371b8c --- /dev/null +++ b/packages/horizon/contracts/interfaces/IAuthorizable.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +/** + * @title Interface for the {Authorizable} contract + * @notice Implements an authorization scheme that allows authorizers to + * authorize signers to sign on their behalf. + */ +interface IAuthorizable { + /** + * @notice Details for an authorizer-signer pair + * @dev Authorizations can be removed only after a thawing period + */ + struct Authorization { + // Resource owner + address authorizer; + // Timestamp at which thawing period ends (zero if not thawing) + uint256 thawEndTimestamp; + // Whether the signer authorization was revoked + bool revoked; + } + + /** + * @notice Emitted when a signer is authorized to sign for a authorizer + * @param authorizer The address of the authorizer + * @param signer The address of the signer + */ + event SignerAuthorized(address indexed authorizer, address indexed signer); + + /** + * @notice Emitted when a signer is thawed to be de-authorized + * @param authorizer The address of the authorizer thawing the signer + * @param signer The address of the signer to thaw + * @param thawEndTimestamp The timestamp at which the thawing period ends + */ + event SignerThawing(address indexed authorizer, address indexed signer, uint256 thawEndTimestamp); + + /** + * @dev Emitted when the thawing of a signer is cancelled + * @param authorizer The address of the authorizer cancelling the thawing + * @param signer The address of the signer + * @param thawEndTimestamp The timestamp at which the thawing period ends + */ + event SignerThawCanceled(address indexed authorizer, address indexed signer, uint256 thawEndTimestamp); + + /** + * @dev Emitted when a signer has been revoked + * @param authorizer The address of the authorizer revoking the signer + * @param signer The address of the signer + */ + event SignerRevoked(address indexed authorizer, address indexed signer); + + /** + * Thrown when the signer is already authorized + * @param authorizer The address of the authorizer + * @param signer The address of the signer + * @param revoked The revoked status of the authorization + */ + error SignerAlreadyAuthorized(address authorizer, address signer, bool revoked); + + /** + * Thrown when the attempting to modify a revoked signer + * @param signer The address of the signer + */ + error SignerAlreadyRevoked(address signer); + + /** + * Thrown when the signer proof deadline is invalid + * @param proofDeadline The deadline for the proof provided + * @param currentTimestamp The current timestamp + */ + error InvalidSignerProofDeadline(uint256 proofDeadline, uint256 currentTimestamp); + + /** + * Thrown when the signer proof is invalid + */ + error InvalidSignerProof(); + + /** + * Thrown when the signer is not authorized by the authorizer + * @param authorizer The address of the authorizer + * @param signer The address of the signer + */ + error SignerNotAuthorized(address authorizer, address signer); + + /** + * Thrown when the signer is not thawing + * @param signer The address of the signer + */ + error SignerNotThawing(address signer); + + /** + * Thrown when the signer is still thawing + * @param currentTimestamp The current timestamp + * @param thawEndTimestamp The timestamp at which the thawing period ends + */ + error SignerStillThawing(uint256 currentTimestamp, uint256 thawEndTimestamp); + + /** + * @notice Authorize a signer to sign on behalf of the authorizer + * @dev Requirements: + * - `signer` must not be already authorized + * - `proofDeadline` must be greater than the current timestamp + * - `proof` must be a valid signature from the signer being authorized + * + * Emits a {SignerAuthorized} event + * @param signer The addres of the signer + * @param proofDeadline The deadline for the proof provided by the signer + * @param proof The proof provided by the signer to be authorized by the authorizer + * consists of (chain id, verifying contract address, domain, proof deadline, authorizer address) + */ + function authorizeSigner(address signer, uint256 proofDeadline, bytes calldata proof) external; + + /** + * @notice Starts thawing a signer to be de-authorized + * @dev Thawing a signer signals that signatures from that signer will soon be deemed invalid. + * Once a signer is thawed, they should be viewed as revoked regardless of their revocation status. + * Requirements: + * - `signer` must be authorized by the authorizer calling this function + * + * Emits a {SignerThawing} event + * @param signer The address of the signer to thaw + */ + function thawSigner(address signer) external; + + /** + * @notice Stops thawing a signer. + * @dev Requirements: + * - `signer` must be thawing and authorized by the function caller + * + * Emits a {SignerThawCanceled} event + * @param signer The address of the signer to cancel thawing + */ + function cancelThawSigner(address signer) external; + + /** + * @notice Revokes a signer if thawed. + * @dev Requirements: + * - `signer` must be thawed and authorized by the function caller + * + * Emits a {SignerRevoked} event + * @param signer The address of the signer + */ + function revokeAuthorizedSigner(address signer) external; + + /** + * @notice Returns the thawing period for revoking an authorization + */ + function getRevokeAuthorizationThawingPeriod() external view returns (uint256); + + /** + * @notice Returns the authorization details for a signer + */ + function getAuthorization(address signer) external view returns (Authorization memory); +} diff --git a/packages/horizon/contracts/interfaces/IIPCollector.sol b/packages/horizon/contracts/interfaces/IIPCollector.sol new file mode 100644 index 000000000..6bee3c358 --- /dev/null +++ b/packages/horizon/contracts/interfaces/IIPCollector.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { IPaymentsCollector } from "./IPaymentsCollector.sol"; +import { IGraphPayments } from "./IGraphPayments.sol"; +import { IAuthorizable } from "./IAuthorizable.sol"; + +/** + * @title Interface for the {IPCollector} contract + * @dev Implements the {IPaymentCollector} interface as defined by the Graph + * Horizon payments protocol. + * @notice Implements a payments collector contract that can be used to collect + * indexing agreement payments. + */ +interface IIPCollector is IAuthorizable, IPaymentsCollector { + /// @notice A struct representing a signed IAV + struct SignedIAV { + // The IAV + IndexingAgreementVoucher iav; + // Signature - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) + bytes signature; + } + + /// @notice The Indexing Agreement Voucher (IAV) struct + struct IndexingAgreementVoucher { + // The address of the payer the IAV was issued by + address payer; + // The address of the data service the IAV was issued to + address dataService; + // The address of the service provider the IAV was issued to + address serviceProvider; + // Arbitrary metadata to extend functionality if a data service requires it + bytes metadata; + } + + /** + * @notice Emitted when an IAV is collected + * @param payer The address of the payer + * @param dataService The address of the data service + * @param serviceProvider The address of the service provider + * @param metadata Arbitrary metadata + * @param signature The signature of the IAV + */ + event IAVCollected( + address indexed payer, + address indexed dataService, + address indexed serviceProvider, + bytes metadata, + bytes signature + ); + + /** + * Thrown when the IAV signer is invalid + */ + error IPCollectorInvalidIAVSigner(); + + /** + * Thrown when the payment type is not IndexingFee + * @param paymentType The provided payment type + */ + error IPCollectorInvalidPaymentType(IGraphPayments.PaymentTypes paymentType); + + /** + * Thrown when the caller is not the data service the IAV was issued to + * @param caller The address of the caller + * @param dataService The address of the data service + */ + error IPCollectorCallerNotDataService(address caller, address dataService); + + /** + * @dev Computes the hash of a IndexingAgreementVoucher (IAV). + * @param iav The IAV for which to compute the hash. + * @return The hash of the IAV. + */ + function encodeIAV(IndexingAgreementVoucher calldata iav) external view returns (bytes32); + + /** + * @dev Recovers the signer address of a signed IndexingAgreementVoucher (IAV). + * @param signedIAV The SignedIAV containing the IAV and its signature. + * @return The address of the signer. + */ + function recoverIAVSigner(SignedIAV calldata signedIAV) external view returns (address); +} diff --git a/packages/horizon/contracts/mocks/ControllerMock.sol b/packages/horizon/contracts/mocks/ControllerMock.sol index 557b1eff6..54c3ec8db 100644 --- a/packages/horizon/contracts/mocks/ControllerMock.sol +++ b/packages/horizon/contracts/mocks/ControllerMock.sol @@ -103,7 +103,7 @@ contract ControllerMock is IController { * @param id Contract id (keccak256 hash of contract name) * @return Address of the proxy contract for the provided id */ - function getContractProxy(bytes32 id) external view override returns (address) { + function getContractProxy(bytes32 id) external view virtual override returns (address) { return _registry[id]; } diff --git a/packages/horizon/contracts/payments/collectors/IPCollector.sol b/packages/horizon/contracts/payments/collectors/IPCollector.sol new file mode 100644 index 000000000..e5c41e46f --- /dev/null +++ b/packages/horizon/contracts/payments/collectors/IPCollector.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { Authorizable } from "../../utilities/Authorizable.sol"; +import { GraphDirectory } from "../../utilities/GraphDirectory.sol"; +import { IIPCollector } from "../../interfaces/IIPCollector.sol"; +import { IGraphPayments } from "../../interfaces/IGraphPayments.sol"; +import { PPMMath } from "../../libraries/PPMMath.sol"; + +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +/** + * @title IPCollector contract + * @dev Implements the {IIPCollector} interface. + * @notice A payments collector contract that can be used to collect payments using an IAV (Indexing Agreement Voucher). + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +contract IPCollector is EIP712, GraphDirectory, Authorizable, IIPCollector { + using PPMMath for uint256; + + /// @notice The EIP712 typehash for the IndexingAgreementVoucher struct + bytes32 private constant EIP712_IAV_TYPEHASH = + keccak256("IndexingAgreementVoucher(address dataService,address serviceProvider,bytes metadata)"); + + /** + * @notice Constructs a new instance of the IPCollector contract. + * @param _eip712Name The name of the EIP712 domain. + * @param _eip712Version The version of the EIP712 domain. + * @param _controller The address of the Graph controller. + * @param _revokeSignerThawingPeriod The duration (in seconds) in which a signer is thawing before they can be revoked. + */ + constructor( + string memory _eip712Name, + string memory _eip712Version, + address _controller, + uint256 _revokeSignerThawingPeriod + ) EIP712(_eip712Name, _eip712Version) GraphDirectory(_controller) Authorizable(_revokeSignerThawingPeriod) {} + + /** + * @notice Initiate a payment collection through the payments protocol. + * See {IGraphPayments.collect}. + * @dev Caller must be the data service the IAV was issued to. + * @dev The signer of the IAV must be authorized. + * @notice REVERT: This function may revert if ECDSA.recover fails, check ECDSA library for details. + */ + function collect(IGraphPayments.PaymentTypes _paymentType, bytes calldata _data) external view returns (uint256) { + require(_paymentType == IGraphPayments.PaymentTypes.IndexingFee, IPCollectorInvalidPaymentType(_paymentType)); + + (SignedIAV memory signedIAV, uint256 dataServiceCut) = abi.decode(_data, (SignedIAV, uint256)); + require( + signedIAV.iav.dataService == msg.sender, + IPCollectorCallerNotDataService(msg.sender, signedIAV.iav.dataService) + ); + + address signer = _recoverIAVSigner(signedIAV); + address payer = signedIAV.iav.payer; + require(_isAuthorized(payer, signer), IPCollectorInvalidIAVSigner()); + + return _collect(signedIAV.iav, dataServiceCut); + } + + function _collect(IndexingAgreementVoucher memory, uint256) private pure returns (uint256) { + revert("Not implemented"); + } + + /** + * @notice See {IIPCollector.recoverIAVSigner} + */ + function recoverIAVSigner(SignedIAV calldata _signedIAV) external view returns (address) { + return _recoverIAVSigner(_signedIAV); + } + + function _recoverIAVSigner(SignedIAV memory _signedIAV) private view returns (address) { + bytes32 messageHash = _encodeIAV(_signedIAV.iav); + return ECDSA.recover(messageHash, _signedIAV.signature); + } + + /** + * @notice See {IIPCollector.encodeIAV} + */ + function encodeIAV(IndexingAgreementVoucher calldata _iav) external view returns (bytes32) { + return _encodeIAV(_iav); + } + + function _encodeIAV(IndexingAgreementVoucher memory _iav) private view returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode(EIP712_IAV_TYPEHASH, _iav.dataService, _iav.serviceProvider, keccak256(_iav.metadata)) + ) + ); + } +} diff --git a/packages/horizon/contracts/utilities/Authorizable.sol b/packages/horizon/contracts/utilities/Authorizable.sol new file mode 100644 index 000000000..3cdc89e30 --- /dev/null +++ b/packages/horizon/contracts/utilities/Authorizable.sol @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { IAuthorizable } from "../interfaces/IAuthorizable.sol"; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract Authorizable is IAuthorizable { + /// @notice The duration (in seconds) for which an authorization is thawing before it can be revoked + uint256 public immutable REVOKE_AUTHORIZATION_THAWING_PERIOD; + + /// @notice Authorization details for authorizer-signer pairs + mapping(address signer => Authorization authorization) private authorizations; + + /** + * @notice Constructs a new instance of the Authorizable contract. + * @param _revokeAuthorizationThawingPeriod The duration (in seconds) for which an authorization is thawing before it can be revoked. + */ + constructor(uint256 _revokeAuthorizationThawingPeriod) { + REVOKE_AUTHORIZATION_THAWING_PERIOD = _revokeAuthorizationThawingPeriod; + } + + modifier onlyAuthorized(address _signer) { + require(_isAuthorized(msg.sender, _signer), SignerNotAuthorized(msg.sender, _signer)); + _; + } + + /** + * See {IAuthorizable.authorizeSigner}. + */ + function authorizeSigner(address _signer, uint256 _proofDeadline, bytes calldata _proof) external { + require( + authorizations[_signer].authorizer == address(0), + SignerAlreadyAuthorized(authorizations[_signer].authorizer, _signer, authorizations[_signer].revoked) + ); + _verifyAuthorizationProof(_proof, _proofDeadline, _signer); + authorizations[_signer].authorizer = msg.sender; + emit SignerAuthorized(msg.sender, _signer); + } + + /** + * See {IAuthorizable.thawSigner}. + */ + function thawSigner(address _signer) external onlyAuthorized(_signer) { + authorizations[_signer].thawEndTimestamp = block.timestamp + REVOKE_AUTHORIZATION_THAWING_PERIOD; + emit SignerThawing(msg.sender, _signer, authorizations[_signer].thawEndTimestamp); + } + + /** + * See {IAuthorizable.cancelThawSigner}. + */ + function cancelThawSigner(address _signer) external onlyAuthorized(_signer) { + require(authorizations[_signer].thawEndTimestamp > 0, SignerNotThawing(_signer)); + authorizations[_signer].thawEndTimestamp = 0; + emit SignerThawCanceled(msg.sender, _signer, 0); + } + + /** + * See {IAuthorizable.revokeAuthorizedSigner}. + */ + function revokeAuthorizedSigner(address _signer) external onlyAuthorized(_signer) { + uint256 thawEndTimestamp = authorizations[_signer].thawEndTimestamp; + require(thawEndTimestamp > 0, SignerNotThawing(_signer)); + require(thawEndTimestamp <= block.timestamp, SignerStillThawing(block.timestamp, thawEndTimestamp)); + authorizations[_signer].revoked = true; + emit SignerRevoked(msg.sender, _signer); + } + + /** + * See {IAuthorizable.getRevokeAuthorizationThawingPeriod}. + */ + function getRevokeAuthorizationThawingPeriod() external view returns (uint256) { + return REVOKE_AUTHORIZATION_THAWING_PERIOD; + } + + /** + * See {IAuthorizable.getAuthorization}. + */ + function getAuthorization(address _signer) external view returns (Authorization memory) { + return authorizations[_signer]; + } + + function _getAuthorization(address _signer) internal view returns (Authorization memory) { + return authorizations[_signer]; + } + + function _isAuthorized(address _authorizer, address _signer) internal view returns (bool) { + return (authorizations[_signer].authorizer == _authorizer && !authorizations[_signer].revoked); + } + + /** + * @notice Verify the authorization proof provided by the authorizer + * @param _proof The proof provided by the authorizer + * @param _proofDeadline The deadline by which the proof must be verified + * @param _signer The authorization recipient + */ + function _verifyAuthorizationProof(bytes calldata _proof, uint256 _proofDeadline, address _signer) private view { + // Check that the proofDeadline has not passed + require(_proofDeadline > block.timestamp, InvalidSignerProofDeadline(_proofDeadline, block.timestamp)); + + // Generate the message hash + bytes32 messageHash = keccak256( + abi.encodePacked(block.chainid, address(this), "authorizeSignerProof", _proofDeadline, msg.sender) + ); + + // Generate the allegedly signed digest + bytes32 digest = MessageHashUtils.toEthSignedMessageHash(messageHash); + + // Verify that the recovered signer matches the to be authorized signer + require(ECDSA.recover(digest, _proof) == _signer, InvalidSignerProof()); + } + + /** + * @notice Requires the authorization is valid for the msgSender + * @param _signer The address of the signer + * @param _authorization The authorization details to check + */ +} diff --git a/packages/horizon/test/payments/ip-collector/IPCollector.t.sol b/packages/horizon/test/payments/ip-collector/IPCollector.t.sol new file mode 100644 index 000000000..42d3c0686 --- /dev/null +++ b/packages/horizon/test/payments/ip-collector/IPCollector.t.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { ControllerMock } from "../../../contracts/mocks/ControllerMock.sol"; +import { IGraphPayments } from "../../../contracts/interfaces/IGraphPayments.sol"; +import { IIPCollector } from "../../../contracts/interfaces/IIPCollector.sol"; +import { IPCollector } from "../../../contracts/payments/collectors/IPCollector.sol"; +import { AuthorizableTest, AuthorizableHelper } from "../../utilities/Authorizable.t.sol"; +import { Bounder } from "../../utils/Bounder.t.sol"; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; + +contract Controller is ControllerMock, Test { + address invalidContractAddress; + + constructor() ControllerMock(address(0)) { + invalidContractAddress = makeAddr("invalidContractAddress"); + } + + function getContractProxy(bytes32) external view override returns (address) { + return invalidContractAddress; + } +} + +contract IPCollectorAuthorizableTest is AuthorizableTest { + function setUp() public override { + setupAuthorizable(new IPCollector("IPCollector", "1", address(new Controller()), 1)); + } +} + +contract IPCollectorTest is Test, Bounder { + IPCollector ipCollector; + AuthorizableHelper authHelper; + + function setUp() public { + ipCollector = new IPCollector("IPCollector", "1", address(new Controller()), 1); + authHelper = new AuthorizableHelper(ipCollector); + } + + function test_Collect_Revert_WhenInvalidPaymentType(uint8 _unboundedPaymentType, bytes memory _data) public { + uint256 lastPaymentType = uint256(IGraphPayments.PaymentTypes.IndexingRewards); + + IGraphPayments.PaymentTypes _paymentType = IGraphPayments.PaymentTypes( + bound(_unboundedPaymentType, 0, lastPaymentType) + ); + vm.assume(_paymentType != IGraphPayments.PaymentTypes.IndexingFee); + + bytes memory expectedErr = abi.encodeWithSelector( + IIPCollector.IPCollectorInvalidPaymentType.selector, + _paymentType + ); + vm.expectRevert(expectedErr); + ipCollector.collect(_paymentType, _data); + + // If I move this to the top of the function, the rest of the test does not run. Not sure why... + { + vm.expectRevert(); + IGraphPayments.PaymentTypes(lastPaymentType + 1); + } + } + + function test_Collect_Revert_WhenCallerNotDataService( + address _payer, + address _dataService, + address _serviceProvider, + bytes memory _metadata, + bytes memory _signature, + address _notDataService, + uint256 _dataServiceCut + ) public { + vm.assume(_dataService != _notDataService); + + IIPCollector.SignedIAV memory signedIAV = IIPCollector.SignedIAV({ + iav: IIPCollector.IndexingAgreementVoucher({ + payer: _payer, + dataService: _dataService, + serviceProvider: _serviceProvider, + metadata: _metadata + }), + signature: _signature + }); + bytes memory data = __generateCollectData(signedIAV, _dataServiceCut); + + bytes memory expectedErr = abi.encodeWithSelector( + IIPCollector.IPCollectorCallerNotDataService.selector, + _notDataService, + _dataService + ); + vm.expectRevert(expectedErr); + vm.prank(_notDataService); + ipCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_Revert_WhenInvalidSignatureLength( + address _payer, + address _dataService, + address _serviceProvider, + bytes memory _metadata, + bytes memory _signature, + uint256 _dataServiceCut + ) public { + vm.assume(_signature.length != 65); + IIPCollector.SignedIAV memory signedIAV = IIPCollector.SignedIAV({ + iav: IIPCollector.IndexingAgreementVoucher({ + payer: _payer, + dataService: _dataService, + serviceProvider: _serviceProvider, + metadata: _metadata + }), + signature: _signature + }); + bytes memory data = __generateCollectData(signedIAV, _dataServiceCut); + + bytes memory expectedErr = abi.encodeWithSelector( + ECDSA.ECDSAInvalidSignatureLength.selector, + _signature.length + ); + vm.expectRevert(expectedErr); + + vm.prank(_dataService); + ipCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_Revert_WhenInvalidIAVSigner( + address _payer, + address _dataService, + address _serviceProvider, + bytes memory _metadata, + uint256 _unboundedSignerPrivateKey, + uint256 _dataServiceCut + ) public { + uint256 _signerPrivateKey = boundKey(_unboundedSignerPrivateKey); + // _signerPrivateKey is not authorized + IIPCollector.SignedIAV memory signedIAV = _generateSignedIAV( + ipCollector, + IIPCollector.IndexingAgreementVoucher({ + payer: _payer, + dataService: _dataService, + serviceProvider: _serviceProvider, + metadata: _metadata + }), + _signerPrivateKey + ); + bytes memory data = __generateCollectData(signedIAV, _dataServiceCut); + + vm.expectRevert(IIPCollector.IPCollectorInvalidIAVSigner.selector); + vm.prank(_dataService); + ipCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_Revert_WhenNotImplemented( + address _dataService, + address _serviceProvider, + bytes memory _metadata, + uint256 _unboundedSignerPrivateKey, + uint256 _dataServiceCut + ) public { + uint256 signerPrivateKey = boundKey(_unboundedSignerPrivateKey); + address signer = vm.addr(signerPrivateKey); + authHelper.authorizeSignerWithChecks(signer, signerPrivateKey); + IIPCollector.SignedIAV memory signedIAV = _generateSignedIAV( + ipCollector, + IIPCollector.IndexingAgreementVoucher({ + payer: signer, + dataService: _dataService, + serviceProvider: _serviceProvider, + metadata: _metadata + }), + signerPrivateKey + ); + bytes memory data = __generateCollectData(signedIAV, _dataServiceCut); + + vm.expectRevert("Not implemented"); + vm.prank(_dataService); + ipCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function _generateCollectData( + IIPCollector _ipCollector, + IIPCollector.IndexingAgreementVoucher memory iav, + uint256 signerPrivateKey, + uint256 dataServiceCut + ) private view returns (bytes memory) { + return __generateCollectData(_generateSignedIAV(_ipCollector, iav, signerPrivateKey), dataServiceCut); + } + + function _generateSignedIAV( + IIPCollector _ipCollector, + IIPCollector.IndexingAgreementVoucher memory iav, + uint256 _signerPrivateKey + ) private view returns (IIPCollector.SignedIAV memory) { + bytes32 messageHash = _ipCollector.encodeIAV(iav); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPrivateKey, messageHash); + bytes memory signature = abi.encodePacked(r, s, v); + IIPCollector.SignedIAV memory signedIAV = IIPCollector.SignedIAV({ iav: iav, signature: signature }); + + return signedIAV; + } + + function __generateCollectData( + IIPCollector.SignedIAV memory signedIAV, + uint256 dataServiceCut + ) private pure returns (bytes memory) { + return abi.encode(signedIAV, dataServiceCut); + } +} diff --git a/packages/horizon/test/utilities/Authorizable.t.sol b/packages/horizon/test/utilities/Authorizable.t.sol new file mode 100644 index 000000000..bf15243de --- /dev/null +++ b/packages/horizon/test/utilities/Authorizable.t.sol @@ -0,0 +1,421 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { Authorizable } from "../../contracts/utilities/Authorizable.sol"; +import { IAuthorizable } from "../../contracts/interfaces/IAuthorizable.sol"; +import { Bounder } from "../utils/Bounder.t.sol"; + +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; + +contract AuthorizableTest is Test, Bounder { + IAuthorizable public authorizable; + AuthorizableHelper authHelper; + + modifier withFuzzyThaw(uint256 _thawPeriod) { + // Max thaw period is 1 year to allow for thawing tests + _thawPeriod = bound(_thawPeriod, 1, 60 * 60 * 24 * 365); + setupAuthorizable(new Authorizable(_thawPeriod)); + _; + } + + function setUp() public virtual { + setupAuthorizable(new Authorizable(0)); + } + + function setupAuthorizable(IAuthorizable _authorizable) internal { + authorizable = _authorizable; + authHelper = new AuthorizableHelper(authorizable); + } + + function test_AuthorizeSigner(uint256 _unboundedKey, address _authorizer) public { + vm.assume(_authorizer != address(0)); + uint256 signerKey = boundKey(_unboundedKey); + + authHelper.authorizeSignerWithChecks(_authorizer, signerKey); + } + + function test_AuthorizeSigner_Revert_WhenAlreadyAuthorized( + uint256[] memory _unboundedAuthorizers, + uint256 _unboundedKey + ) public { + vm.assume(_unboundedAuthorizers.length > 1); + address[] memory authorizers = new address[](_unboundedAuthorizers.length); + for (uint256 i = 0; i < authorizers.length; i++) { + authorizers[i] = boundAddr(_unboundedAuthorizers[i]); + } + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + + address validAuthorizer = authorizers[0]; + authHelper.authorizeSignerWithChecks(validAuthorizer, signerKey); + + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.SignerAlreadyAuthorized.selector, + validAuthorizer, + signer, + false + ); + + for (uint256 i = 0; i < authorizers.length; i++) { + vm.expectRevert(expectedErr); + vm.prank(authorizers[i]); + authorizable.authorizeSigner(signer, 0, ""); + } + } + + function test_AuthorizeSigner_Revert_WhenInvalidProofDeadline(uint256 _proofDeadline, uint256 _now) public { + _proofDeadline = bound(_proofDeadline, 0, _now); + vm.warp(_now); + + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.InvalidSignerProofDeadline.selector, + _proofDeadline, + _now + ); + vm.expectRevert(expectedErr); + authorizable.authorizeSigner(address(0), _proofDeadline, ""); + } + + function test_AuthorizeSigner_Revert_WhenInvalidSignerProof( + uint256 _now, + uint256 _unboundedAuthorizer, + uint256 _unboundedKey, + uint256 _proofDeadline, + uint256 _chainid, + uint256 _wrong + ) public { + _now = bound(_now, 0, type(uint256).max - 1); + address authorizer = boundAddr(_unboundedAuthorizer); + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + _proofDeadline = boundTimestampMin(_proofDeadline, _now + 1); + vm.assume(_wrong != _proofDeadline); + _chainid = boundChainId(_chainid); + vm.assume(_wrong != _chainid); + (uint256 wrongKey, address wrongAddress) = boundAddrAndKey(_wrong); + vm.assume(wrongKey != signerKey); + vm.assume(wrongAddress != authorizer); + + vm.chainId(_chainid); + vm.warp(_now); + + bytes memory validProof = authHelper.generateAuthorizationProof( + _chainid, + address(authorizable), + _proofDeadline, + authorizer, + signerKey + ); + bytes[5] memory proofs = [ + authHelper.generateAuthorizationProof(_wrong, address(authorizable), _proofDeadline, authorizer, signerKey), + authHelper.generateAuthorizationProof(_chainid, wrongAddress, _proofDeadline, authorizer, signerKey), + authHelper.generateAuthorizationProof(_chainid, address(authorizable), _wrong, authorizer, signerKey), + authHelper.generateAuthorizationProof( + _chainid, + address(authorizable), + _proofDeadline, + wrongAddress, + signerKey + ), + authHelper.generateAuthorizationProof(_chainid, address(authorizable), _proofDeadline, authorizer, wrongKey) + ]; + + for (uint256 i = 0; i < proofs.length; i++) { + vm.expectRevert(IAuthorizable.InvalidSignerProof.selector); + vm.prank(authorizer); + authorizable.authorizeSigner(signer, _proofDeadline, proofs[i]); + } + + vm.prank(authorizer); + authorizable.authorizeSigner(signer, _proofDeadline, validProof); + authHelper.assertAuthorized(signer, authorizer); + } + + function test_ThawSigner(address _authorizer, uint256 _unboundedKey, uint256 _thaw) public withFuzzyThaw(_thaw) { + vm.assume(_authorizer != address(0)); + uint256 signerKey = boundKey(_unboundedKey); + + authHelper.authorizeAndThawSignerWithChecks(_authorizer, signerKey); + } + + function test_ThawSigner_Revert_WhenNotAuthorized(address _authorizer, address _signer) public { + vm.assume(_authorizer != address(0)); + vm.assume(_signer != address(0)); + + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.SignerNotAuthorized.selector, + _authorizer, + _signer + ); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.thawSigner(_signer); + } + + function test_ThawSigner_Revert_WhenAuthorizationRevoked( + address _authorizer, + uint256 _unboundedKey, + uint256 _thaw + ) public withFuzzyThaw(_thaw) { + vm.assume(_authorizer != address(0)); + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + authHelper.authorizeAndRevokeSignerWithChecks(_authorizer, signerKey); + + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.SignerNotAuthorized.selector, + _authorizer, + signer + ); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.thawSigner(signer); + } + + function test_CancelThawSigner( + address _authorizer, + uint256 _unboundedKey, + uint256 _thaw + ) public withFuzzyThaw(_thaw) { + vm.assume(_authorizer != address(0)); + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + + authHelper.authorizeAndThawSignerWithChecks(_authorizer, signerKey); + vm.expectEmit(address(authorizable)); + emit IAuthorizable.SignerThawCanceled(_authorizer, signer, 0); + vm.prank(_authorizer); + authorizable.cancelThawSigner(signer); + + authHelper.assertAuthorized(signer, _authorizer); + } + + function test_CancelThawSigner_Revert_When_NotAuthorized(address _authorizer, address _signer) public { + vm.assume(_authorizer != address(0)); + vm.assume(_signer != address(0)); + + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.SignerNotAuthorized.selector, + _authorizer, + _signer + ); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.cancelThawSigner(_signer); + } + + function test_CancelThawSigner_Revert_WhenAuthorizationRevoked( + address _authorizer, + uint256 _unboundedKey, + uint256 _thaw + ) public withFuzzyThaw(_thaw) { + vm.assume(_authorizer != address(0)); + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + authHelper.authorizeAndRevokeSignerWithChecks(_authorizer, signerKey); + + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.SignerNotAuthorized.selector, + _authorizer, + signer + ); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.cancelThawSigner(signer); + } + + function test_CancelThawSigner_Revert_When_NotThawing(address _authorizer, uint256 _unboundedKey) public { + vm.assume(_authorizer != address(0)); + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + + authHelper.authorizeSignerWithChecks(_authorizer, signerKey); + + bytes memory expectedErr = abi.encodeWithSelector(IAuthorizable.SignerNotThawing.selector, signer); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.cancelThawSigner(signer); + } + + function test_RevokeAuthorizedSigner( + address _authorizer, + uint256 _unboundedKey, + uint256 _thaw + ) public withFuzzyThaw(_thaw) { + vm.assume(_authorizer != address(0)); + uint256 signerKey = boundKey(_unboundedKey); + + authHelper.authorizeAndRevokeSignerWithChecks(_authorizer, signerKey); + } + + function test_RevokeAuthorizedSigner_Revert_WhenNotAuthorized(address _authorizer, address _signer) public { + vm.assume(_authorizer != address(0)); + vm.assume(_signer != address(0)); + + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.SignerNotAuthorized.selector, + _authorizer, + _signer + ); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.revokeAuthorizedSigner(_signer); + } + + function test_RevokeAuthorizedSigner_Revert_WhenAuthorizationRevoked( + address _authorizer, + uint256 _unboundedKey, + uint256 _thaw + ) public withFuzzyThaw(_thaw) { + vm.assume(_authorizer != address(0)); + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + authHelper.authorizeAndRevokeSignerWithChecks(_authorizer, signerKey); + + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.SignerNotAuthorized.selector, + _authorizer, + signer + ); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.revokeAuthorizedSigner(signer); + } + + function test_RevokeAuthorizedSigner_Revert_WhenNotThawing(address _authorizer, uint256 _unboundedKey) public { + vm.assume(_authorizer != address(0)); + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + + authHelper.authorizeSignerWithChecks(_authorizer, signerKey); + bytes memory expectedErr = abi.encodeWithSelector(IAuthorizable.SignerNotThawing.selector, signer); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.revokeAuthorizedSigner(signer); + } + + function test_RevokeAuthorizedSigner_Revert_WhenStillThawing( + address _authorizer, + uint256 _unboundedKey, + uint256 _thaw, + uint256 _skip + ) public withFuzzyThaw(_thaw) { + vm.assume(_authorizer != address(0)); + (uint256 signerKey, address signer) = boundAddrAndKey(_unboundedKey); + + authHelper.authorizeAndThawSignerWithChecks(_authorizer, signerKey); + + _skip = bound(_skip, 0, authorizable.getRevokeAuthorizationThawingPeriod() - 1); + skip(_skip); + bytes memory expectedErr = abi.encodeWithSelector( + IAuthorizable.SignerStillThawing.selector, + block.timestamp, + block.timestamp - _skip + authorizable.getRevokeAuthorizationThawingPeriod() + ); + vm.expectRevert(expectedErr); + vm.prank(_authorizer); + authorizable.revokeAuthorizedSigner(signer); + } +} + +contract AuthorizableHelper is Test { + IAuthorizable internal authorizable; + + constructor(IAuthorizable _authorizable) { + authorizable = _authorizable; + } + + function authorizeAndThawSignerWithChecks(address _authorizer, uint256 _signerKey) public { + address signer = vm.addr(_signerKey); + authorizeSignerWithChecks(_authorizer, _signerKey); + + uint256 thawEndTimestamp = block.timestamp + authorizable.getRevokeAuthorizationThawingPeriod(); + vm.expectEmit(address(authorizable)); + emit IAuthorizable.SignerThawing(_authorizer, signer, thawEndTimestamp); + vm.prank(_authorizer); + authorizable.thawSigner(signer); + + assertThawing(signer, _authorizer, thawEndTimestamp); + } + + function authorizeAndRevokeSignerWithChecks(address _authorizer, uint256 _signerKey) public { + address signer = vm.addr(_signerKey); + authorizeAndThawSignerWithChecks(_authorizer, _signerKey); + skip(authorizable.getRevokeAuthorizationThawingPeriod() + 1); + vm.expectEmit(address(authorizable)); + emit IAuthorizable.SignerRevoked(_authorizer, signer); + vm.prank(_authorizer); + authorizable.revokeAuthorizedSigner(signer); + + assertAuthorizationRevoked(signer); + } + + function authorizeSignerWithChecks(address _authorizer, uint256 _signerKey) public { + address signer = vm.addr(_signerKey); + assertNotAuthorized(signer); + + uint256 proofDeadline = block.timestamp + 1; + bytes memory proof = generateAuthorizationProof( + block.chainid, + address(authorizable), + proofDeadline, + _authorizer, + _signerKey + ); + vm.expectEmit(address(authorizable)); + emit IAuthorizable.SignerAuthorized(_authorizer, signer); + vm.prank(_authorizer); + authorizable.authorizeSigner(signer, proofDeadline, proof); + + assertAuthorized(signer, _authorizer); + } + + function assertNotAuthorized(address _signer) public view { + IAuthorizable.Authorization memory authorization = authorizable.getAuthorization(_signer); + assertEq(authorization.authorizer, address(0), "Should not be authorized"); + assertEq(authorization.thawEndTimestamp, 0); + } + + function assertAuthorized(address _signer, address _authorizer, uint256 _expectedThawEnd) public view { + IAuthorizable.Authorization memory authorization = authorizable.getAuthorization(_signer); + + assertEq(authorization.authorizer, _authorizer, "Should be authorized"); + if (_expectedThawEnd == 0) { + assertEq(authorization.thawEndTimestamp, 0, "Should not be thawing"); + } else { + assertGt(authorizable.getRevokeAuthorizationThawingPeriod(), 0, "Thaw period should be greater than 0"); + assertEq(authorization.thawEndTimestamp, _expectedThawEnd, "Should be thawing"); + } + } + + function assertAuthorized(address _signer, address _authorizer) public view { + assertAuthorized(_signer, _authorizer, 0); + } + + function assertThawing(address _signer, address _authorizer, uint256 _expectedThawEnd) public view { + assertAuthorized(_signer, _authorizer, _expectedThawEnd); + } + + function assertAuthorizationRevoked(address _signer) public view { + IAuthorizable.Authorization memory authorization = authorizable.getAuthorization(_signer); + assertTrue(authorization.revoked); + assertNotEq(authorization.authorizer, address(0), "Should be authorized"); + assertGt(authorization.thawEndTimestamp, 0); + } + + function generateAuthorizationProof( + uint256 _chainId, + address _verifyingContract, + uint256 _proofDeadline, + address _authorizer, + uint256 _signerPrivateKey + ) public pure returns (bytes memory) { + // Generate the message hash + bytes32 messageHash = keccak256( + abi.encodePacked(_chainId, _verifyingContract, "authorizeSignerProof", _proofDeadline, _authorizer) + ); + + // Generate the digest to sign + bytes32 digest = MessageHashUtils.toEthSignedMessageHash(messageHash); + + // Sign the digest + (uint8 v, bytes32 r, bytes32 s) = vm.sign(_signerPrivateKey, digest); + + // Encode the signature + return abi.encodePacked(r, s, v); + } +} diff --git a/packages/horizon/test/utils/Bounder.t.sol b/packages/horizon/test/utils/Bounder.t.sol new file mode 100644 index 000000000..44e977f57 --- /dev/null +++ b/packages/horizon/test/utils/Bounder.t.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +contract Bounder is Test { + uint256 constant SECP256K1_CURVE_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + + function boundAddrAndKey(uint256 _value) internal pure returns (uint256, address) { + uint256 signerKey = bound(_value, 1, SECP256K1_CURVE_ORDER - 1); + return (signerKey, vm.addr(signerKey)); + } + + function boundAddr(uint256 _value) internal pure returns (address) { + (, address addr) = boundAddrAndKey(_value); + return addr; + } + + function boundKey(uint256 _value) internal pure returns (uint256) { + (uint256 key, ) = boundAddrAndKey(_value); + return key; + } + + function boundChainId(uint256 _value) internal pure returns (uint256) { + return bound(_value, 1, (2 ^ 64) - 1); + } + + function boundTimestampMin(uint256 _value, uint256 _min) internal pure returns (uint256) { + return bound(_value, _min, type(uint256).max); + } +}