diff --git a/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol b/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol index a1c38a99a..7b978794b 100644 --- a/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol +++ b/packages/horizon/contracts/data-service/extensions/DataServiceFees.sol @@ -5,6 +5,7 @@ import { IDataServiceFees } from "../interfaces/IDataServiceFees.sol"; import { ProvisionTracker } from "../libraries/ProvisionTracker.sol"; import { LinkedList } from "../../libraries/LinkedList.sol"; +import { StakeClaims } from "../libraries/StakeClaims.sol"; import { DataService } from "../DataService.sol"; import { DataServiceFeesV1Storage } from "./DataServiceFeesStorage.sol"; @@ -41,23 +42,17 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat * @param _unlockTimestamp The timestamp when the tokens can be released */ function _lockStake(address _serviceProvider, uint256 _tokens, uint256 _unlockTimestamp) internal { - require(_tokens != 0, DataServiceFeesZeroTokens()); - feesProvisionTracker.lock(_graphStaking(), _serviceProvider, _tokens, _delegationRatio); - - LinkedList.List storage claimsList = claimsLists[_serviceProvider]; - - // Save item and add to list - bytes32 claimId = _buildStakeClaimId(_serviceProvider, claimsList.nonce); - claims[claimId] = StakeClaim({ - tokens: _tokens, - createdAt: block.timestamp, - releasableAt: _unlockTimestamp, - nextClaim: bytes32(0) - }); - if (claimsList.count != 0) claims[claimsList.tail].nextClaim = claimId; - claimsList.addTail(claimId); - - emit StakeClaimLocked(_serviceProvider, claimId, _tokens, _unlockTimestamp); + StakeClaims.lockStake( + feesProvisionTracker, + claims, + claimsLists, + _graphStaking(), + address(this), + _delegationRatio, + _serviceProvider, + _tokens, + _unlockTimestamp + ); } /** @@ -80,7 +75,7 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat _numClaimsToRelease ); - emit StakeClaimsReleased(_serviceProvider, claimsReleased, abi.decode(data, (uint256))); + emit StakeClaims.StakeClaimsReleased(_serviceProvider, claimsReleased, abi.decode(data, (uint256))); } /** @@ -92,23 +87,7 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat * @return The updated accumulator data */ function _processStakeClaim(bytes32 _claimId, bytes memory _acc) private returns (bool, bytes memory) { - StakeClaim memory claim = _getStakeClaim(_claimId); - - // early exit - if (claim.releasableAt > block.timestamp) { - return (true, LinkedList.NULL_BYTES); - } - - // decode - (uint256 tokensClaimed, address serviceProvider) = abi.decode(_acc, (uint256, address)); - - // process - feesProvisionTracker.release(serviceProvider, claim.tokens); - emit StakeClaimReleased(serviceProvider, _claimId, claim.tokens, claim.releasableAt); - - // encode - _acc = abi.encode(tokensClaimed + claim.tokens, serviceProvider); - return (false, _acc); + return StakeClaims.processStakeClaim(feesProvisionTracker, claims, _claimId, _acc); } /** @@ -117,18 +96,7 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat * @param _claimId The ID of the stake claim to delete */ function _deleteStakeClaim(bytes32 _claimId) private { - delete claims[_claimId]; - } - - /** - * @notice Gets the details of a stake claim - * @param _claimId The ID of the stake claim - * @return The stake claim details - */ - function _getStakeClaim(bytes32 _claimId) private view returns (StakeClaim memory) { - StakeClaim memory claim = claims[_claimId]; - require(claim.createdAt != 0, DataServiceFeesClaimNotFound(_claimId)); - return claim; + StakeClaims.deleteStakeClaim(claims, _claimId); } /** @@ -138,16 +106,6 @@ abstract contract DataServiceFees is DataService, DataServiceFeesV1Storage, IDat * @return The next stake claim ID */ function _getNextStakeClaim(bytes32 _claimId) private view returns (bytes32) { - return claims[_claimId].nextClaim; - } - - /** - * @notice Builds a stake claim ID - * @param _serviceProvider The address of the service provider - * @param _nonce A nonce of the stake claim - * @return The stake claim ID - */ - function _buildStakeClaimId(address _serviceProvider, uint256 _nonce) private view returns (bytes32) { - return keccak256(abi.encodePacked(address(this), _serviceProvider, _nonce)); + return StakeClaims.getNextStakeClaim(claims, _claimId); } } diff --git a/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol b/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol index 30d1aa4ee..795206151 100644 --- a/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol +++ b/packages/horizon/contracts/data-service/extensions/DataServiceFeesStorage.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.27; -import { IDataServiceFees } from "../interfaces/IDataServiceFees.sol"; +import { StakeClaims } from "../libraries/StakeClaims.sol"; import { LinkedList } from "../../libraries/LinkedList.sol"; @@ -15,7 +15,7 @@ abstract contract DataServiceFeesV1Storage { mapping(address serviceProvider => uint256 tokens) public feesProvisionTracker; /// @notice List of all locked stake claims to be released to service providers - mapping(bytes32 claimId => IDataServiceFees.StakeClaim claim) public claims; + mapping(bytes32 claimId => StakeClaims.StakeClaim claim) public claims; /// @notice Service providers registered in the data service mapping(address serviceProvider => LinkedList.List list) public claimsLists; diff --git a/packages/horizon/contracts/data-service/interfaces/IDataServiceFees.sol b/packages/horizon/contracts/data-service/interfaces/IDataServiceFees.sol index 9d235f4f7..58dfd95f5 100644 --- a/packages/horizon/contracts/data-service/interfaces/IDataServiceFees.sol +++ b/packages/horizon/contracts/data-service/interfaces/IDataServiceFees.sol @@ -22,70 +22,6 @@ import { IDataService } from "./IDataService.sol"; * bugs. We may have an active bug bounty program. */ interface IDataServiceFees is IDataService { - /** - * @notice A stake claim, representing provisioned stake that gets locked - * to be released to a service provider. - * @dev StakeClaims are stored in linked lists by service provider, ordered by - * creation timestamp. - * @param tokens The amount of tokens to be locked in the claim - * @param createdAt The timestamp when the claim was created - * @param releasableAt The timestamp when the tokens can be released - * @param nextClaim The next claim in the linked list - */ - struct StakeClaim { - uint256 tokens; - uint256 createdAt; - uint256 releasableAt; - bytes32 nextClaim; - } - - /** - * @notice Emitted when a stake claim is created and stake is locked. - * @param serviceProvider The address of the service provider - * @param claimId The id of the stake claim - * @param tokens The amount of tokens to lock in the claim - * @param unlockTimestamp The timestamp when the tokens can be released - */ - event StakeClaimLocked( - address indexed serviceProvider, - bytes32 indexed claimId, - uint256 tokens, - uint256 unlockTimestamp - ); - - /** - * @notice Emitted when a stake claim is released and stake is unlocked. - * @param serviceProvider The address of the service provider - * @param claimId The id of the stake claim - * @param tokens The amount of tokens released - * @param releasableAt The timestamp when the tokens were released - */ - event StakeClaimReleased( - address indexed serviceProvider, - bytes32 indexed claimId, - uint256 tokens, - uint256 releasableAt - ); - - /** - * @notice Emitted when a series of stake claims are released. - * @param serviceProvider The address of the service provider - * @param claimsCount The number of stake claims being released - * @param tokensReleased The total amount of tokens being released - */ - event StakeClaimsReleased(address indexed serviceProvider, uint256 claimsCount, uint256 tokensReleased); - - /** - * @notice Thrown when attempting to get a stake claim that does not exist. - * @param claimId The id of the stake claim - */ - error DataServiceFeesClaimNotFound(bytes32 claimId); - - /** - * @notice Emitted when trying to lock zero tokens in a stake claim - */ - error DataServiceFeesZeroTokens(); - /** * @notice Releases expired stake claims for the caller. * @dev This function is only meant to be called if the service provider has enough diff --git a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol new file mode 100644 index 000000000..5269d7ec4 --- /dev/null +++ b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { ProvisionTracker } from "./ProvisionTracker.sol"; +import { IHorizonStaking } from "../../interfaces/IHorizonStaking.sol"; +import { LinkedList } from "../../libraries/LinkedList.sol"; + +library StakeClaims { + using ProvisionTracker for mapping(address => uint256); + using LinkedList for LinkedList.List; + + /** + * @notice A stake claim, representing provisioned stake that gets locked + * to be released to a service provider. + * @dev StakeClaims are stored in linked lists by service provider, ordered by + * creation timestamp. + * @param tokens The amount of tokens to be locked in the claim + * @param createdAt The timestamp when the claim was created + * @param releasableAt The timestamp when the tokens can be released + * @param nextClaim The next claim in the linked list + */ + struct StakeClaim { + uint256 tokens; + uint256 createdAt; + uint256 releasableAt; + bytes32 nextClaim; + } + + /** + * @notice Emitted when a stake claim is created and stake is locked. + * @param serviceProvider The address of the service provider + * @param claimId The id of the stake claim + * @param tokens The amount of tokens to lock in the claim + * @param unlockTimestamp The timestamp when the tokens can be released + */ + event StakeClaimLocked( + address indexed serviceProvider, + bytes32 indexed claimId, + uint256 tokens, + uint256 unlockTimestamp + ); + + /** + * @notice Emitted when a stake claim is released and stake is unlocked. + * @param serviceProvider The address of the service provider + * @param claimId The id of the stake claim + * @param tokens The amount of tokens released + * @param releasableAt The timestamp when the tokens were released + */ + event StakeClaimReleased( + address indexed serviceProvider, + bytes32 indexed claimId, + uint256 tokens, + uint256 releasableAt + ); + + /** + * @notice Emitted when a series of stake claims are released. + * @param serviceProvider The address of the service provider + * @param claimsCount The number of stake claims being released + * @param tokensReleased The total amount of tokens being released + */ + event StakeClaimsReleased(address indexed serviceProvider, uint256 claimsCount, uint256 tokensReleased); + + /** + * @notice Thrown when attempting to get a stake claim that does not exist. + * @param claimId The id of the stake claim + */ + error StakeClaimsClaimNotFound(bytes32 claimId); + + /** + * @notice Emitted when trying to lock zero tokens in a stake claim + */ + error StakeClaimsZeroTokens(); + + /** + * @notice Locks stake for a service provider to back a payment. + * Creates a stake claim, which is stored in a linked list by service provider. + * @dev Requirements: + * - The associated provision must have enough available tokens to lock the stake. + * + * Emits a {StakeClaimLocked} event. + * + * @param feesProvisionTracker The mapping that tracks the provision tokens for each service provider + * @param claims The mapping that stores stake claims by their ID + * @param claimsLists The mapping that stores linked lists of stake claims by service provider + * @param graphStaking The Horizon staking contract used to lock the tokens + * @param _dataService The address of the data service + * @param _delegationRatio The delegation ratio to use for the stake claim + * @param _serviceProvider The address of the service provider + * @param _tokens The amount of tokens to lock in the claim + * @param _unlockTimestamp The timestamp when the tokens can be released + */ + function lockStake( + mapping(address => uint256) storage feesProvisionTracker, + mapping(bytes32 => StakeClaim) storage claims, + mapping(address serviceProvider => LinkedList.List list) storage claimsLists, + IHorizonStaking graphStaking, + address _dataService, + uint32 _delegationRatio, + address _serviceProvider, + uint256 _tokens, + uint256 _unlockTimestamp + ) external { + require(_tokens != 0, StakeClaimsZeroTokens()); + feesProvisionTracker.lock(graphStaking, _serviceProvider, _tokens, _delegationRatio); + + LinkedList.List storage claimsList = claimsLists[_serviceProvider]; + + // Save item and add to list + bytes32 claimId = _buildStakeClaimId(_dataService, _serviceProvider, claimsList.nonce); + claims[claimId] = StakeClaim({ + tokens: _tokens, + createdAt: block.timestamp, + releasableAt: _unlockTimestamp, + nextClaim: bytes32(0) + }); + if (claimsList.count != 0) claims[claimsList.tail].nextClaim = claimId; + claimsList.addTail(claimId); + + emit StakeClaimLocked(_serviceProvider, claimId, _tokens, _unlockTimestamp); + } + + /** + * @notice Processes a stake claim, releasing the tokens if the claim has expired. + * @dev This function is used as a callback in the stake claims linked list traversal. + * @param feesProvisionTracker The mapping that tracks the provision tokens for each service provider. + * @param claims The mapping that stores stake claims by their ID. + * @param _claimId The ID of the stake claim to process. + * @param _acc The accumulator data, which contains the total tokens claimed and the service provider address. + * @return Whether the stake claim is still locked, indicating that the traversal should continue or stop. + * @return The updated accumulator data + */ + function processStakeClaim( + mapping(address serviceProvider => uint256 tokens) storage feesProvisionTracker, + mapping(bytes32 claimId => StakeClaim claim) storage claims, + bytes32 _claimId, + bytes memory _acc + ) external returns (bool, bytes memory) { + StakeClaim memory claim = claims[_claimId]; + require(claim.createdAt != 0, StakeClaimsClaimNotFound(_claimId)); + + // early exit + if (claim.releasableAt > block.timestamp) { + return (true, LinkedList.NULL_BYTES); + } + + // decode + (uint256 tokensClaimed, address serviceProvider) = abi.decode(_acc, (uint256, address)); + + // process + feesProvisionTracker.release(serviceProvider, claim.tokens); + emit StakeClaimReleased(serviceProvider, _claimId, claim.tokens, claim.releasableAt); + + // encode + _acc = abi.encode(tokensClaimed + claim.tokens, serviceProvider); + return (false, _acc); + } + + /** + * @notice Deletes a stake claim. + * @dev This function is used as a callback in the stake claims linked list traversal. + * @param claims The mapping that stores stake claims by their ID + * @param claimId The ID of the stake claim to delete + */ + function deleteStakeClaim(mapping(bytes32 claimId => StakeClaim claim) storage claims, bytes32 claimId) external { + delete claims[claimId]; + } + + /** + * @notice Gets the next stake claim in the linked list + * @dev This function is used as a callback in the stake claims linked list traversal. + * @param claims The mapping that stores stake claims by their ID + * @param claimId The ID of the stake claim + * @return The next stake claim ID + */ + function getNextStakeClaim( + mapping(bytes32 claimId => StakeClaim claim) storage claims, + bytes32 claimId + ) external view returns (bytes32) { + return claims[claimId].nextClaim; + } + + /** + * @notice Builds a stake claim ID + * @param dataService The address of the data service + * @param serviceProvider The address of the service provider + * @param nonce A nonce of the stake claim + * @return The stake claim ID + */ + function buildStakeClaimId( + address dataService, + address serviceProvider, + uint256 nonce + ) public pure returns (bytes32) { + return _buildStakeClaimId(dataService, serviceProvider, nonce); + } + + /** + * @notice Builds a stake claim ID + * @param _dataService The address of the data service + * @param _serviceProvider The address of the service provider + * @param _nonce A nonce of the stake claim + * @return The stake claim ID + */ + function _buildStakeClaimId( + address _dataService, + address _serviceProvider, + uint256 _nonce + ) internal pure returns (bytes32) { + return keccak256(abi.encodePacked(_dataService, _serviceProvider, _nonce)); + } +} diff --git a/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol b/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol index 699394c8d..a8f5de172 100644 --- a/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol +++ b/packages/horizon/contracts/data-service/utilities/ProvisionManager.sol @@ -124,9 +124,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param serviceProvider The address of the service provider. */ modifier onlyValidProvision(address serviceProvider) virtual { - IHorizonStaking.Provision memory provision = _getProvision(serviceProvider); - _checkProvisionTokens(provision); - _checkProvisionParameters(provision, false); + _requireValidProvision(serviceProvider); _; } @@ -176,7 +174,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _max The maximum allowed value for the provision tokens. */ function _setProvisionTokensRange(uint256 _min, uint256 _max) internal { - require(_min <= _max, ProvisionManagerInvalidRange(_min, _max)); + _requireLTE(_min, _max); _minimumProvisionTokens = _min; _maximumProvisionTokens = _max; emit ProvisionTokensRangeSet(_min, _max); @@ -188,7 +186,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _max The maximum allowed value for the max verifier cut. */ function _setVerifierCutRange(uint32 _min, uint32 _max) internal { - require(_min <= _max, ProvisionManagerInvalidRange(_min, _max)); + _requireLTE(_min, _max); require(PPMMath.isValidPPM(_max), ProvisionManagerInvalidRange(_min, _max)); _minimumVerifierCut = _min; _maximumVerifierCut = _max; @@ -201,12 +199,23 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _max The maximum allowed value for the thawing period. */ function _setThawingPeriodRange(uint64 _min, uint64 _max) internal { - require(_min <= _max, ProvisionManagerInvalidRange(_min, _max)); + _requireLTE(_min, _max); _minimumThawingPeriod = _min; _maximumThawingPeriod = _max; emit ThawingPeriodRangeSet(_min, _max); } + /** + * @notice Checks if a provision of a service provider is valid according + * to the parameter ranges established. + * @param _serviceProvider The address of the service provider. + */ + function _requireValidProvision(address _serviceProvider) internal view { + IHorizonStaking.Provision memory provision = _getProvision(_serviceProvider); + _checkProvisionTokens(provision); + _checkProvisionParameters(provision, false); + } + // -- checks -- /** @@ -214,8 +223,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _serviceProvider The address of the service provider. */ function _checkProvisionTokens(address _serviceProvider) internal view virtual { - IHorizonStaking.Provision memory provision = _getProvision(_serviceProvider); - _checkProvisionTokens(provision); + _checkProvisionTokens(_getProvision(_serviceProvider)); } /** @@ -238,8 +246,7 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa * @param _checkPending If true, checks the pending provision parameters. */ function _checkProvisionParameters(address _serviceProvider, bool _checkPending) internal view virtual { - IHorizonStaking.Provision memory provision = _getProvision(_serviceProvider); - _checkProvisionParameters(provision, _checkPending); + _checkProvisionParameters(_getProvision(_serviceProvider), _checkPending); } /** @@ -320,4 +327,13 @@ abstract contract ProvisionManager is Initializable, GraphDirectory, ProvisionMa function _checkValueInRange(uint256 _value, uint256 _min, uint256 _max, bytes memory _revertMessage) private pure { require(_value.isInRange(_min, _max), ProvisionManagerInvalidValue(_revertMessage, _value, _min, _max)); } + + /** + * @notice Requires that a value is less than or equal to another value. + * @param _a The value to check. + * @param _b The value to compare against. + */ + function _requireLTE(uint256 _a, uint256 _b) private pure { + require(_a <= _b, ProvisionManagerInvalidRange(_a, _b)); + } } diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol new file mode 100644 index 000000000..9f3c3a1bf --- /dev/null +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -0,0 +1,430 @@ +// 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 {RecurringCollector} 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 + * recurrent payments. + */ +interface IRecurringCollector is IAuthorizable, IPaymentsCollector { + /// @notice The state of an agreement + enum AgreementState { + NotAccepted, + Accepted, + CanceledByServiceProvider, + CanceledByPayer + } + + /// @notice The party that can cancel an agreement + enum CancelAgreementBy { + ServiceProvider, + Payer, + ThirdParty + } + + /** + * @notice A representation of a signed Recurring Collection Agreement (RCA) + * @param rca The Recurring Collection Agreement to be signed + * @param signature The signature of the RCA - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) + */ + struct SignedRCA { + RecurringCollectionAgreement rca; + bytes signature; + } + + /** + * @notice The Recurring Collection Agreement (RCA) + * @param agreementId The agreement ID of the RCA + * @param deadline The deadline for accepting the RCA + * @param endsAt The timestamp when the agreement ends + * @param payer The address of the payer the RCA was issued by + * @param dataService The address of the data service the RCA was issued to + * @param serviceProvider The address of the service provider the RCA was issued to + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * on top of the amount allowed for subsequent collections + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * except for the first collection + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param metadata Arbitrary metadata to extend functionality if a data service requires it + * + */ + struct RecurringCollectionAgreement { + bytes16 agreementId; + uint64 deadline; + uint64 endsAt; + address payer; + address dataService; + address serviceProvider; + uint256 maxInitialTokens; + uint256 maxOngoingTokensPerSecond; + uint32 minSecondsPerCollection; + uint32 maxSecondsPerCollection; + bytes metadata; + } + + /** + * @notice A representation of a signed Recurring Collection Agreement Update (RCAU) + * @param rcau The Recurring Collection Agreement Update to be signed + * @param signature The signature of the RCAU - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) + */ + struct SignedRCAU { + RecurringCollectionAgreementUpdate rcau; + bytes signature; + } + + /** + * @notice The Recurring Collection Agreement Update (RCAU) + * @param agreementId The agreement ID of the RCAU + * @param deadline The deadline for upgrading the RCA + * @param endsAt The timestamp when the agreement ends + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * on top of the amount allowed for subsequent collections + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * except for the first collection + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param metadata Arbitrary metadata to extend functionality if a data service requires it + */ + struct RecurringCollectionAgreementUpdate { + bytes16 agreementId; + uint64 deadline; + uint64 endsAt; + uint256 maxInitialTokens; + uint256 maxOngoingTokensPerSecond; + uint32 minSecondsPerCollection; + uint32 maxSecondsPerCollection; + bytes metadata; + } + + /** + * @notice The data for an agreement + * @dev This struct is used to store the data of an agreement in the contract + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param acceptedAt The timestamp when the agreement was accepted + * @param lastCollectionAt The timestamp when the agreement was last collected at + * @param endsAt The timestamp when the agreement ends + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * on top of the amount allowed for subsequent collections + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * except for the first collection + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + * @param canceledAt The timestamp when the agreement was canceled + * @param state The state of the agreement + */ + struct AgreementData { + // The address of the data service + address dataService; + // The address of the payer + address payer; + // The address of the service provider + address serviceProvider; + // The timestamp when the agreement was accepted + uint64 acceptedAt; + // The timestamp when the agreement was last collected at + uint64 lastCollectionAt; + // The timestamp when the agreement ends + uint64 endsAt; + // The maximum amount of tokens that can be collected in the first collection + // on top of the amount allowed for subsequent collections + uint256 maxInitialTokens; + // The maximum amount of tokens that can be collected per second + // except for the first collection + uint256 maxOngoingTokensPerSecond; + // The minimum amount of seconds that must pass between collections + uint32 minSecondsPerCollection; + // The maximum amount of seconds that can pass between collections + uint32 maxSecondsPerCollection; + // The timestamp when the agreement was canceled + uint64 canceledAt; + // The state of the agreement + AgreementState state; + } + + /** + * @notice The params for collecting an agreement + * @param agreementId The agreement ID of the RCA + * @param collectionId The collection ID of the RCA + * @param tokens The amount of tokens to collect + * @param dataServiceCut The data service cut in parts per million + * @param receiverDestination The address where the collected fees should be sent + */ + struct CollectParams { + bytes16 agreementId; + bytes32 collectionId; + uint256 tokens; + uint256 dataServiceCut; + address receiverDestination; + } + + /** + * @notice Emitted when an agreement is accepted + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param agreementId The agreement ID + * @param acceptedAt The timestamp when the agreement was accepted + * @param endsAt The timestamp when the agreement ends + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + */ + event AgreementAccepted( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + uint64 acceptedAt, + uint64 endsAt, + uint256 maxInitialTokens, + uint256 maxOngoingTokensPerSecond, + uint32 minSecondsPerCollection, + uint32 maxSecondsPerCollection + ); + + /** + * @notice Emitted when an agreement is canceled + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param agreementId The agreement ID + * @param canceledAt The timestamp when the agreement was canceled + * @param canceledBy The party that canceled the agreement + */ + event AgreementCanceled( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + uint64 canceledAt, + CancelAgreementBy canceledBy + ); + + /** + * @notice Emitted when an agreement is updated + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param agreementId The agreement ID + * @param updatedAt The timestamp when the agreement was updated + * @param endsAt The timestamp when the agreement ends + * @param maxInitialTokens The maximum amount of tokens that can be collected in the first collection + * @param maxOngoingTokensPerSecond The maximum amount of tokens that can be collected per second + * @param minSecondsPerCollection The minimum amount of seconds that must pass between collections + * @param maxSecondsPerCollection The maximum amount of seconds that can pass between collections + */ + event AgreementUpdated( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + uint64 updatedAt, + uint64 endsAt, + uint256 maxInitialTokens, + uint256 maxOngoingTokensPerSecond, + uint32 minSecondsPerCollection, + uint32 maxSecondsPerCollection + ); + + /** + * @notice Emitted when an RCA is collected + * @param dataService The address of the data service + * @param payer The address of the payer + * @param serviceProvider The address of the service provider + * @param agreementId The agreement ID + * @param collectionId The collection ID + * @param tokens The amount of tokens collected + * @param dataServiceCut The tokens cut for the data service + */ + event RCACollected( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + bytes32 collectionId, + uint256 tokens, + uint256 dataServiceCut + ); + + /** + * @notice Thrown when accepting an agreement with a zero ID + */ + error RecurringCollectorAgreementIdZero(); + + /** + * @notice Thrown when interacting with an agreement not owned by the message sender + * @param agreementId The agreement ID + * @param unauthorizedDataService The address of the unauthorized data service + */ + error RecurringCollectorDataServiceNotAuthorized(bytes16 agreementId, address unauthorizedDataService); + + /** + * @notice Thrown when interacting with an agreement with an elapsed deadline + * @param currentTimestamp The current timestamp + * @param deadline The elapsed deadline timestamp + */ + error RecurringCollectorAgreementDeadlineElapsed(uint256 currentTimestamp, uint64 deadline); + + /** + * @notice Thrown when the signer is invalid + */ + error RecurringCollectorInvalidSigner(); + + /** + * @notice Thrown when the payment type is not IndexingFee + * @param invalidPaymentType The invalid payment type + */ + error RecurringCollectorInvalidPaymentType(IGraphPayments.PaymentTypes invalidPaymentType); + + /** + * @notice Thrown when the caller is not the data service the RCA was issued to + * @param unauthorizedCaller The address of the caller + * @param dataService The address of the data service + */ + error RecurringCollectorUnauthorizedCaller(address unauthorizedCaller, address dataService); + + /** + * @notice Thrown when calling collect() with invalid data + * @param invalidData The invalid data + */ + error RecurringCollectorInvalidCollectData(bytes invalidData); + + /** + * @notice Thrown when calling collect() on a payer canceled agreement + * where the final collection has already been done + * @param agreementId The agreement ID + * @param finalCollectionAt The timestamp when the final collection was done + */ + error RecurringCollectorFinalCollectionDone(bytes16 agreementId, uint256 finalCollectionAt); + + /** + * @notice Thrown when interacting with an agreement that has an incorrect state + * @param agreementId The agreement ID + * @param incorrectState The incorrect state + */ + error RecurringCollectorAgreementIncorrectState(bytes16 agreementId, AgreementState incorrectState); + + /** + * @notice Thrown when accepting an agreement with an address that is not set + */ + error RecurringCollectorAgreementAddressNotSet(); + + /** + * @notice Thrown when accepting or upgrading an agreement with an elapsed endsAt + * @param currentTimestamp The current timestamp + * @param endsAt The agreement end timestamp + */ + error RecurringCollectorAgreementElapsedEndsAt(uint256 currentTimestamp, uint64 endsAt); + + /** + * @notice Thrown when accepting or upgrading an agreement with an elapsed endsAt + * @param allowedMinCollectionWindow The allowed minimum collection window + * @param minSecondsPerCollection The minimum seconds per collection + * @param maxSecondsPerCollection The maximum seconds per collection + */ + error RecurringCollectorAgreementInvalidCollectionWindow( + uint32 allowedMinCollectionWindow, + uint32 minSecondsPerCollection, + uint32 maxSecondsPerCollection + ); + + /** + * @notice Thrown when accepting or upgrading an agreement with an invalid duration + * @param requiredMinDuration The required minimum duration + * @param invalidDuration The invalid duration + */ + error RecurringCollectorAgreementInvalidDuration(uint32 requiredMinDuration, uint256 invalidDuration); + + /** + * @notice Thrown when calling collect() with a zero collection seconds + * @param agreementId The agreement ID + * @param currentTimestamp The current timestamp + * @param lastCollectionAt The timestamp when the last collection was done + * + */ + error RecurringCollectorZeroCollectionSeconds( + bytes16 agreementId, + uint256 currentTimestamp, + uint64 lastCollectionAt + ); + + /** + * @notice Thrown when calling collect() too soon + * @param agreementId The agreement ID + * @param secondsSinceLast Seconds since last collection + * @param minSeconds Minimum seconds between collections + */ + error RecurringCollectorCollectionTooSoon(bytes16 agreementId, uint32 secondsSinceLast, uint32 minSeconds); + + /** + * @notice Thrown when calling collect() too late + * @param agreementId The agreement ID + * @param secondsSinceLast Seconds since last collection + * @param maxSeconds Maximum seconds between collections + */ + error RecurringCollectorCollectionTooLate(bytes16 agreementId, uint64 secondsSinceLast, uint32 maxSeconds); + + /** + * @dev Accept an indexing agreement. + * @param signedRCA The signed Recurring Collection Agreement which is to be accepted. + */ + function accept(SignedRCA calldata signedRCA) external; + + /** + * @dev Cancel an indexing agreement. + * @param agreementId The agreement's ID. + * @param by The party that is canceling the agreement. + */ + function cancel(bytes16 agreementId, CancelAgreementBy by) external; + + /** + * @dev Update an indexing agreement. + * @param signedRCAU The signed Recurring Collection Agreement Update which is to be applied. + */ + function update(SignedRCAU calldata signedRCAU) external; + + /** + * @dev Computes the hash of a RecurringCollectionAgreement (RCA). + * @param rca The RCA for which to compute the hash. + * @return The hash of the RCA. + */ + function hashRCA(RecurringCollectionAgreement calldata rca) external view returns (bytes32); + + /** + * @dev Computes the hash of a RecurringCollectionAgreementUpdate (RCAU). + * @param rcau The RCAU for which to compute the hash. + * @return The hash of the RCAU. + */ + function hashRCAU(RecurringCollectionAgreementUpdate calldata rcau) external view returns (bytes32); + + /** + * @dev Recovers the signer address of a signed RecurringCollectionAgreement (RCA). + * @param signedRCA The SignedRCA containing the RCA and its signature. + * @return The address of the signer. + */ + function recoverRCASigner(SignedRCA calldata signedRCA) external view returns (address); + + /** + * @dev Recovers the signer address of a signed RecurringCollectionAgreementUpdate (RCAU). + * @param signedRCAU The SignedRCAU containing the RCAU and its signature. + * @return The address of the signer. + */ + function recoverRCAUSigner(SignedRCAU calldata signedRCAU) external view returns (address); + + /** + * @notice Gets an agreement. + * @param agreementId The ID of the agreement to retrieve. + * @return The AgreementData struct containing the agreement's data. + */ + function getAgreement(bytes16 agreementId) external view returns (AgreementData memory); +} diff --git a/packages/horizon/contracts/payments/collectors/GraphTallyCollector.sol b/packages/horizon/contracts/payments/collectors/GraphTallyCollector.sol index bab1be09e..6eda16b5f 100644 --- a/packages/horizon/contracts/payments/collectors/GraphTallyCollector.sol +++ b/packages/horizon/contracts/payments/collectors/GraphTallyCollector.sol @@ -102,7 +102,10 @@ contract GraphTallyCollector is EIP712, GraphDirectory, Authorizable, IGraphTall bytes calldata _data, uint256 _tokensToCollect ) private returns (uint256) { - require(_paymentType == IGraphPayments.PaymentTypes.QueryFee, GraphTallyCollectorInvalidPaymentType(_paymentType)); + require( + _paymentType == IGraphPayments.PaymentTypes.QueryFee, + GraphTallyCollectorInvalidPaymentType(_paymentType) + ); (SignedRAV memory signedRAV, uint256 dataServiceCut, address receiverDestination) = abi.decode( _data, diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol new file mode 100644 index 000000000..99122a348 --- /dev/null +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -0,0 +1,540 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; + +import { Authorizable } from "../../utilities/Authorizable.sol"; +import { GraphDirectory } from "../../utilities/GraphDirectory.sol"; +// solhint-disable-next-line no-unused-import +import { IPaymentsCollector } from "../../interfaces/IPaymentsCollector.sol"; // for @inheritdoc +import { IRecurringCollector } from "../../interfaces/IRecurringCollector.sol"; +import { IGraphPayments } from "../../interfaces/IGraphPayments.sol"; +import { PPMMath } from "../../libraries/PPMMath.sol"; + +/** + * @title RecurringCollector contract + * @dev Implements the {IRecurringCollector} interface. + * @notice A payments collector contract that can be used to collect payments using a RCA (Recurring Collection Agreement). + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringCollector { + using PPMMath for uint256; + + /// @notice The minimum number of seconds that must be between two collections + uint32 public constant MIN_SECONDS_COLLECTION_WINDOW = 600; + + /// @notice The EIP712 typehash for the RecurringCollectionAgreement struct + bytes32 public constant EIP712_RCA_TYPEHASH = + keccak256( + "RecurringCollectionAgreement(bytes16 agreementId,uint256 deadline,uint256 endsAt,address payer,address dataService,address serviceProvider,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,bytes metadata)" + ); + + /// @notice The EIP712 typehash for the RecurringCollectionAgreementUpdate struct + bytes32 public constant EIP712_RCAU_TYPEHASH = + keccak256( + "RecurringCollectionAgreementUpdate(bytes16 agreementId,uint256 deadline,uint256 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,bytes metadata)" + ); + + /// @notice Tracks agreements + mapping(bytes16 agreementId => AgreementData data) public agreements; + + /** + * @notice Constructs a new instance of the RecurringCollector 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) {} + + /** + * @inheritdoc IPaymentsCollector + * @notice Initiate a payment collection through the payments protocol. + * See {IPaymentsCollector.collect}. + * @dev Caller must be the data service the RCA was issued to. + */ + function collect(IGraphPayments.PaymentTypes paymentType, bytes calldata data) external returns (uint256) { + require( + paymentType == IGraphPayments.PaymentTypes.IndexingFee, + RecurringCollectorInvalidPaymentType(paymentType) + ); + try this.decodeCollectData(data) returns (CollectParams memory collectParams) { + return _collect(collectParams); + } catch { + revert RecurringCollectorInvalidCollectData(data); + } + } + + /** + * @inheritdoc IRecurringCollector + * @notice Accept an indexing agreement. + * See {IRecurringCollector.accept}. + * @dev Caller must be the data service the RCA was issued to. + */ + function accept(SignedRCA calldata signedRCA) external { + require(signedRCA.rca.agreementId != bytes16(0), RecurringCollectorAgreementIdZero()); + require( + msg.sender == signedRCA.rca.dataService, + RecurringCollectorUnauthorizedCaller(msg.sender, signedRCA.rca.dataService) + ); + require( + signedRCA.rca.deadline >= block.timestamp, + RecurringCollectorAgreementDeadlineElapsed(block.timestamp, signedRCA.rca.deadline) + ); + + // check that the voucher is signed by the payer (or proxy) + _requireAuthorizedRCASigner(signedRCA); + + require( + signedRCA.rca.dataService != address(0) && + signedRCA.rca.payer != address(0) && + signedRCA.rca.serviceProvider != address(0), + RecurringCollectorAgreementAddressNotSet() + ); + + _requireValidCollectionWindowParams( + signedRCA.rca.endsAt, + signedRCA.rca.minSecondsPerCollection, + signedRCA.rca.maxSecondsPerCollection + ); + + AgreementData storage agreement = _getAgreementStorage(signedRCA.rca.agreementId); + // check that the agreement is not already accepted + require( + agreement.state == AgreementState.NotAccepted, + RecurringCollectorAgreementIncorrectState(signedRCA.rca.agreementId, agreement.state) + ); + + // accept the agreement + agreement.acceptedAt = uint64(block.timestamp); + agreement.state = AgreementState.Accepted; + agreement.dataService = signedRCA.rca.dataService; + agreement.payer = signedRCA.rca.payer; + agreement.serviceProvider = signedRCA.rca.serviceProvider; + agreement.endsAt = signedRCA.rca.endsAt; + agreement.maxInitialTokens = signedRCA.rca.maxInitialTokens; + agreement.maxOngoingTokensPerSecond = signedRCA.rca.maxOngoingTokensPerSecond; + agreement.minSecondsPerCollection = signedRCA.rca.minSecondsPerCollection; + agreement.maxSecondsPerCollection = signedRCA.rca.maxSecondsPerCollection; + + emit AgreementAccepted( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + signedRCA.rca.agreementId, + agreement.acceptedAt, + agreement.endsAt, + agreement.maxInitialTokens, + agreement.maxOngoingTokensPerSecond, + agreement.minSecondsPerCollection, + agreement.maxSecondsPerCollection + ); + } + + /** + * @inheritdoc IRecurringCollector + * @notice Cancel an indexing agreement. + * See {IRecurringCollector.cancel}. + * @dev Caller must be the data service for the agreement. + */ + function cancel(bytes16 agreementId, CancelAgreementBy by) external { + AgreementData storage agreement = _getAgreementStorage(agreementId); + require( + agreement.state == AgreementState.Accepted, + RecurringCollectorAgreementIncorrectState(agreementId, agreement.state) + ); + require( + agreement.dataService == msg.sender, + RecurringCollectorDataServiceNotAuthorized(agreementId, msg.sender) + ); + agreement.canceledAt = uint64(block.timestamp); + if (by == CancelAgreementBy.Payer) { + agreement.state = AgreementState.CanceledByPayer; + } else { + agreement.state = AgreementState.CanceledByServiceProvider; + } + + emit AgreementCanceled( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + agreementId, + agreement.canceledAt, + by + ); + } + + /** + * @inheritdoc IRecurringCollector + * @notice Update an indexing agreement. + * See {IRecurringCollector.update}. + * @dev Caller must be the data service for the agreement. + */ + function update(SignedRCAU calldata signedRCAU) external { + require( + signedRCAU.rcau.deadline >= block.timestamp, + RecurringCollectorAgreementDeadlineElapsed(block.timestamp, signedRCAU.rcau.deadline) + ); + + AgreementData storage agreement = _getAgreementStorage(signedRCAU.rcau.agreementId); + require( + agreement.state == AgreementState.Accepted, + RecurringCollectorAgreementIncorrectState(signedRCAU.rcau.agreementId, agreement.state) + ); + require( + agreement.dataService == msg.sender, + RecurringCollectorDataServiceNotAuthorized(signedRCAU.rcau.agreementId, msg.sender) + ); + + // check that the voucher is signed by the payer (or proxy) + _requireAuthorizedRCAUSigner(signedRCAU, agreement.payer); + + _requireValidCollectionWindowParams( + signedRCAU.rcau.endsAt, + signedRCAU.rcau.minSecondsPerCollection, + signedRCAU.rcau.maxSecondsPerCollection + ); + + // update the agreement + agreement.endsAt = signedRCAU.rcau.endsAt; + agreement.maxInitialTokens = signedRCAU.rcau.maxInitialTokens; + agreement.maxOngoingTokensPerSecond = signedRCAU.rcau.maxOngoingTokensPerSecond; + agreement.minSecondsPerCollection = signedRCAU.rcau.minSecondsPerCollection; + agreement.maxSecondsPerCollection = signedRCAU.rcau.maxSecondsPerCollection; + + emit AgreementUpdated( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + signedRCAU.rcau.agreementId, + uint64(block.timestamp), + agreement.endsAt, + agreement.maxInitialTokens, + agreement.maxOngoingTokensPerSecond, + agreement.minSecondsPerCollection, + agreement.maxSecondsPerCollection + ); + } + + /// @inheritdoc IRecurringCollector + function recoverRCASigner(SignedRCA calldata signedRCA) external view returns (address) { + return _recoverRCASigner(signedRCA); + } + + /// @inheritdoc IRecurringCollector + function recoverRCAUSigner(SignedRCAU calldata signedRCAU) external view returns (address) { + return _recoverRCAUSigner(signedRCAU); + } + + /// @inheritdoc IRecurringCollector + function hashRCA(RecurringCollectionAgreement calldata rca) external view returns (bytes32) { + return _hashRCA(rca); + } + + /// @inheritdoc IRecurringCollector + function hashRCAU(RecurringCollectionAgreementUpdate calldata rcau) external view returns (bytes32) { + return _hashRCAU(rcau); + } + + /// @inheritdoc IRecurringCollector + function getAgreement(bytes16 agreementId) external view returns (AgreementData memory) { + return _getAgreement(agreementId); + } + + /** + * @notice Decodes the collect data. + * @param data The encoded collect parameters. + * @return The decoded collect parameters. + */ + function decodeCollectData(bytes calldata data) public pure returns (CollectParams memory) { + return abi.decode(data, (CollectParams)); + } + + /** + * @notice Collect payment through the payments protocol. + * @dev Caller must be the data service the RCA was issued to. + * + * Emits {PaymentCollected} and {RCACollected} events. + * + * @param _params The decoded parameters for the collection + * @return The amount of tokens collected + */ + function _collect(CollectParams memory _params) private returns (uint256) { + AgreementData storage agreement = _getAgreementStorage(_params.agreementId); + require( + agreement.state == AgreementState.Accepted || agreement.state == AgreementState.CanceledByPayer, + RecurringCollectorAgreementIncorrectState(_params.agreementId, agreement.state) + ); + + require( + msg.sender == agreement.dataService, + RecurringCollectorDataServiceNotAuthorized(_params.agreementId, msg.sender) + ); + + uint256 tokensToCollect = 0; + if (_params.tokens != 0) { + tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens); + + _graphPaymentsEscrow().collect( + IGraphPayments.PaymentTypes.IndexingFee, + agreement.payer, + agreement.serviceProvider, + tokensToCollect, + agreement.dataService, + _params.dataServiceCut, + _params.receiverDestination + ); + } + agreement.lastCollectionAt = uint64(block.timestamp); + + emit PaymentCollected( + IGraphPayments.PaymentTypes.IndexingFee, + _params.collectionId, + agreement.payer, + agreement.serviceProvider, + agreement.dataService, + tokensToCollect + ); + + emit RCACollected( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + _params.agreementId, + _params.collectionId, + tokensToCollect, + _params.dataServiceCut + ); + + return tokensToCollect; + } + + /** + * @notice Requires that the collection window parameters are valid. + * + * @param _endsAt The end time of the agreement + * @param _minSecondsPerCollection The minimum seconds per collection + * @param _maxSecondsPerCollection The maximum seconds per collection + */ + function _requireValidCollectionWindowParams( + uint64 _endsAt, + uint32 _minSecondsPerCollection, + uint32 _maxSecondsPerCollection + ) private view { + // Agreement needs to end in the future + require(_endsAt > block.timestamp, RecurringCollectorAgreementElapsedEndsAt(block.timestamp, _endsAt)); + + // Collection window needs to be at least MIN_SECONDS_COLLECTION_WINDOW + require( + _maxSecondsPerCollection > _minSecondsPerCollection && + (_maxSecondsPerCollection - _minSecondsPerCollection >= MIN_SECONDS_COLLECTION_WINDOW), + RecurringCollectorAgreementInvalidCollectionWindow( + MIN_SECONDS_COLLECTION_WINDOW, + _minSecondsPerCollection, + _maxSecondsPerCollection + ) + ); + + // Agreement needs to last at least one min collection window + require( + _endsAt - block.timestamp >= _minSecondsPerCollection + MIN_SECONDS_COLLECTION_WINDOW, + RecurringCollectorAgreementInvalidDuration( + _minSecondsPerCollection + MIN_SECONDS_COLLECTION_WINDOW, + _endsAt - block.timestamp + ) + ); + } + + /** + * @notice Requires that the collection params are valid. + * @param _agreement The agreement data + * @param _agreementId The ID of the agreement + * @param _tokens The number of tokens to collect + * @return The number of tokens that can be collected + */ + function _requireValidCollect( + AgreementData memory _agreement, + bytes16 _agreementId, + uint256 _tokens + ) private view returns (uint256) { + bool canceledOrElapsed = _agreement.state == AgreementState.CanceledByPayer || + block.timestamp > _agreement.endsAt; + uint256 canceledOrNow = _agreement.state == AgreementState.CanceledByPayer + ? _agreement.canceledAt + : block.timestamp; + + // if canceled by the payer allow collection till canceledAt + // if elapsed allow collection till endsAt + // if both are true, use the earlier one + uint256 collectionEnd = canceledOrElapsed ? Math.min(canceledOrNow, _agreement.endsAt) : block.timestamp; + uint256 collectionStart = _agreementCollectionStartAt(_agreement); + require( + collectionEnd != collectionStart, + RecurringCollectorZeroCollectionSeconds(_agreementId, block.timestamp, uint64(collectionStart)) + ); + require(collectionEnd > collectionStart, RecurringCollectorFinalCollectionDone(_agreementId, collectionStart)); + + uint256 collectionSeconds = collectionEnd - collectionStart; + // Check that the collection window is long enough + // If the agreement is canceled or elapsed, allow a shorter collection window + if (!canceledOrElapsed) { + require( + collectionSeconds >= _agreement.minSecondsPerCollection, + RecurringCollectorCollectionTooSoon( + _agreementId, + uint32(collectionSeconds), + _agreement.minSecondsPerCollection + ) + ); + } + require( + collectionSeconds <= _agreement.maxSecondsPerCollection, + RecurringCollectorCollectionTooLate( + _agreementId, + uint64(collectionSeconds), + _agreement.maxSecondsPerCollection + ) + ); + + uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * collectionSeconds; + maxTokens += _agreement.lastCollectionAt == 0 ? _agreement.maxInitialTokens : 0; + + return Math.min(_tokens, maxTokens); + } + + /** + * @notice See {recoverRCASigner} + * @param _signedRCA The signed RCA to recover the signer from + * @return The address of the signer + */ + function _recoverRCASigner(SignedRCA memory _signedRCA) private view returns (address) { + bytes32 messageHash = _hashRCA(_signedRCA.rca); + return ECDSA.recover(messageHash, _signedRCA.signature); + } + + /** + * @notice See {recoverRCAUSigner} + * @param _signedRCAU The signed RCAU to recover the signer from + * @return The address of the signer + */ + function _recoverRCAUSigner(SignedRCAU memory _signedRCAU) private view returns (address) { + bytes32 messageHash = _hashRCAU(_signedRCAU.rcau); + return ECDSA.recover(messageHash, _signedRCAU.signature); + } + + /** + * @notice See {hashRCA} + * @param _rca The RCA to hash + * @return The EIP712 hash of the RCA + */ + function _hashRCA(RecurringCollectionAgreement memory _rca) private view returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode( + EIP712_RCA_TYPEHASH, + _rca.agreementId, + _rca.deadline, + _rca.endsAt, + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.maxInitialTokens, + _rca.maxOngoingTokensPerSecond, + _rca.minSecondsPerCollection, + _rca.maxSecondsPerCollection, + keccak256(_rca.metadata) + ) + ) + ); + } + + /** + * @notice See {hashRCAU} + * @param _rcau The RCAU to hash + * @return The EIP712 hash of the RCAU + */ + function _hashRCAU(RecurringCollectionAgreementUpdate memory _rcau) private view returns (bytes32) { + return + _hashTypedDataV4( + keccak256( + abi.encode( + EIP712_RCAU_TYPEHASH, + _rcau.agreementId, + _rcau.deadline, + _rcau.endsAt, + _rcau.maxInitialTokens, + _rcau.maxOngoingTokensPerSecond, + _rcau.minSecondsPerCollection, + _rcau.maxSecondsPerCollection, + keccak256(_rcau.metadata) + ) + ) + ); + } + + /** + * @notice Requires that the signer for the RCA is authorized + * by the payer of the RCA. + * @param _signedRCA The signed RCA to verify + * @return The address of the authorized signer + */ + function _requireAuthorizedRCASigner(SignedRCA memory _signedRCA) private view returns (address) { + address signer = _recoverRCASigner(_signedRCA); + require(_isAuthorized(_signedRCA.rca.payer, signer), RecurringCollectorInvalidSigner()); + + return signer; + } + + /** + * @notice Requires that the signer for the RCAU is authorized + * by the payer. + * @param _signedRCAU The signed RCAU to verify + * @param _payer The address of the payer + * @return The address of the authorized signer + */ + function _requireAuthorizedRCAUSigner( + SignedRCAU memory _signedRCAU, + address _payer + ) private view returns (address) { + address signer = _recoverRCAUSigner(_signedRCAU); + require(_isAuthorized(_payer, signer), RecurringCollectorInvalidSigner()); + + return signer; + } + + /** + * @notice Gets an agreement to be updated. + * @param _agreementId The ID of the agreement to get + * @return The storage reference to the agreement data + */ + function _getAgreementStorage(bytes16 _agreementId) private view returns (AgreementData storage) { + return agreements[_agreementId]; + } + + /** + * @notice See {getAgreement} + * @param _agreementId The ID of the agreement to get + * @return The agreement data + */ + function _getAgreement(bytes16 _agreementId) private view returns (AgreementData memory) { + return agreements[_agreementId]; + } + + /** + * @notice Gets the start time for the collection of an agreement. + * @param _agreement The agreement data + * @return The start time for the collection of the agreement + */ + function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) { + return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; + } +} diff --git a/packages/horizon/package.json b/packages/horizon/package.json index c3f960433..641fc61dd 100644 --- a/packages/horizon/package.json +++ b/packages/horizon/package.json @@ -17,9 +17,10 @@ "scripts": { "lint": "pnpm lint:ts && pnpm lint:sol", "lint:ts": "eslint '**/*.{js,ts}' --fix --no-warn-ignored", - "lint:sol": "pnpm lint:sol:prettier && pnpm lint:sol:solhint", + "lint:sol": "pnpm lint:sol:prettier && pnpm lint:sol:solhint && pnpm lint:sol:solhint:test", "lint:sol:prettier": "prettier --write \"contracts/**/*.sol\" \"test/**/*.sol\"", "lint:sol:solhint": "solhint --noPrompt --fix \"contracts/**/*.sol\" --config node_modules/solhint-graph-config/index.js", + "lint:sol:solhint:test": "solhint --noPrompt --fix \"test/unit/payments/recurring-collector/*\" --config node_modules/solhint-graph-config/index.js", "lint:sol:natspec": "natspec-smells --config natspec-smells.config.js", "clean": "rm -rf build dist cache cache_forge typechain-types", "build": "hardhat compile", diff --git a/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol b/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol index cd6e7bf46..6657ac315 100644 --- a/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol +++ b/packages/horizon/test/unit/data-service/extensions/DataServiceFees.t.sol @@ -3,7 +3,7 @@ pragma solidity 0.8.27; import { HorizonStakingSharedTest } from "../../shared/horizon-staking/HorizonStakingShared.t.sol"; import { DataServiceImpFees } from "../implementations/DataServiceImpFees.sol"; -import { IDataServiceFees } from "../../../../contracts/data-service/interfaces/IDataServiceFees.sol"; +import { StakeClaims } from "../../../../contracts/data-service/libraries/StakeClaims.sol"; import { ProvisionTracker } from "../../../../contracts/data-service/libraries/ProvisionTracker.sol"; import { LinkedList } from "../../../../contracts/libraries/LinkedList.sol"; @@ -13,7 +13,7 @@ contract DataServiceFeesTest is HorizonStakingSharedTest { useIndexer useProvisionDataService(address(dataService), PROVISION_TOKENS, 0, 0) { - vm.expectRevert(abi.encodeWithSignature("DataServiceFeesZeroTokens()")); + vm.expectRevert(abi.encodeWithSignature("StakeClaimsZeroTokens()")); dataService.lockStake(users.indexer, 0); } @@ -132,6 +132,7 @@ contract DataServiceFeesTest is HorizonStakingSharedTest { uint256 stakeToLock; bytes32 predictedClaimId; } + function _assert_lockStake(address serviceProvider, uint256 tokens) private { // before state (bytes32 beforeHead, , uint256 beforeNonce, uint256 beforeCount) = dataService.claimsLists(serviceProvider); @@ -146,7 +147,7 @@ contract DataServiceFeesTest is HorizonStakingSharedTest { // it should emit a an event vm.expectEmit(); - emit IDataServiceFees.StakeClaimLocked( + emit StakeClaims.StakeClaimLocked( serviceProvider, calcValues.predictedClaimId, calcValues.stakeToLock, @@ -185,6 +186,7 @@ contract DataServiceFeesTest is HorizonStakingSharedTest { uint256 tokensReleased; bytes32 head; } + function _assert_releaseStake(address serviceProvider, uint256 numClaimsToRelease) private { // before state (bytes32 beforeHead, bytes32 beforeTail, uint256 beforeNonce, uint256 beforeCount) = dataService.claimsLists( @@ -208,14 +210,14 @@ contract DataServiceFeesTest is HorizonStakingSharedTest { break; } - emit IDataServiceFees.StakeClaimReleased(serviceProvider, calcValues.head, claimTokens, releasableAt); + emit StakeClaims.StakeClaimReleased(serviceProvider, calcValues.head, claimTokens, releasableAt); calcValues.head = nextClaim; calcValues.tokensReleased += claimTokens; calcValues.claimsCount++; } // it should emit a an event - emit IDataServiceFees.StakeClaimsReleased(serviceProvider, calcValues.claimsCount, calcValues.tokensReleased); + emit StakeClaims.StakeClaimsReleased(serviceProvider, calcValues.claimsCount, calcValues.tokensReleased); dataService.releaseStake(numClaimsToRelease); // after state diff --git a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol new file mode 100644 index 000000000..36ebdda18 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol"; +import { IPaymentsEscrow } from "../../../../contracts/interfaces/IPaymentsEscrow.sol"; + +contract PaymentsEscrowMock is IPaymentsEscrow { + function initialize() external {} + + function collect(IGraphPayments.PaymentTypes, address, address, uint256, address, uint256, address) external {} + + function deposit(address, address, uint256) external {} + + function depositTo(address, address, address, uint256) external {} + + function thaw(address, address, uint256) external {} + + function cancelThaw(address, address) external {} + + function withdraw(address, address) external {} + + function getBalance(address, address, address) external pure returns (uint256) { + return 0; + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol new file mode 100644 index 000000000..ff5e39848 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IAuthorizable } from "../../../../contracts/interfaces/IAuthorizable.sol"; +import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; + +import { AuthorizableTest } from "../../../unit/utilities/Authorizable.t.sol"; +import { RecurringCollectorControllerMock } from "./RecurringCollectorControllerMock.t.sol"; + +contract RecurringCollectorAuthorizableTest is AuthorizableTest { + function newAuthorizable(uint256 thawPeriod) public override returns (IAuthorizable) { + return + new RecurringCollector( + "RecurringCollector", + "1", + address(new RecurringCollectorControllerMock(address(1))), + thawPeriod + ); + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorControllerMock.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorControllerMock.t.sol new file mode 100644 index 000000000..3425e8b01 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorControllerMock.t.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { IPaymentsEscrow } from "../../../../contracts/interfaces/IPaymentsEscrow.sol"; +import { ControllerMock } from "../../../../contracts/mocks/ControllerMock.sol"; + +contract RecurringCollectorControllerMock is ControllerMock, Test { + address private _invalidContractAddress; + IPaymentsEscrow private _paymentsEscrow; + + constructor(address paymentsEscrow) ControllerMock(address(0)) { + _invalidContractAddress = makeAddr("invalidContractAddress"); + _paymentsEscrow = IPaymentsEscrow(paymentsEscrow); + } + + function getContractProxy(bytes32 data) external view override returns (address) { + return data == keccak256("PaymentsEscrow") ? address(_paymentsEscrow) : _invalidContractAddress; + } + + function getPaymentsEscrow() external view returns (address) { + return address(_paymentsEscrow); + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol new file mode 100644 index 000000000..b3ccbc3b8 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; +import { AuthorizableHelper } from "../../../unit/utilities/Authorizable.t.sol"; +import { Bounder } from "../../../unit/utils/Bounder.t.sol"; + +contract RecurringCollectorHelper is AuthorizableHelper, Bounder { + RecurringCollector public collector; + + constructor( + RecurringCollector collector_ + ) AuthorizableHelper(collector_, collector_.REVOKE_AUTHORIZATION_THAWING_PERIOD()) { + collector = collector_; + } + + function generateSignedRCA( + IRecurringCollector.RecurringCollectionAgreement memory rca, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCA memory) { + bytes32 messageHash = collector.hashRCA(rca); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, messageHash); + bytes memory signature = abi.encodePacked(r, s, v); + IRecurringCollector.SignedRCA memory signedRCA = IRecurringCollector.SignedRCA({ + rca: rca, + signature: signature + }); + + return signedRCA; + } + + function generateSignedRCAU( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCAU memory) { + bytes32 messageHash = collector.hashRCAU(rcau); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(signerPrivateKey, messageHash); + bytes memory signature = abi.encodePacked(r, s, v); + IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ + rcau: rcau, + signature: signature + }); + + return signedRCAU; + } + + function withElapsedAcceptDeadline( + IRecurringCollector.RecurringCollectionAgreement memory rca + ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + require(block.timestamp > 0, "block.timestamp can't be zero"); + require(block.timestamp <= type(uint64).max, "block.timestamp can't be huge"); + rca.deadline = uint64(bound(rca.deadline, 0, block.timestamp - 1)); + return rca; + } + + function withOKAcceptDeadline( + IRecurringCollector.RecurringCollectionAgreement memory rca + ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + require(block.timestamp <= type(uint64).max, "block.timestamp can't be huge"); + rca.deadline = uint64(boundTimestampMin(rca.deadline, block.timestamp)); + return rca; + } + + function sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement memory rca + ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + vm.assume(rca.agreementId != bytes16(0)); + vm.assume(rca.dataService != address(0)); + vm.assume(rca.payer != address(0)); + vm.assume(rca.serviceProvider != address(0)); + + rca.minSecondsPerCollection = _sensibleMinSecondsPerCollection(rca.minSecondsPerCollection); + rca.maxSecondsPerCollection = _sensibleMaxSecondsPerCollection( + rca.maxSecondsPerCollection, + rca.minSecondsPerCollection + ); + + rca.deadline = _sensibleDeadline(rca.deadline); + rca.endsAt = _sensibleEndsAt(rca.endsAt, rca.maxSecondsPerCollection); + + rca.maxInitialTokens = _sensibleMaxInitialTokens(rca.maxInitialTokens); + rca.maxOngoingTokensPerSecond = _sensibleMaxOngoingTokensPerSecond(rca.maxOngoingTokensPerSecond); + + return rca; + } + + function sensibleRCAU( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau + ) public view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory) { + rcau.minSecondsPerCollection = _sensibleMinSecondsPerCollection(rcau.minSecondsPerCollection); + rcau.maxSecondsPerCollection = _sensibleMaxSecondsPerCollection( + rcau.maxSecondsPerCollection, + rcau.minSecondsPerCollection + ); + + rcau.deadline = _sensibleDeadline(rcau.deadline); + rcau.endsAt = _sensibleEndsAt(rcau.endsAt, rcau.maxSecondsPerCollection); + rcau.maxInitialTokens = _sensibleMaxInitialTokens(rcau.maxInitialTokens); + rcau.maxOngoingTokensPerSecond = _sensibleMaxOngoingTokensPerSecond(rcau.maxOngoingTokensPerSecond); + + return rcau; + } + + function _sensibleDeadline(uint256 _seed) internal view returns (uint64) { + return + uint64( + bound(_seed, block.timestamp + 1, block.timestamp + uint256(collector.MIN_SECONDS_COLLECTION_WINDOW())) + ); // between now and +MIN_SECONDS_COLLECTION_WINDOW + } + + function _sensibleEndsAt(uint256 _seed, uint32 _maxSecondsPerCollection) internal view returns (uint64) { + return + uint64( + bound( + _seed, + block.timestamp + (10 * uint256(_maxSecondsPerCollection)), + block.timestamp + (1_000_000 * uint256(_maxSecondsPerCollection)) + ) + ); // between 10 and 1M max collections + } + + function _sensibleMaxSecondsPerCollection( + uint32 _seed, + uint32 _minSecondsPerCollection + ) internal view returns (uint32) { + return + uint32( + bound( + _seed, + _minSecondsPerCollection + uint256(collector.MIN_SECONDS_COLLECTION_WINDOW()), + 60 * 60 * 24 * 30 + ) // between minSecondsPerCollection + 2h and 30 days + ); + } + + function _sensibleMaxInitialTokens(uint256 _seed) internal pure returns (uint256) { + return bound(_seed, 0, 1e18 * 100_000_000); // between 0 and 100M tokens + } + + function _sensibleMaxOngoingTokensPerSecond(uint256 _seed) internal pure returns (uint256) { + return bound(_seed, 1, 1e18); // between 1 and 1e18 tokens per second + } + + function _sensibleMinSecondsPerCollection(uint32 _seed) internal pure returns (uint32) { + return uint32(bound(_seed, 10 * 60, 24 * 60 * 60)); // between 10 min and 24h + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol new file mode 100644 index 000000000..d9479b955 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorAcceptTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Accept(FuzzyTestAccept calldata fuzzyTestAccept) public { + _sensibleAuthorizeAndAccept(fuzzyTestAccept); + } + + function test_Accept_Revert_WhenAcceptanceDeadlineElapsed( + IRecurringCollector.SignedRCA memory fuzzySignedRCA, + uint256 unboundedSkip + ) public { + vm.assume(fuzzySignedRCA.rca.agreementId != bytes16(0)); + skip(boundSkip(unboundedSkip, 1, type(uint64).max - block.timestamp)); + fuzzySignedRCA.rca = _recurringCollectorHelper.withElapsedAcceptDeadline(fuzzySignedRCA.rca); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementDeadlineElapsed.selector, + block.timestamp, + fuzzySignedRCA.rca.deadline + ); + vm.expectRevert(expectedErr); + vm.prank(fuzzySignedRCA.rca.dataService); + _recurringCollector.accept(fuzzySignedRCA); + } + + function test_Accept_Revert_WhenAlreadyAccepted(FuzzyTestAccept calldata fuzzyTestAccept) public { + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + accepted.rca.agreementId, + IRecurringCollector.AgreementState.Accepted + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.accept(accepted); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol new file mode 100644 index 000000000..fe938c825 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorCancelTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Cancel(FuzzyTestAccept calldata fuzzyTestAccept, uint8 unboundedCanceler) public { + _sensibleAuthorizeAndAccept(fuzzyTestAccept); + _cancel(fuzzyTestAccept.rca, _fuzzyCancelAgreementBy(unboundedCanceler)); + } + + function test_Cancel_Revert_WhenNotAccepted( + IRecurringCollector.RecurringCollectionAgreement memory fuzzyRCA, + uint8 unboundedCanceler + ) public { + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + fuzzyRCA.agreementId, + IRecurringCollector.AgreementState.NotAccepted + ); + vm.expectRevert(expectedErr); + vm.prank(fuzzyRCA.dataService); + _recurringCollector.cancel(fuzzyRCA.agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); + } + + function test_Cancel_Revert_WhenNotDataService( + FuzzyTestAccept calldata fuzzyTestAccept, + uint8 unboundedCanceler, + address notDataService + ) public { + vm.assume(fuzzyTestAccept.rca.dataService != notDataService); + + _sensibleAuthorizeAndAccept(fuzzyTestAccept); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, + fuzzyTestAccept.rca.agreementId, + notDataService + ); + vm.expectRevert(expectedErr); + vm.prank(notDataService); + _recurringCollector.cancel(fuzzyTestAccept.rca.agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol new file mode 100644 index 000000000..8942c21bf --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol"; + +import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Collect_Revert_WhenInvalidPaymentType(uint8 unboundedPaymentType, bytes memory data) public { + IGraphPayments.PaymentTypes paymentType = IGraphPayments.PaymentTypes( + bound( + unboundedPaymentType, + uint256(type(IGraphPayments.PaymentTypes).min), + uint256(type(IGraphPayments.PaymentTypes).max) + ) + ); + vm.assume(paymentType != IGraphPayments.PaymentTypes.IndexingFee); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidPaymentType.selector, + paymentType + ); + vm.expectRevert(expectedErr); + _recurringCollector.collect(paymentType, data); + } + + function test_Collect_Revert_WhenInvalidData(address caller, bytes memory data) public { + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidCollectData.selector, + data + ); + vm.expectRevert(expectedErr); + vm.prank(caller); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_Revert_WhenCallerNotDataService( + FuzzyTestCollect calldata fuzzy, + address notDataService + ) public { + vm.assume(fuzzy.fuzzyTestAccept.rca.dataService != notDataService); + + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; + + collectParams.agreementId = accepted.rca.agreementId; + bytes memory data = _generateCollectData(collectParams); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, + collectParams.agreementId, + notDataService + ); + vm.expectRevert(expectedErr); + vm.prank(notDataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_Revert_WhenUnknownAgreement(FuzzyTestCollect memory fuzzy, address dataService) public { + bytes memory data = _generateCollectData(fuzzy.collectParams); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + fuzzy.collectParams.agreementId, + IRecurringCollector.AgreementState.NotAccepted + ); + vm.expectRevert(expectedErr); + vm.prank(dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_Revert_WhenCanceledAgreementByServiceProvider(FuzzyTestCollect calldata fuzzy) public { + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + _cancel(accepted.rca, IRecurringCollector.CancelAgreementBy.ServiceProvider); + IRecurringCollector.CollectParams memory collectData = fuzzy.collectParams; + collectData.tokens = bound(collectData.tokens, 1, type(uint256).max); + IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( + accepted.rca, + collectData.collectionId, + collectData.tokens, + collectData.dataServiceCut + ); + bytes memory data = _generateCollectData(collectParams); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + collectParams.agreementId, + IRecurringCollector.AgreementState.CanceledByServiceProvider + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_Revert_WhenCollectingTooSoon( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedCollectionSeconds + ) public { + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + + skip(accepted.rca.minSecondsPerCollection); + bytes memory data = _generateCollectData( + _generateCollectParams( + accepted.rca, + fuzzy.collectParams.collectionId, + 1, + fuzzy.collectParams.dataServiceCut + ) + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + + uint256 collectionSeconds = boundSkip(unboundedCollectionSeconds, 1, accepted.rca.minSecondsPerCollection - 1); + skip(collectionSeconds); + + IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( + accepted.rca, + fuzzy.collectParams.collectionId, + bound(fuzzy.collectParams.tokens, 1, type(uint256).max), + fuzzy.collectParams.dataServiceCut + ); + data = _generateCollectData(collectParams); + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorCollectionTooSoon.selector, + collectParams.agreementId, + collectionSeconds, + accepted.rca.minSecondsPerCollection + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_Revert_WhenCollectingTooLate( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedFirstCollectionSeconds, + uint256 unboundedSecondCollectionSeconds + ) public { + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + + // skip to collectable time + skip( + boundSkip( + unboundedFirstCollectionSeconds, + accepted.rca.minSecondsPerCollection, + accepted.rca.maxSecondsPerCollection + ) + ); + bytes memory data = _generateCollectData( + _generateCollectParams( + accepted.rca, + fuzzy.collectParams.collectionId, + 1, + fuzzy.collectParams.dataServiceCut + ) + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + + // skip beyond collectable time but still within the agreement endsAt + uint256 collectionSeconds = boundSkip( + unboundedSecondCollectionSeconds, + accepted.rca.maxSecondsPerCollection + 1, + accepted.rca.endsAt - block.timestamp + ); + skip(collectionSeconds); + + data = _generateCollectData( + _generateCollectParams( + accepted.rca, + fuzzy.collectParams.collectionId, + bound(fuzzy.collectParams.tokens, 1, type(uint256).max), + fuzzy.collectParams.dataServiceCut + ) + ); + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorCollectionTooLate.selector, + accepted.rca.agreementId, + collectionSeconds, + accepted.rca.maxSecondsPerCollection + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_OK_WhenCollectingTooMuch( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedInitialCollectionSeconds, + uint256 unboundedCollectionSeconds, + uint256 unboundedTokens, + bool testInitialCollection + ) public { + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + + if (!testInitialCollection) { + // skip to collectable time + skip( + boundSkip( + unboundedInitialCollectionSeconds, + accepted.rca.minSecondsPerCollection, + accepted.rca.maxSecondsPerCollection + ) + ); + bytes memory initialData = _generateCollectData( + _generateCollectParams( + accepted.rca, + fuzzy.collectParams.collectionId, + 1, + fuzzy.collectParams.dataServiceCut + ) + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, initialData); + } + + // skip to collectable time + uint256 collectionSeconds = boundSkip( + unboundedCollectionSeconds, + accepted.rca.minSecondsPerCollection, + accepted.rca.maxSecondsPerCollection + ); + skip(collectionSeconds); + uint256 maxTokens = accepted.rca.maxOngoingTokensPerSecond * collectionSeconds; + maxTokens += testInitialCollection ? accepted.rca.maxInitialTokens : 0; + uint256 tokens = bound(unboundedTokens, maxTokens + 1, type(uint256).max); + IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( + accepted.rca, + fuzzy.collectParams.collectionId, + tokens, + fuzzy.collectParams.dataServiceCut + ); + bytes memory data = _generateCollectData(collectParams); + vm.prank(accepted.rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, maxTokens); + } + + function test_Collect_OK( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedCollectionSeconds, + uint256 unboundedTokens + ) public { + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + + (bytes memory data, uint256 collectionSeconds, uint256 tokens) = _generateValidCollection( + accepted.rca, + fuzzy.collectParams, + unboundedCollectionSeconds, + unboundedTokens + ); + skip(collectionSeconds); + _expectCollectCallAndEmit(accepted.rca, fuzzy.collectParams, tokens); + vm.prank(accepted.rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, tokens); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol new file mode 100644 index 000000000..397925600 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol"; +import { IPaymentsCollector } from "../../../../contracts/interfaces/IPaymentsCollector.sol"; +import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; + +import { Bounder } from "../../../unit/utils/Bounder.t.sol"; +import { RecurringCollectorControllerMock } from "./RecurringCollectorControllerMock.t.sol"; +import { PaymentsEscrowMock } from "./PaymentsEscrowMock.t.sol"; +import { RecurringCollectorHelper } from "./RecurringCollectorHelper.t.sol"; + +contract RecurringCollectorSharedTest is Test, Bounder { + struct FuzzyTestCollect { + FuzzyTestAccept fuzzyTestAccept; + IRecurringCollector.CollectParams collectParams; + } + + struct FuzzyTestAccept { + IRecurringCollector.RecurringCollectionAgreement rca; + uint256 unboundedSignerKey; + } + + struct FuzzyTestUpdate { + FuzzyTestAccept fuzzyTestAccept; + IRecurringCollector.RecurringCollectionAgreementUpdate rcau; + } + + RecurringCollector internal _recurringCollector; + PaymentsEscrowMock internal _paymentsEscrow; + RecurringCollectorHelper internal _recurringCollectorHelper; + + function setUp() public { + _paymentsEscrow = new PaymentsEscrowMock(); + _recurringCollector = new RecurringCollector( + "RecurringCollector", + "1", + address(new RecurringCollectorControllerMock(address(_paymentsEscrow))), + 1 + ); + _recurringCollectorHelper = new RecurringCollectorHelper(_recurringCollector); + } + + function _sensibleAuthorizeAndAccept( + FuzzyTestAccept calldata _fuzzyTestAccept + ) internal returns (IRecurringCollector.SignedRCA memory, uint256 key) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + _fuzzyTestAccept.rca + ); + key = boundKey(_fuzzyTestAccept.unboundedSignerKey); + return (_authorizeAndAccept(rca, key), key); + } + + // authorizes signer, signs the RCA, and accepts it + function _authorizeAndAccept( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + uint256 _signerKey + ) internal returns (IRecurringCollector.SignedRCA memory) { + _recurringCollectorHelper.authorizeSignerWithChecks(_rca.payer, _signerKey); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(_rca, _signerKey); + + _accept(signedRCA); + + return signedRCA; + } + + function _accept(IRecurringCollector.SignedRCA memory _signedRCA) internal { + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementAccepted( + _signedRCA.rca.dataService, + _signedRCA.rca.payer, + _signedRCA.rca.serviceProvider, + _signedRCA.rca.agreementId, + uint64(block.timestamp), + _signedRCA.rca.endsAt, + _signedRCA.rca.maxInitialTokens, + _signedRCA.rca.maxOngoingTokensPerSecond, + _signedRCA.rca.minSecondsPerCollection, + _signedRCA.rca.maxSecondsPerCollection + ); + vm.prank(_signedRCA.rca.dataService); + _recurringCollector.accept(_signedRCA); + } + + function _cancel( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + IRecurringCollector.CancelAgreementBy _by + ) internal { + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementCanceled( + _rca.dataService, + _rca.payer, + _rca.serviceProvider, + _rca.agreementId, + uint64(block.timestamp), + _by + ); + vm.prank(_rca.dataService); + _recurringCollector.cancel(_rca.agreementId, _by); + } + + function _expectCollectCallAndEmit( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + IRecurringCollector.CollectParams memory _fuzzyParams, + uint256 _tokens + ) internal { + vm.expectCall( + address(_paymentsEscrow), + abi.encodeCall( + _paymentsEscrow.collect, + ( + IGraphPayments.PaymentTypes.IndexingFee, + _rca.payer, + _rca.serviceProvider, + _tokens, + _rca.dataService, + _fuzzyParams.dataServiceCut, + _rca.serviceProvider + ) + ) + ); + vm.expectEmit(address(_recurringCollector)); + emit IPaymentsCollector.PaymentCollected( + IGraphPayments.PaymentTypes.IndexingFee, + _fuzzyParams.collectionId, + _rca.payer, + _rca.serviceProvider, + _rca.dataService, + _tokens + ); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.RCACollected( + _rca.dataService, + _rca.payer, + _rca.serviceProvider, + _rca.agreementId, + _fuzzyParams.collectionId, + _tokens, + _fuzzyParams.dataServiceCut + ); + } + + function _generateValidCollection( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + IRecurringCollector.CollectParams memory _fuzzyParams, + uint256 _unboundedCollectionSkip, + uint256 _unboundedTokens + ) internal view returns (bytes memory, uint256, uint256) { + uint256 collectionSeconds = boundSkip( + _unboundedCollectionSkip, + _rca.minSecondsPerCollection, + _rca.maxSecondsPerCollection + ); + uint256 tokens = bound(_unboundedTokens, 1, _rca.maxOngoingTokensPerSecond * collectionSeconds); + bytes memory data = _generateCollectData( + _generateCollectParams(_rca, _fuzzyParams.collectionId, tokens, _fuzzyParams.dataServiceCut) + ); + + return (data, collectionSeconds, tokens); + } + + function _generateCollectParams( + IRecurringCollector.RecurringCollectionAgreement memory _rca, + bytes32 _collectionId, + uint256 _tokens, + uint256 _dataServiceCut + ) internal pure returns (IRecurringCollector.CollectParams memory) { + return + IRecurringCollector.CollectParams({ + agreementId: _rca.agreementId, + collectionId: _collectionId, + tokens: _tokens, + dataServiceCut: _dataServiceCut, + receiverDestination: _rca.serviceProvider + }); + } + + function _generateCollectData( + IRecurringCollector.CollectParams memory _params + ) internal pure returns (bytes memory) { + return abi.encode(_params); + } + + function _fuzzyCancelAgreementBy(uint8 _seed) internal pure returns (IRecurringCollector.CancelAgreementBy) { + return + IRecurringCollector.CancelAgreementBy( + bound(_seed, 0, uint256(IRecurringCollector.CancelAgreementBy.Payer)) + ); + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol new file mode 100644 index 000000000..4fd8af1e7 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol @@ -0,0 +1,157 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Update_Revert_WhenUpdateElapsed( + IRecurringCollector.RecurringCollectionAgreement memory rca, + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + uint256 unboundedUpdateSkip + ) public { + rca = _recurringCollectorHelper.sensibleRCA(rca); + rcau = _recurringCollectorHelper.sensibleRCAU(rcau); + rcau.agreementId = rca.agreementId; + + boundSkipCeil(unboundedUpdateSkip, type(uint64).max); + rcau.deadline = uint64(bound(rcau.deadline, 0, block.timestamp - 1)); + IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ + rcau: rcau, + signature: "" + }); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementDeadlineElapsed.selector, + block.timestamp, + rcau.deadline + ); + vm.expectRevert(expectedErr); + vm.prank(rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenNeverAccepted( + IRecurringCollector.RecurringCollectionAgreement memory rca, + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau + ) public { + rca = _recurringCollectorHelper.sensibleRCA(rca); + rcau = _recurringCollectorHelper.sensibleRCAU(rcau); + rcau.agreementId = rca.agreementId; + + rcau.deadline = uint64(block.timestamp); + IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ + rcau: rcau, + signature: "" + }); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + rcau.agreementId, + IRecurringCollector.AgreementState.NotAccepted + ); + vm.expectRevert(expectedErr); + vm.prank(rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenDataServiceNotAuthorized( + FuzzyTestUpdate calldata fuzzyTestUpdate, + address notDataService + ) public { + vm.assume(fuzzyTestUpdate.fuzzyTestAccept.rca.dataService != notDataService); + (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( + fuzzyTestUpdate.fuzzyTestAccept + ); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau.agreementId = accepted.rca.agreementId; + + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + signerKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, + signedRCAU.rcau.agreementId, + notDataService + ); + vm.expectRevert(expectedErr); + vm.prank(notDataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenInvalidSigner( + FuzzyTestUpdate calldata fuzzyTestUpdate, + uint256 unboundedInvalidSignerKey + ) public { + (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( + fuzzyTestUpdate.fuzzyTestAccept + ); + uint256 invalidSignerKey = boundKey(unboundedInvalidSignerKey); + vm.assume(signerKey != invalidSignerKey); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau.agreementId = accepted.rca.agreementId; + + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + invalidSignerKey + ); + + vm.expectRevert(IRecurringCollector.RecurringCollectorInvalidSigner.selector); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_OK(FuzzyTestUpdate calldata fuzzyTestUpdate) public { + (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( + fuzzyTestUpdate.fuzzyTestAccept + ); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau.agreementId = accepted.rca.agreementId; + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + signerKey + ); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementUpdated( + accepted.rca.dataService, + accepted.rca.payer, + accepted.rca.serviceProvider, + rcau.agreementId, + uint64(block.timestamp), + rcau.endsAt, + rcau.maxInitialTokens, + rcau.maxOngoingTokensPerSecond, + rcau.minSecondsPerCollection, + rcau.maxSecondsPerCollection + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU); + + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(accepted.rca.agreementId); + assertEq(rcau.endsAt, agreement.endsAt); + assertEq(rcau.maxInitialTokens, agreement.maxInitialTokens); + assertEq(rcau.maxOngoingTokensPerSecond, agreement.maxOngoingTokensPerSecond); + assertEq(rcau.minSecondsPerCollection, agreement.minSecondsPerCollection); + assertEq(rcau.maxSecondsPerCollection, agreement.maxSecondsPerCollection); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/utilities/Authorizable.t.sol b/packages/horizon/test/unit/utilities/Authorizable.t.sol index 4528b339d..20ca7e2b9 100644 --- a/packages/horizon/test/unit/utilities/Authorizable.t.sol +++ b/packages/horizon/test/unit/utilities/Authorizable.t.sol @@ -14,23 +14,27 @@ contract AuthorizableImp is Authorizable { } contract AuthorizableTest is Test, Bounder { - AuthorizableImp public authorizable; + 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 AuthorizableImp(_thawPeriod)); + setupAuthorizable(_thawPeriod); _; } - function setUp() public virtual { - setupAuthorizable(new AuthorizableImp(0)); + function setUp() public { + setupAuthorizable(0); } - function setupAuthorizable(AuthorizableImp _authorizable) internal { - authorizable = _authorizable; - authHelper = new AuthorizableHelper(authorizable); + function setupAuthorizable(uint256 _thawPeriod) internal { + authorizable = newAuthorizable(_thawPeriod); + authHelper = new AuthorizableHelper(authorizable, _thawPeriod); + } + + function newAuthorizable(uint256 _thawPeriod) public virtual returns (IAuthorizable) { + return new AuthorizableImp(_thawPeriod); } function test_AuthorizeSigner(uint256 _unboundedKey, address _authorizer) public { @@ -303,12 +307,12 @@ contract AuthorizableTest is Test, Bounder { authHelper.authorizeAndThawSignerWithChecks(_authorizer, signerKey); - _skip = bound(_skip, 0, authorizable.REVOKE_AUTHORIZATION_THAWING_PERIOD() - 1); + _skip = bound(_skip, 0, authHelper.revokeAuthorizationThawingPeriod() - 1); skip(_skip); bytes memory expectedErr = abi.encodeWithSelector( IAuthorizable.AuthorizableSignerStillThawing.selector, block.timestamp, - block.timestamp - _skip + authorizable.REVOKE_AUTHORIZATION_THAWING_PERIOD() + block.timestamp - _skip + authHelper.revokeAuthorizationThawingPeriod() ); vm.expectRevert(expectedErr); vm.prank(_authorizer); @@ -321,17 +325,19 @@ contract AuthorizableTest is Test, Bounder { } contract AuthorizableHelper is Test { - AuthorizableImp internal authorizable; + IAuthorizable internal authorizable; + uint256 public revokeAuthorizationThawingPeriod; - constructor(AuthorizableImp _authorizable) { + constructor(IAuthorizable _authorizable, uint256 _thawPeriod) { authorizable = _authorizable; + revokeAuthorizationThawingPeriod = _thawPeriod; } function authorizeAndThawSignerWithChecks(address _authorizer, uint256 _signerKey) public { address signer = vm.addr(_signerKey); authorizeSignerWithChecks(_authorizer, _signerKey); - uint256 thawEndTimestamp = block.timestamp + authorizable.REVOKE_AUTHORIZATION_THAWING_PERIOD(); + uint256 thawEndTimestamp = block.timestamp + revokeAuthorizationThawingPeriod; vm.expectEmit(address(authorizable)); emit IAuthorizable.SignerThawing(_authorizer, signer, thawEndTimestamp); vm.prank(_authorizer); @@ -343,7 +349,7 @@ contract AuthorizableHelper is Test { function authorizeAndRevokeSignerWithChecks(address _authorizer, uint256 _signerKey) public { address signer = vm.addr(_signerKey); authorizeAndThawSignerWithChecks(_authorizer, _signerKey); - skip(authorizable.REVOKE_AUTHORIZATION_THAWING_PERIOD() + 1); + skip(revokeAuthorizationThawingPeriod + 1); vm.expectEmit(address(authorizable)); emit IAuthorizable.SignerRevoked(_authorizer, signer); vm.prank(_authorizer); @@ -356,6 +362,7 @@ contract AuthorizableHelper is Test { address signer = vm.addr(_signerKey); assertNotAuthorized(_authorizer, signer); + require(block.timestamp < type(uint256).max, "Test cannot be run at the end of time"); uint256 proofDeadline = block.timestamp + 1; bytes memory proof = generateAuthorizationProof( block.chainid, diff --git a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol index bf40a35b8..8e06d2875 100644 --- a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol +++ b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol @@ -22,6 +22,7 @@ contract GraphDirectoryImplementation is GraphDirectory { function getContractFromController(bytes memory contractName) external view returns (address) { return _graphController().getContractProxy(keccak256(contractName)); } + function graphToken() external view returns (IGraphToken) { return _graphToken(); } diff --git a/packages/horizon/test/unit/utils/Bounder.t.sol b/packages/horizon/test/unit/utils/Bounder.t.sol index 44e977f57..9b95a3425 100644 --- a/packages/horizon/test/unit/utils/Bounder.t.sol +++ b/packages/horizon/test/unit/utils/Bounder.t.sol @@ -6,18 +6,22 @@ import { Test } from "forge-std/Test.sol"; contract Bounder is Test { uint256 constant SECP256K1_CURVE_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + function boundKeyAndAddr(uint256 _value) internal pure returns (uint256, address) { + uint256 key = bound(_value, 1, SECP256K1_CURVE_ORDER - 1); + return (key, vm.addr(key)); + } + function boundAddrAndKey(uint256 _value) internal pure returns (uint256, address) { - uint256 signerKey = bound(_value, 1, SECP256K1_CURVE_ORDER - 1); - return (signerKey, vm.addr(signerKey)); + return boundKeyAndAddr(_value); } function boundAddr(uint256 _value) internal pure returns (address) { - (, address addr) = boundAddrAndKey(_value); + (, address addr) = boundKeyAndAddr(_value); return addr; } function boundKey(uint256 _value) internal pure returns (uint256) { - (uint256 key, ) = boundAddrAndKey(_value); + (uint256 key, ) = boundKeyAndAddr(_value); return key; } @@ -28,4 +32,21 @@ contract Bounder is Test { function boundTimestampMin(uint256 _value, uint256 _min) internal pure returns (uint256) { return bound(_value, _min, type(uint256).max); } + + function boundSkipFloor(uint256 _value, uint256 _min) internal view returns (uint256) { + return boundSkip(_value, _min, type(uint256).max); + } + + function boundSkipCeil(uint256 _value, uint256 _max) internal view returns (uint256) { + return boundSkip(_value, 0, _max); + } + + function boundSkip(uint256 _value, uint256 _min, uint256 _max) internal view returns (uint256) { + return bound(_value, orTillEndOfTime(_min), orTillEndOfTime(_max)); + } + + function orTillEndOfTime(uint256 _value) internal view returns (uint256) { + uint256 tillEndOfTime = type(uint256).max - block.timestamp; + return _value < tillEndOfTime ? _value : tillEndOfTime; + } } diff --git a/packages/subgraph-service/contracts/DisputeManager.sol b/packages/subgraph-service/contracts/DisputeManager.sol index 573e8f67e..e509f1410 100644 --- a/packages/subgraph-service/contracts/DisputeManager.sol +++ b/packages/subgraph-service/contracts/DisputeManager.sol @@ -11,6 +11,7 @@ import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol" import { MathUtils } from "@graphprotocol/horizon/contracts/libraries/MathUtils.sol"; import { Allocation } from "./libraries/Allocation.sol"; import { Attestation } from "./libraries/Attestation.sol"; +import { IndexingAgreement } from "./libraries/IndexingAgreement.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import { Initializable } from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -132,6 +133,20 @@ contract DisputeManager is return _createIndexingDisputeWithAllocation(msg.sender, disputeDeposit, allocationId, poi, blockNumber); } + /// @inheritdoc IDisputeManager + function createIndexingFeeDisputeV1( + bytes16 agreementId, + bytes32 poi, + uint256 entities, + uint256 blockNumber + ) external override returns (bytes32) { + // Get funds from fisherman + _graphToken().pullTokens(msg.sender, disputeDeposit); + + // Create a dispute + return _createIndexingFeeDisputeV1(msg.sender, disputeDeposit, agreementId, poi, entities, blockNumber); + } + /// @inheritdoc IDisputeManager function createQueryDispute(bytes calldata attestationData) external override returns (bytes32) { // Get funds from fisherman @@ -501,6 +516,75 @@ contract DisputeManager is return disputeId; } + /** + * @notice Create indexing fee (version 1) dispute internal function. + * @param _fisherman The fisherman creating the dispute + * @param _deposit Amount of tokens staked as deposit + * @param _agreementId The agreement id being disputed + * @param _poi The POI being disputed + * @param _entities The number of entities disputed + * @param _blockNumber The block number of the disputed POI + * @return The dispute id + */ + function _createIndexingFeeDisputeV1( + address _fisherman, + uint256 _deposit, + bytes16 _agreementId, + bytes32 _poi, + uint256 _entities, + uint256 _blockNumber + ) private returns (bytes32) { + IndexingAgreement.AgreementWrapper memory wrapper = _getSubgraphService().getIndexingAgreement(_agreementId); + + // Agreement must have been collected on and be a version 1 + require( + wrapper.collectorAgreement.lastCollectionAt > 0, + DisputeManagerIndexingAgreementNotDisputable(_agreementId) + ); + require( + wrapper.agreement.version == IndexingAgreement.IndexingAgreementVersion.V1, + DisputeManagerIndexingAgreementInvalidVersion(wrapper.agreement.version) + ); + + // Create a disputeId + bytes32 disputeId = keccak256( + abi.encodePacked("IndexingFeeDisputeWithAgreement", _agreementId, _poi, _entities, _blockNumber) + ); + + // Only one dispute at a time + require(!isDisputeCreated(disputeId), DisputeManagerDisputeAlreadyCreated(disputeId)); + + // The indexer must be disputable + uint256 stakeSnapshot = _getStakeSnapshot(wrapper.collectorAgreement.serviceProvider); + require(stakeSnapshot != 0, DisputeManagerZeroTokens()); + + disputes[disputeId] = Dispute( + wrapper.collectorAgreement.serviceProvider, + _fisherman, + _deposit, + 0, // no related dispute, + DisputeType.IndexingFeeDispute, + IDisputeManager.DisputeStatus.Pending, + block.timestamp, + block.timestamp + disputePeriod, + stakeSnapshot + ); + + emit IndexingFeeDisputeCreated( + disputeId, + wrapper.collectorAgreement.serviceProvider, + _fisherman, + _deposit, + wrapper.collectorAgreement.payer, + _agreementId, + _poi, + _entities, + stakeSnapshot + ); + + return disputeId; + } + /** * @notice Accept a dispute * @param _disputeId The id of the dispute diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 140ab9c34..5c557a908 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -23,6 +23,10 @@ import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol" import { Allocation } from "./libraries/Allocation.sol"; import { LegacyAllocation } from "./libraries/LegacyAllocation.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IndexingAgreementDecoder } from "./libraries/IndexingAgreementDecoder.sol"; +import { IndexingAgreement } from "./libraries/IndexingAgreement.sol"; + /** * @title SubgraphService contract * @custom:security-contact Please email security+contracts@thegraph.com if you find any @@ -45,13 +49,14 @@ contract SubgraphService is using Allocation for mapping(address => Allocation.State); using Allocation for Allocation.State; using TokenUtils for IGraphToken; + using IndexingAgreement for IndexingAgreement.StorageManager; /** * @notice Checks that an indexer is registered * @param indexer The address of the indexer */ modifier onlyRegisteredIndexer(address indexer) { - require(indexers[indexer].registeredAt != 0, SubgraphServiceIndexerNotRegistered(indexer)); + _requireRegisteredIndexer(indexer); _; } @@ -62,13 +67,18 @@ contract SubgraphService is * @param disputeManager The address of the DisputeManager contract * @param graphTallyCollector The address of the GraphTallyCollector contract * @param curation The address of the Curation contract + * @param recurringCollector The address of the RecurringCollector contract */ constructor( address graphController, address disputeManager, address graphTallyCollector, - address curation - ) DataService(graphController) Directory(address(this), disputeManager, graphTallyCollector, curation) { + address curation, + address recurringCollector + ) + DataService(graphController) + Directory(address(this), disputeManager, graphTallyCollector, curation, recurringCollector) + { _disableInitializers(); } @@ -226,13 +236,14 @@ contract SubgraphService is _allocations.get(allocationId).indexer == indexer, SubgraphServiceAllocationNotAuthorized(indexer, allocationId) ); + _onCloseAllocation(allocationId, false); _closeAllocation(allocationId, false); emit ServiceStopped(indexer, data); } /** * @notice Collects payment for the service provided by the indexer - * Allows collecting different types of payments such as query fees and indexing rewards. + * Allows collecting different types of payments such as query fees, indexing rewards and indexing fees. * It uses Graph Horizon payments protocol to process payments. * Reverts if the payment type is not supported. * @dev This function is the equivalent of the `collect` function for query fees and the `closeAllocation` function @@ -246,6 +257,12 @@ contract SubgraphService is * * For query fees, see {SubgraphService-_collectQueryFees} for more details. * For indexing rewards, see {AllocationManager-_collectIndexingRewards} for more details. + * For indexing fees, see {SubgraphService-_collectIndexingFees} for more details. + * + * Note that collecting any type of payment will require locking provisioned stake as collateral for a period of time. + * All types of payment share the same pool of provisioned stake however they each have separate accounting: + * - Indexing rewards can make full use of the available stake + * - Query and indexing fees share the pool, combined they can also make full use of the available stake * * @param indexer The address of the indexer * @param paymentType The type of payment to collect as defined in {IGraphPayments} @@ -256,6 +273,9 @@ contract SubgraphService is * - address `allocationId`: The id of the allocation * - bytes32 `poi`: The POI being presented * - bytes `poiMetadata`: The metadata associated with the POI. See {AllocationManager-_collectIndexingRewards} for more details. + * - For indexing fees: + * - bytes16 `agreementId`: The id of the indexing agreement + * - bytes `agreementCollectionMetadata`: The metadata required by the indexing agreement version. */ /// @inheritdoc IDataService function collect( @@ -265,10 +285,10 @@ contract SubgraphService is ) external override + whenNotPaused onlyAuthorizedForProvision(indexer) onlyValidProvision(indexer) onlyRegisteredIndexer(indexer) - whenNotPaused returns (uint256) { uint256 paymentCollected = 0; @@ -277,6 +297,9 @@ contract SubgraphService is paymentCollected = _collectQueryFees(indexer, data); } else if (paymentType == IGraphPayments.PaymentTypes.IndexingRewards) { paymentCollected = _collectIndexingRewards(indexer, data); + } else if (paymentType == IGraphPayments.PaymentTypes.IndexingFee) { + (bytes16 agreementId, bytes memory iaCollectionData) = IndexingAgreementDecoder.decodeCollectData(data); + paymentCollected = _collectIndexingFees(agreementId, paymentsDestination[indexer], iaCollectionData); } else { revert SubgraphServiceInvalidPaymentType(paymentType); } @@ -302,6 +325,7 @@ contract SubgraphService is Allocation.State memory allocation = _allocations.get(allocationId); require(allocation.isStale(maxPOIStaleness), SubgraphServiceCannotForceCloseAllocation(allocationId)); require(!allocation.isAltruistic(), SubgraphServiceAllocationIsAltruistic(allocationId)); + _onCloseAllocation(allocationId, true); _closeAllocation(allocationId, true); } @@ -370,6 +394,121 @@ contract SubgraphService is emit CurationCutSet(curationCut); } + /** + * @inheritdoc ISubgraphService + * @notice Accept an indexing agreement. + * + * See {ISubgraphService.acceptIndexingAgreement}. + * + * Requirements: + * - The agreement's indexer must be registered + * - The caller must be authorized by the agreement's indexer + * - The provision must be valid according to the subgraph service rules + * - Allocation must belong to the indexer and be open + * - Agreement must be for this data service + * - Agreement's subgraph deployment must match the allocation's subgraph deployment + * - Agreement must not have been accepted before + * - Allocation must not have an agreement already + * + * @dev signedRCA.rca.metadata is an encoding of {IndexingAgreement.AcceptIndexingAgreementMetadata} + * + * Emits {IndexingAgreement.IndexingAgreementAccepted} event + * + * @param allocationId The id of the allocation + * @param signedRCA The signed Recurring Collection Agreement + */ + function acceptIndexingAgreement( + address allocationId, + IRecurringCollector.SignedRCA calldata signedRCA + ) + external + whenNotPaused + onlyAuthorizedForProvision(signedRCA.rca.serviceProvider) + onlyValidProvision(signedRCA.rca.serviceProvider) + onlyRegisteredIndexer(signedRCA.rca.serviceProvider) + { + IndexingAgreement._getStorageManager().accept(_allocations, allocationId, signedRCA); + } + + /** + * @inheritdoc ISubgraphService + * @notice Update an indexing agreement. + * + * See {IndexingAgreement.update}. + * + * Requirements: + * - The contract must not be paused + * - The indexer must be valid + * + * @param indexer The indexer address + * @param signedRCAU The signed Recurring Collection Agreement Update + */ + function updateIndexingAgreement( + address indexer, + IRecurringCollector.SignedRCAU calldata signedRCAU + ) + external + whenNotPaused + onlyAuthorizedForProvision(indexer) + onlyValidProvision(indexer) + onlyRegisteredIndexer(indexer) + { + IndexingAgreement._getStorageManager().update(indexer, signedRCAU); + } + + /** + * @inheritdoc ISubgraphService + * @notice Cancel an indexing agreement by indexer / operator. + * + * See {IndexingAgreement.cancel}. + * + * @dev Can only be canceled on behalf of a valid indexer. + * + * Requirements: + * - The contract must not be paused + * - The indexer must be valid + * + * @param indexer The indexer address + * @param agreementId The id of the agreement + */ + function cancelIndexingAgreement( + address indexer, + bytes16 agreementId + ) + external + whenNotPaused + onlyAuthorizedForProvision(indexer) + onlyValidProvision(indexer) + onlyRegisteredIndexer(indexer) + { + IndexingAgreement._getStorageManager().cancel(indexer, agreementId); + } + + /** + * @inheritdoc ISubgraphService + * @notice Cancel an indexing agreement by payer / signer. + * + * See {ISubgraphService.cancelIndexingAgreementByPayer}. + * + * Requirements: + * - The caller must be authorized by the payer + * - The agreement must be active + * + * Emits {IndexingAgreementCanceled} event + * + * @param agreementId The id of the agreement + */ + function cancelIndexingAgreementByPayer(bytes16 agreementId) external whenNotPaused { + IndexingAgreement._getStorageManager().cancelByPayer(agreementId); + } + + /// @inheritdoc ISubgraphService + function getIndexingAgreement( + bytes16 agreementId + ) external view returns (IndexingAgreement.AgreementWrapper memory) { + return IndexingAgreement._getStorageManager().get(agreementId); + } + /// @inheritdoc ISubgraphService function getAllocation(address allocationId) external view override returns (Allocation.State memory) { return _allocations[allocationId]; @@ -425,6 +564,16 @@ contract SubgraphService is return _isOverAllocated(indexer, _delegationRatio); } + /** + * @notice Internal function to handle closing an allocation + * @dev This function is called when an allocation is closed, either by the indexer or by a third party + * @param _allocationId The id of the allocation being closed + * @param _stale Whether the allocation is stale or not + */ + function _onCloseAllocation(address _allocationId, bool _stale) internal { + IndexingAgreement._getStorageManager().onCloseAllocation(_allocationId, _stale); + } + /** * @notice Sets the payments destination for an indexer to receive payments * @dev Emits a {PaymentsDestinationSet} event @@ -436,6 +585,14 @@ contract SubgraphService is emit PaymentsDestinationSet(_indexer, _paymentsDestination); } + /** + * @notice Requires that the indexer is registered + * @param _indexer The address of the indexer + */ + function _requireRegisteredIndexer(address _indexer) internal view { + require(indexers[_indexer].registeredAt != 0, SubgraphServiceIndexerNotRegistered(_indexer)); + } + // -- Data service parameter getters -- /** * @notice Getter for the accepted thawing period range for provisions @@ -578,6 +735,59 @@ contract SubgraphService is return _presentPOI(allocationId, poi_, poiMetadata_, _delegationRatio, paymentsDestination[_indexer]); } + /** + * @notice Collect Indexing fees + * Stake equal to the amount being collected times the `stakeToFeesRatio` is locked into a stake claim. + * This claim can be released at a later stage once expired. + * + * It's important to note that before collecting this function will attempt to release any expired stake claims. + * This could lead to an out of gas error if there are too many expired claims. In that case, the indexer will need to + * manually release the claims, see {IDataServiceFees-releaseStake}, before attempting to collect again. + * + * @dev Uses the {RecurringCollector} to collect payment from Graph Horizon payments protocol. + * Fees are distributed to service provider and delegators by {GraphPayments} + * + * Requirements: + * - Indexer must have enough available tokens to lock as economic security for fees + * - Allocation must be open + * + * Emits a {StakeClaimsReleased} event, and a {StakeClaimReleased} event for each claim released. + * Emits a {StakeClaimLocked} event. + * Emits a {IndexingFeesCollectedV1} event. + * + * @param _agreementId The id of the indexing agreement + * @param _paymentsDestination The address where the fees should be sent + * @param _data The indexing agreement collection data + * @return The amount of fees collected + */ + function _collectIndexingFees( + bytes16 _agreementId, + address _paymentsDestination, + bytes memory _data + ) private returns (uint256) { + (address indexer, uint256 tokensCollected) = IndexingAgreement._getStorageManager().collect( + _allocations, + IndexingAgreement.CollectParams({ + agreementId: _agreementId, + currentEpoch: _graphEpochManager().currentEpoch(), + receiverDestination: _paymentsDestination, + data: _data + }) + ); + + _releaseStake(indexer, 0); + if (tokensCollected > 0) { + // lock stake as economic security for fees + _lockStake( + indexer, + tokensCollected * stakeToFeesRatio, + block.timestamp + _disputeManager().getDisputePeriod() + ); + } + + return tokensCollected; + } + /** * @notice Set the stake to fees ratio. * @param _stakeToFeesRatio The stake to fees ratio diff --git a/packages/subgraph-service/contracts/interfaces/IDisputeManager.sol b/packages/subgraph-service/contracts/interfaces/IDisputeManager.sol index 217b1c154..5133b38a0 100644 --- a/packages/subgraph-service/contracts/interfaces/IDisputeManager.sol +++ b/packages/subgraph-service/contracts/interfaces/IDisputeManager.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.27; import { Attestation } from "../libraries/Attestation.sol"; +import { IndexingAgreement } from "../libraries/IndexingAgreement.sol"; /** * @title IDisputeManager @@ -16,7 +17,8 @@ interface IDisputeManager { Null, IndexingDispute, QueryDispute, - LegacyDispute + LegacyDispute, + IndexingFeeDispute } /// @notice Status of a dispute @@ -113,6 +115,32 @@ interface IDisputeManager { uint256 cancellableAt ); + /** + * @dev Emitted when an indexing fee dispute is created for `agreementId` and `indexer` + * by `fisherman`. + * The event emits the amount of `tokens` deposited by the fisherman. + * @param disputeId The dispute id + * @param indexer The indexer address + * @param fisherman The fisherman address + * @param tokens The amount of tokens deposited by the fisherman + * @param payer The address of the payer of the indexing fee + * @param agreementId The agreement id + * @param poi The POI disputed + * @param entities The entities disputed + * @param stakeSnapshot The stake snapshot of the indexer at the time of the dispute + */ + event IndexingFeeDisputeCreated( + bytes32 indexed disputeId, + address indexed indexer, + address indexed fisherman, + uint256 tokens, + address payer, + bytes16 agreementId, + bytes32 poi, + uint256 entities, + uint256 stakeSnapshot + ); + /** * @dev Emitted when an indexing dispute is created for `allocationId` and `indexer` * by `fisherman`. @@ -352,6 +380,18 @@ interface IDisputeManager { */ error DisputeManagerSubgraphServiceNotSet(); + /** + * @notice Thrown when the Indexing Agreement is not disputable + * @param agreementId The indexing agreement id + */ + error DisputeManagerIndexingAgreementNotDisputable(bytes16 agreementId); + + /** + * @notice Thrown when the Indexing Agreement is not disputable + * @param version The indexing agreement version + */ + error DisputeManagerIndexingAgreementInvalidVersion(IndexingAgreement.IndexingAgreementVersion version); + /** * @notice Initialize this contract. * @param owner The owner of the contract @@ -498,6 +538,29 @@ interface IDisputeManager { uint256 tokensRewards ) external returns (bytes32); + /** + * @notice Create an indexing fee (version 1) dispute for the arbitrator to resolve. + * The disputes are created in reference to a version 1 indexing agreement and specifically + * a POI and entities provided when collecting that agreement. + * This function is called by a fisherman and it will pull `disputeDeposit` GRT tokens. + * + * Requirements: + * - fisherman must have previously approved this contract to pull `disputeDeposit` amount + * of tokens from their balance. + * + * @param agreementId The indexing agreement to dispute + * @param poi The Proof of Indexing (POI) being disputed + * @param entities The number of entities disputed + * @param blockNumber The block number at which the indexing fee was collected + * @return The dispute id + */ + function createIndexingFeeDisputeV1( + bytes16 agreementId, + bytes32 poi, + uint256 entities, + uint256 blockNumber + ) external returns (bytes32); + // -- Arbitrator -- /** diff --git a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol index 5c35296f2..1ba5e647a 100644 --- a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol +++ b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol @@ -4,9 +4,12 @@ pragma solidity 0.8.27; import { IDataServiceFees } from "@graphprotocol/horizon/contracts/data-service/interfaces/IDataServiceFees.sol"; import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; +import { IndexingAgreement } from "../libraries/IndexingAgreement.sol"; import { Allocation } from "../libraries/Allocation.sol"; import { LegacyAllocation } from "../libraries/LegacyAllocation.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; + /** * @title Interface for the {SubgraphService} contract * @dev This interface extends {IDataServiceFees} and {IDataService}. @@ -108,7 +111,7 @@ interface ISubgraphService is IDataServiceFees { error SubgraphServiceInconsistentCollection(uint256 balanceBefore, uint256 balanceAfter); /** - * @notice @notice Thrown when the service provider in the RAV does not match the expected indexer. + * @notice @notice Thrown when the service provider does not match the expected indexer. * @param providedIndexer The address of the provided indexer. * @param expectedIndexer The address of the expected indexer. */ @@ -257,6 +260,42 @@ interface ISubgraphService is IDataServiceFees { */ function setPaymentsDestination(address paymentsDestination) external; + /** + * @notice Accept an indexing agreement. + * @param allocationId The id of the allocation + * @param signedRCA The signed recurring collector agreement (RCA) that the indexer accepts + */ + function acceptIndexingAgreement(address allocationId, IRecurringCollector.SignedRCA calldata signedRCA) external; + + /** + * @notice Update an indexing agreement. + * @param indexer The address of the indexer + * @param signedRCAU The signed recurring collector agreement update (RCAU) that the indexer accepts + */ + function updateIndexingAgreement(address indexer, IRecurringCollector.SignedRCAU calldata signedRCAU) external; + + /** + * @notice Cancel an indexing agreement by indexer / operator. + * @param indexer The address of the indexer + * @param agreementId The id of the indexing agreement + */ + function cancelIndexingAgreement(address indexer, bytes16 agreementId) external; + + /** + * @notice Cancel an indexing agreement by payer / signer. + * @param agreementId The id of the indexing agreement + */ + function cancelIndexingAgreementByPayer(bytes16 agreementId) external; + + /** + * @notice Get the indexing agreement for a given agreement ID. + * @param agreementId The id of the indexing agreement + * @return The indexing agreement details + */ + function getIndexingAgreement( + bytes16 agreementId + ) external view returns (IndexingAgreement.AgreementWrapper memory); + /** * @notice Gets the details of an allocation * For legacy allocations use {getLegacyAllocation} diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol new file mode 100644 index 000000000..394430cad --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -0,0 +1,597 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { IEpochManager } from "@graphprotocol/contracts/contracts/epochs/IEpochManager.sol"; +import { IGraphToken } from "@graphprotocol/contracts/contracts/token/IGraphToken.sol"; +import { IRewardsManager } from "@graphprotocol/contracts/contracts/rewards/IRewardsManager.sol"; +import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/horizon/contracts/interfaces/internal/IHorizonStakingTypes.sol"; +import { IHorizonStaking } from "@graphprotocol/horizon/contracts/interfaces/IHorizonStaking.sol"; +import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; +import { ProvisionTracker } from "@graphprotocol/horizon/contracts/data-service/libraries/ProvisionTracker.sol"; +import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; + +import { Allocation } from "../libraries/Allocation.sol"; +import { LegacyAllocation } from "../libraries/LegacyAllocation.sol"; + +/** + * @title AllocationHandler contract + * @notice A helper contract implementing allocation lifecycle management. + * Allows opening, resizing, and closing allocations, as well as collecting indexing rewards by presenting a Proof + * of Indexing (POI). + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +library AllocationHandler { + using ProvisionTracker for mapping(address => uint256); + using Allocation for mapping(address => Allocation.State); + using Allocation for Allocation.State; + using LegacyAllocation for mapping(address => LegacyAllocation.State); + using PPMMath for uint256; + using TokenUtils for IGraphToken; + + /** + * @notice Parameters for the allocation creation + * @param currentEpoch The current epoch at the time of allocation creation + * @param graphStaking The Horizon staking contract to handle token locking + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param _encodeAllocationProof The EIP712 encoded allocation proof + * @param _indexer The address of the indexer creating the allocation + * @param _allocationId The id of the allocation to be created + * @param _subgraphDeploymentId The id of the subgraph deployment for which the allocation is created + * @param _tokens The amount of tokens to allocate + * @param _allocationProof The EIP712 proof, an EIP712 signed message of (indexer,allocationId) + * @param _delegationRatio The delegation ratio to consider when locking tokens + */ + struct AllocateParams { + uint256 currentEpoch; + IHorizonStaking graphStaking; + IRewardsManager graphRewardsManager; + bytes32 _encodeAllocationProof; + address _indexer; + address _allocationId; + bytes32 _subgraphDeploymentId; + uint256 _tokens; + bytes _allocationProof; + uint32 _delegationRatio; + } + + /** + * @notice Parameters for the POI presentation + * @param maxPOIStaleness The maximum staleness of the POI in epochs + * @param graphEpochManager The epoch manager to get the current epoch + * @param graphStaking The Horizon staking contract to handle token locking + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param graphToken The Graph token contract to handle token transfers + * @param _allocationId The id of the allocation for which the POI is presented + * @param _poi The proof of indexing (POI) to be presented + * @param _poiMetadata The metadata associated with the POI + * @param _delegationRatio The delegation ratio to consider when locking tokens + * @param _paymentsDestination The address to which the indexing rewards should be sent + */ + struct PresentParams { + uint256 maxPOIStaleness; + IEpochManager graphEpochManager; + IHorizonStaking graphStaking; + IRewardsManager graphRewardsManager; + IGraphToken graphToken; + address dataService; + address _allocationId; + bytes32 _poi; + bytes _poiMetadata; + uint32 _delegationRatio; + address _paymentsDestination; + } + + /** + * @notice Emitted when an indexer creates an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param tokens The amount of tokens allocated + * @param currentEpoch The current epoch + */ + event AllocationCreated( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + uint256 tokens, + uint256 currentEpoch + ); + + /** + * @notice Emitted when an indexer collects indexing rewards for an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param tokensRewards The amount of tokens collected + * @param tokensIndexerRewards The amount of tokens collected for the indexer + * @param tokensDelegationRewards The amount of tokens collected for delegators + * @param poi The POI presented + * @param currentEpoch The current epoch + * @param poiMetadata The metadata associated with the POI + */ + event IndexingRewardsCollected( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + uint256 tokensRewards, + uint256 tokensIndexerRewards, + uint256 tokensDelegationRewards, + bytes32 poi, + bytes poiMetadata, + uint256 currentEpoch + ); + + /** + * @notice Emitted when an indexer resizes an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param newTokens The new amount of tokens allocated + * @param oldTokens The old amount of tokens allocated + */ + event AllocationResized( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + uint256 newTokens, + uint256 oldTokens + ); + + /** + * @dev Emitted when an indexer closes an allocation + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param tokens The amount of tokens allocated + * @param forceClosed Whether the allocation was force closed + */ + event AllocationClosed( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId, + uint256 tokens, + bool forceClosed + ); + + /** + * @notice Emitted when a legacy allocation is migrated into the subgraph service + * @param indexer The address of the indexer + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + */ + event LegacyAllocationMigrated( + address indexed indexer, + address indexed allocationId, + bytes32 indexed subgraphDeploymentId + ); + + /** + * @notice Emitted when the maximum POI staleness is updated + * @param maxPOIStaleness The max POI staleness in seconds + */ + event MaxPOIStalenessSet(uint256 maxPOIStaleness); + + /** + * @notice Thrown when an allocation proof is invalid + * Both `signer` and `allocationId` should match for a valid proof. + * @param signer The address that signed the proof + * @param allocationId The id of the allocation + */ + error AllocationHandlerInvalidAllocationProof(address signer, address allocationId); + + /** + * @notice Thrown when attempting to create an allocation with a zero allocation id + */ + error AllocationHandlerInvalidZeroAllocationId(); + + /** + * @notice Thrown when attempting to collect indexing rewards on a closed allocationl + * @param allocationId The id of the allocation + */ + error AllocationHandlerAllocationClosed(address allocationId); + + /** + * @notice Thrown when attempting to resize an allocation with the same size + * @param allocationId The id of the allocation + * @param tokens The amount of tokens + */ + error AllocationHandlerAllocationSameSize(address allocationId, uint256 tokens); + + /** + * @notice Create an allocation + * @dev The `_allocationProof` is a 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationId)` + * + * Requirements: + * - `_allocationId` must not be the zero address + * + * Emits a {AllocationCreated} event + * + * @param _allocations The mapping of allocation ids to allocation states + * @param _legacyAllocations The mapping of legacy allocation ids to legacy allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param params The parameters for the allocation + */ + function allocate( + mapping(address allocationId => Allocation.State allocation) storage _allocations, + mapping(address allocationId => LegacyAllocation.State allocation) storage _legacyAllocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + AllocateParams memory params + ) external { + require(params._allocationId != address(0), AllocationHandler.AllocationHandlerInvalidZeroAllocationId()); + + _verifyAllocationProof(params._encodeAllocationProof, params._allocationId, params._allocationProof); + + // Ensure allocation id is not reused + // need to check both subgraph service (on allocations.create()) and legacy allocations + _legacyAllocations.revertIfExists(params.graphStaking, params._allocationId); + + Allocation.State memory allocation = _allocations.create( + params._indexer, + params._allocationId, + params._subgraphDeploymentId, + params._tokens, + params.graphRewardsManager.onSubgraphAllocationUpdate(params._subgraphDeploymentId), + params.currentEpoch + ); + + // Check that the indexer has enough tokens available + // Note that the delegation ratio ensures overdelegation cannot be used + allocationProvisionTracker.lock(params.graphStaking, params._indexer, params._tokens, params._delegationRatio); + + // Update total allocated tokens for the subgraph deployment + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] + + allocation.tokens; + + emit AllocationHandler.AllocationCreated( + params._indexer, + params._allocationId, + params._subgraphDeploymentId, + allocation.tokens, + params.currentEpoch + ); + } + + /** + * @notice Present a POI to collect indexing rewards for an allocation + * This function will mint indexing rewards using the {RewardsManager} and distribute them to the indexer and delegators. + * + * Conditions to qualify for indexing rewards: + * - POI must be non-zero + * - POI must not be stale, i.e: older than `maxPOIStaleness` + * - allocation must not be altruistic (allocated tokens = 0) + * - allocation must be open for at least one epoch + * + * Note that indexers are required to periodically (at most every `maxPOIStaleness`) present POIs to collect rewards. + * Rewards will not be issued to stale POIs, which means that indexers are advised to present a zero POI if they are + * unable to present a valid one to prevent being locked out of future rewards. + * + * Note on allocation duration restriction: this is required to ensure that non protocol chains have a valid block number for + * which to calculate POIs. EBO posts once per epoch typically at each epoch change, so we restrict rewards to allocations + * that have gone through at least one epoch change. + * + * Emits a {IndexingRewardsCollected} event. + * + * @param _allocations The mapping of allocation ids to allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param params The parameters for the POI presentation + * @return The amount of tokens collected + */ + function presentPOI( + mapping(address allocationId => Allocation.State allocation) storage _allocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + PresentParams memory params + ) external returns (uint256) { + Allocation.State memory allocation = _allocations.get(params._allocationId); + require(allocation.isOpen(), AllocationHandler.AllocationHandlerAllocationClosed(params._allocationId)); + + // Mint indexing rewards if all conditions are met + uint256 tokensRewards = (!allocation.isStale(params.maxPOIStaleness) && + !allocation.isAltruistic() && + params._poi != bytes32(0)) && params.graphEpochManager.currentEpoch() > allocation.createdAtEpoch + ? params.graphRewardsManager.takeRewards(params._allocationId) + : 0; + + // ... but we still take a snapshot to ensure the rewards are not accumulated for the next valid POI + _allocations.snapshotRewards( + params._allocationId, + params.graphRewardsManager.onSubgraphAllocationUpdate(allocation.subgraphDeploymentId) + ); + _allocations.presentPOI(params._allocationId); + + // Any pending rewards should have been collected now + _allocations.clearPendingRewards(params._allocationId); + + uint256 tokensIndexerRewards = 0; + uint256 tokensDelegationRewards = 0; + if (tokensRewards != 0) { + // Distribute rewards to delegators + uint256 delegatorCut = params.graphStaking.getDelegationFeeCut( + allocation.indexer, + params.dataService, + IGraphPayments.PaymentTypes.IndexingRewards + ); + IHorizonStakingTypes.DelegationPool memory delegationPool = params.graphStaking.getDelegationPool( + allocation.indexer, + params.dataService + ); + // If delegation pool has no shares then we don't need to distribute rewards to delegators + tokensDelegationRewards = delegationPool.shares > 0 ? tokensRewards.mulPPM(delegatorCut) : 0; + if (tokensDelegationRewards > 0) { + params.graphToken.approve(address(params.graphStaking), tokensDelegationRewards); + params.graphStaking.addToDelegationPool( + allocation.indexer, + params.dataService, + tokensDelegationRewards + ); + } + + // Distribute rewards to indexer + tokensIndexerRewards = tokensRewards - tokensDelegationRewards; + if (tokensIndexerRewards > 0) { + if (params._paymentsDestination == address(0)) { + params.graphToken.approve(address(params.graphStaking), tokensIndexerRewards); + params.graphStaking.stakeToProvision(allocation.indexer, params.dataService, tokensIndexerRewards); + } else { + params.graphToken.pushTokens(params._paymentsDestination, tokensIndexerRewards); + } + } + } + + emit AllocationHandler.IndexingRewardsCollected( + allocation.indexer, + params._allocationId, + allocation.subgraphDeploymentId, + tokensRewards, + tokensIndexerRewards, + tokensDelegationRewards, + params._poi, + params._poiMetadata, + params.graphEpochManager.currentEpoch() + ); + + // Check if the indexer is over-allocated and force close the allocation if necessary + if ( + _isOverAllocated( + allocationProvisionTracker, + params.graphStaking, + allocation.indexer, + params._delegationRatio + ) + ) { + _closeAllocation( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + params.graphRewardsManager, + params._allocationId, + true + ); + } + + return tokensRewards; + } + + /** + * @notice Close an allocation + * Does not require presenting a POI, use {_collectIndexingRewards} to present a POI and collect rewards + * @dev Note that allocations are nowlong lived. All service payments, including indexing rewards, should be collected periodically + * without the need of closing the allocation. Allocations should only be closed when indexers want to reclaim the allocated + * tokens for other purposes. + * + * Emits a {AllocationClosed} event + * + * @param _allocations The mapping of allocation ids to allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param _allocationId The id of the allocation to be closed + * @param _forceClosed Whether the allocation was force closed + */ + function closeAllocation( + mapping(address allocationId => Allocation.State allocation) storage _allocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + IRewardsManager graphRewardsManager, + address _allocationId, + bool _forceClosed + ) external { + _closeAllocation( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + graphRewardsManager, + _allocationId, + _forceClosed + ); + } + + /** + * @notice Resize an allocation + * @dev Will lock or release tokens in the provision tracker depending on the new allocation size. + * Rewards accrued but not issued before the resize will be accounted for as pending rewards. + * These will be paid out when the indexer presents a POI. + * + * Requirements: + * - `_indexer` must be the owner of the allocation + * - Allocation must be open + * - `_tokens` must be different from the current allocation size + * + * Emits a {AllocationResized} event. + * + * @param _allocations The mapping of allocation ids to allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param graphStaking The Horizon staking contract to handle token locking + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param _allocationId The id of the allocation to be resized + * @param _tokens The new amount of tokens to allocate + * @param _delegationRatio The delegation ratio to consider when locking tokens + */ + function resizeAllocation( + mapping(address allocationId => Allocation.State allocation) storage _allocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + IHorizonStaking graphStaking, + IRewardsManager graphRewardsManager, + address _allocationId, + uint256 _tokens, + uint32 _delegationRatio + ) external { + Allocation.State memory allocation = _allocations.get(_allocationId); + require(allocation.isOpen(), AllocationHandler.AllocationHandlerAllocationClosed(_allocationId)); + require( + _tokens != allocation.tokens, + AllocationHandler.AllocationHandlerAllocationSameSize(_allocationId, _tokens) + ); + + // Update provision tracker + uint256 oldTokens = allocation.tokens; + if (_tokens > oldTokens) { + allocationProvisionTracker.lock(graphStaking, allocation.indexer, _tokens - oldTokens, _delegationRatio); + } else { + allocationProvisionTracker.release(allocation.indexer, oldTokens - _tokens); + } + + // Calculate rewards that have been accrued since the last snapshot but not yet issued + uint256 accRewardsPerAllocatedToken = graphRewardsManager.onSubgraphAllocationUpdate( + allocation.subgraphDeploymentId + ); + uint256 accRewardsPerAllocatedTokenPending = !allocation.isAltruistic() + ? accRewardsPerAllocatedToken - allocation.accRewardsPerAllocatedToken + : 0; + + // Update the allocation + _allocations[_allocationId].tokens = _tokens; + _allocations[_allocationId].accRewardsPerAllocatedToken = accRewardsPerAllocatedToken; + _allocations[_allocationId].accRewardsPending += graphRewardsManager.calcRewards( + oldTokens, + accRewardsPerAllocatedTokenPending + ); + + // Update total allocated tokens for the subgraph deployment + if (_tokens > oldTokens) { + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] += (_tokens - oldTokens); + } else { + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] -= (oldTokens - _tokens); + } + + emit AllocationHandler.AllocationResized( + allocation.indexer, + _allocationId, + allocation.subgraphDeploymentId, + _tokens, + oldTokens + ); + } + + /** + * @notice Checks if an allocation is over-allocated + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param graphStaking The Horizon staking contract to check delegation ratios + * @param _indexer The address of the indexer + * @param _delegationRatio The delegation ratio to consider when locking tokens + * @return True if the allocation is over-allocated, false otherwise + */ + function isOverAllocated( + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + IHorizonStaking graphStaking, + address _indexer, + uint32 _delegationRatio + ) external view returns (bool) { + return _isOverAllocated(allocationProvisionTracker, graphStaking, _indexer, _delegationRatio); + } + + /** + * @notice Close an allocation + * Does not require presenting a POI, use {_collectIndexingRewards} to present a POI and collect rewards + * @dev Note that allocations are nowlong lived. All service payments, including indexing rewards, should be collected periodically + * without the need of closing the allocation. Allocations should only be closed when indexers want to reclaim the allocated + * tokens for other purposes. + * + * Emits a {AllocationClosed} event + * + * @param _allocations The mapping of allocation ids to allocation states + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param _subgraphAllocatedTokens The mapping of subgraph deployment ids to their allocated tokens + * @param graphRewardsManager The rewards manager to handle rewards distribution + * @param _allocationId The id of the allocation to be closed + * @param _forceClosed Whether the allocation was force closed + */ + function _closeAllocation( + mapping(address allocationId => Allocation.State allocation) storage _allocations, + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, + IRewardsManager graphRewardsManager, + address _allocationId, + bool _forceClosed + ) private { + Allocation.State memory allocation = _allocations.get(_allocationId); + + // Take rewards snapshot to prevent other allos from counting tokens from this allo + _allocations.snapshotRewards( + _allocationId, + graphRewardsManager.onSubgraphAllocationUpdate(allocation.subgraphDeploymentId) + ); + + _allocations.close(_allocationId); + allocationProvisionTracker.release(allocation.indexer, allocation.tokens); + + // Update total allocated tokens for the subgraph deployment + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] - + allocation.tokens; + + emit AllocationHandler.AllocationClosed( + allocation.indexer, + _allocationId, + allocation.subgraphDeploymentId, + allocation.tokens, + _forceClosed + ); + } + + /** + * @notice Checks if an allocation is over-allocated + * @param allocationProvisionTracker The mapping of indexers to their locked tokens + * @param graphStaking The Horizon staking contract to check delegation ratios + * @param _indexer The address of the indexer + * @param _delegationRatio The delegation ratio to consider when locking tokens + * @return True if the allocation is over-allocated, false otherwise + */ + function _isOverAllocated( + mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, + IHorizonStaking graphStaking, + address _indexer, + uint32 _delegationRatio + ) private view returns (bool) { + return !allocationProvisionTracker.check(graphStaking, _indexer, _delegationRatio); + } + + /** + * @notice Verifies ownership of an allocation id by verifying an EIP712 allocation proof + * @dev Requirements: + * - Signer must be the allocation id address + * @param _encodeAllocationProof The EIP712 encoded allocation proof + * @param _allocationId The id of the allocation + * @param _proof The EIP712 proof, an EIP712 signed message of (indexer,allocationId) + */ + function _verifyAllocationProof( + bytes32 _encodeAllocationProof, + address _allocationId, + bytes memory _proof + ) private pure { + address signer = ECDSA.recover(_encodeAllocationProof, _proof); + require( + signer == _allocationId, + AllocationHandler.AllocationHandlerInvalidAllocationProof(signer, _allocationId) + ); + } +} diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol new file mode 100644 index 000000000..a3669fffc --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -0,0 +1,730 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; + +import { ISubgraphService } from "../interfaces/ISubgraphService.sol"; +import { AllocationHandler } from "../libraries/AllocationHandler.sol"; +import { Directory } from "../utilities/Directory.sol"; +import { Allocation } from "./Allocation.sol"; +import { IndexingAgreementDecoder } from "./IndexingAgreementDecoder.sol"; + +library IndexingAgreement { + using IndexingAgreement for StorageManager; + using Allocation for Allocation.State; + using Allocation for mapping(address => Allocation.State); + + /// @notice Versions of Indexing Agreement Metadata + enum IndexingAgreementVersion { + V1 + } + + /** + * @notice Indexer Agreement Data + * @param allocationId The allocation ID + * @param version The indexing agreement version + */ + struct State { + address allocationId; + IndexingAgreementVersion version; + } + + /** + * @notice Wrapper for Indexing Agreement and Collector Agreement Data + * @dev This struct is used to encapsulate the state of an indexing agreement + * @param agreement The indexing agreement state + * @param collectorAgreement The collector agreement data + */ + struct AgreementWrapper { + State agreement; + IRecurringCollector.AgreementData collectorAgreement; + } + + /** + * @notice Accept Indexing Agreement metadata + * @param subgraphDeploymentId The subgraph deployment ID + * @param version The indexing agreement version + * @param terms The indexing agreement terms + */ + struct AcceptIndexingAgreementMetadata { + bytes32 subgraphDeploymentId; + IndexingAgreementVersion version; + bytes terms; + } + + /** + * @notice Update Indexing Agreement metadata + * @param version The indexing agreement version + * @param terms The indexing agreement terms + */ + struct UpdateIndexingAgreementMetadata { + IndexingAgreementVersion version; + bytes terms; + } + + /** + * @notice Indexing Agreement Terms (Version 1) + * @param tokensPerSecond The amount of tokens per second + * @param tokensPerEntityPerSecond The amount of tokens per entity per second + */ + struct IndexingAgreementTermsV1 { + uint256 tokensPerSecond; + uint256 tokensPerEntityPerSecond; + } + + /** + * @notice Parameters for collecting indexing fees + * @param agreementId The ID of the indexing agreement + * @param currentEpoch The current epoch + * @param receiverDestination The address where the collected fees should be sent + * @param data The encoded data containing the number of entities indexed, proof of indexing, and epoch + */ + struct CollectParams { + bytes16 agreementId; + uint256 currentEpoch; + address receiverDestination; + bytes data; + } + + /** + * @notice Nested data for collecting indexing fees V1. + * + * @param entities The number of entities + * @param poi The proof of indexing (POI) + * @param poiBlockNumber The block number of the POI + * @param metadata Additional metadata associated with the collection + */ + struct CollectIndexingFeeDataV1 { + uint256 entities; + bytes32 poi; + uint256 poiBlockNumber; + bytes metadata; + } + + /** + * @notice Storage manager for indexing agreements + * @dev This struct holds the state of indexing agreements and their terms. + * It is used to manage the lifecycle of indexing agreements in the subgraph service. + * @param agreements Mapping of agreement IDs to their states + * @param termsV1 Mapping of agreement IDs to their terms for version 1 agreements + * @param allocationToActiveAgreementId Mapping of allocation IDs to their active agreement IDs + * @custom:storage-location erc7201:graphprotocol.subgraph-service.storage.StorageManager.IndexingAgreement + */ + struct StorageManager { + mapping(bytes16 => State) agreements; + mapping(bytes16 agreementId => IndexingAgreementTermsV1 data) termsV1; + mapping(address allocationId => bytes16 agreementId) allocationToActiveAgreementId; + } + + /** + * @notice Storage location for the indexing agreement storage manager + * @dev Equals keccak256(abi.encode(uint256(keccak256("graphprotocol.subgraph-service.storage.StorageManager.IndexingAgreement")) - 1)) & ~bytes32(uint256(0xff)) + */ + bytes32 public constant INDEXING_AGREEMENT_STORAGE_MANAGER_LOCATION = + 0xb59b65b7215c7fb95ac34d2ad5aed7c775c8bc77ad936b1b43e17b95efc8e400; + + /** + * @notice Emitted when an indexer collects indexing fees from a V1 agreement + * @param indexer The address of the indexer + * @param payer The address paying for the indexing fees + * @param agreementId The id of the agreement + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param currentEpoch The current epoch + * @param tokensCollected The amount of tokens collected + * @param entities The number of entities indexed + * @param poi The proof of indexing + * @param poiBlockNumber The block number of the proof of indexing + * @param metadata Additional metadata associated with the collection + */ + event IndexingFeesCollectedV1( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address allocationId, + bytes32 subgraphDeploymentId, + uint256 currentEpoch, + uint256 tokensCollected, + uint256 entities, + bytes32 poi, + uint256 poiBlockNumber, + bytes metadata + ); + + /** + * @notice Emitted when an indexing agreement is canceled + * @param indexer The address of the indexer + * @param payer The address of the payer + * @param agreementId The id of the agreement + * @param canceledOnBehalfOf The address of the entity that canceled the agreement + */ + event IndexingAgreementCanceled( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address canceledOnBehalfOf + ); + + /** + * @notice Emitted when an indexing agreement is accepted + * @param indexer The address of the indexer + * @param payer The address of the payer + * @param agreementId The id of the agreement + * @param allocationId The id of the allocation + * @param subgraphDeploymentId The id of the subgraph deployment + * @param version The version of the indexing agreement + * @param versionTerms The version data of the indexing agreement + */ + event IndexingAgreementAccepted( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address allocationId, + bytes32 subgraphDeploymentId, + IndexingAgreementVersion version, + bytes versionTerms + ); + + /** + * @notice Emitted when an indexing agreement is updated + * @param indexer The address of the indexer + * @param payer The address of the payer + * @param agreementId The id of the agreement + * @param allocationId The id of the allocation + * @param version The version of the indexing agreement + * @param versionTerms The version data of the indexing agreement + */ + event IndexingAgreementUpdated( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address allocationId, + IndexingAgreementVersion version, + bytes versionTerms + ); + + /** + * @notice Thrown when trying to interact with an agreement with an invalid version + * @param version The invalid version + */ + error IndexingAgreementInvalidVersion(IndexingAgreementVersion version); + + /** + * @notice Thrown when an agreement is not for the subgraph data service + * @param expectedDataService The expected data service address + * @param wrongDataService The wrong data service address + */ + error IndexingAgreementWrongDataService(address expectedDataService, address wrongDataService); + + /** + * @notice Thrown when an agreement and the allocation correspond to different deployment IDs + * @param agreementDeploymentId The agreement's deployment ID + * @param allocationId The allocation ID + * @param allocationDeploymentId The allocation's deployment ID + */ + error IndexingAgreementDeploymentIdMismatch( + bytes32 agreementDeploymentId, + address allocationId, + bytes32 allocationDeploymentId + ); + + /** + * @notice Thrown when the agreement is already accepted + * @param agreementId The agreement ID + */ + error IndexingAgreementAlreadyAccepted(bytes16 agreementId); + + /** + * @notice Thrown when an allocation already has an active agreement + * @param allocationId The allocation ID + */ + error AllocationAlreadyHasIndexingAgreement(address allocationId); + + /** + * @notice Thrown when caller or proxy can not cancel an agreement + * @param owner The address of the owner of the agreement + * @param unauthorized The unauthorized caller + */ + error IndexingAgreementNonCancelableBy(address owner, address unauthorized); + + /** + * @notice Thrown when the agreement is not active + * @param agreementId The agreement ID + */ + error IndexingAgreementNotActive(bytes16 agreementId); + + /** + * @notice Thrown when trying to interact with an agreement not owned by the indexer + * @param agreementId The agreement ID + * @param unauthorizedIndexer The unauthorized indexer + */ + error IndexingAgreementNotAuthorized(bytes16 agreementId, address unauthorizedIndexer); + + /** + * @notice Accept an indexing agreement. + * + * Requirements: + * - Allocation must belong to the indexer and be open + * - Agreement must be for this data service + * - Agreement's subgraph deployment must match the allocation's subgraph deployment + * - Agreement must not have been accepted before + * - Allocation must not have an agreement already + * + * @dev signedRCA.rca.metadata is an encoding of {IndexingAgreement.AcceptIndexingAgreementMetadata} + * + * Emits {IndexingAgreementAccepted} event + * + * @param self The indexing agreement storage manager + * @param allocations The mapping of allocation IDs to their states + * @param allocationId The id of the allocation + * @param signedRCA The signed Recurring Collection Agreement + */ + function accept( + StorageManager storage self, + mapping(address allocationId => Allocation.State allocation) storage allocations, + address allocationId, + IRecurringCollector.SignedRCA calldata signedRCA + ) external { + Allocation.State memory allocation = _requireValidAllocation( + allocations, + allocationId, + signedRCA.rca.serviceProvider + ); + + require( + signedRCA.rca.dataService == address(this), + IndexingAgreementWrongDataService(address(this), signedRCA.rca.dataService) + ); + + AcceptIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAMetadata( + signedRCA.rca.metadata + ); + + State storage agreement = self.agreements[signedRCA.rca.agreementId]; + + require(agreement.allocationId == address(0), IndexingAgreementAlreadyAccepted(signedRCA.rca.agreementId)); + + require( + allocation.subgraphDeploymentId == metadata.subgraphDeploymentId, + IndexingAgreementDeploymentIdMismatch( + metadata.subgraphDeploymentId, + allocationId, + allocation.subgraphDeploymentId + ) + ); + + // Ensure that an allocation can only have one active indexing agreement + require( + self.allocationToActiveAgreementId[allocationId] == bytes16(0), + AllocationAlreadyHasIndexingAgreement(allocationId) + ); + self.allocationToActiveAgreementId[allocationId] = signedRCA.rca.agreementId; + + agreement.version = metadata.version; + agreement.allocationId = allocationId; + + require(metadata.version == IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version)); + _setTermsV1(self, signedRCA.rca.agreementId, metadata.terms); + + emit IndexingAgreementAccepted( + signedRCA.rca.serviceProvider, + signedRCA.rca.payer, + signedRCA.rca.agreementId, + allocationId, + metadata.subgraphDeploymentId, + metadata.version, + metadata.terms + ); + + _directory().recurringCollector().accept(signedRCA); + } + + /** + * @notice Update an indexing agreement. + * + * Requirements: + * - Agreement must be active + * - The indexer must be the service provider of the agreement + * + * @dev signedRCA.rcau.metadata is an encoding of {IndexingAgreement.UpdateIndexingAgreementMetadata} + * + * Emits {IndexingAgreementUpdated} event + * + * @param self The indexing agreement storage manager + * @param indexer The indexer address + * @param signedRCAU The signed Recurring Collection Agreement Update + */ + function update( + StorageManager storage self, + address indexer, + IRecurringCollector.SignedRCAU calldata signedRCAU + ) external { + AgreementWrapper memory wrapper = _get(self, signedRCAU.rcau.agreementId); + require(_isActive(wrapper), IndexingAgreementNotActive(signedRCAU.rcau.agreementId)); + require( + wrapper.collectorAgreement.serviceProvider == indexer, + IndexingAgreementNotAuthorized(signedRCAU.rcau.agreementId, indexer) + ); + + UpdateIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAUMetadata( + signedRCAU.rcau.metadata + ); + + wrapper.agreement.version = metadata.version; + + require(metadata.version == IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version)); + _setTermsV1(self, signedRCAU.rcau.agreementId, metadata.terms); + + emit IndexingAgreementUpdated({ + indexer: wrapper.collectorAgreement.serviceProvider, + payer: wrapper.collectorAgreement.payer, + agreementId: signedRCAU.rcau.agreementId, + allocationId: wrapper.agreement.allocationId, + version: metadata.version, + versionTerms: metadata.terms + }); + + _directory().recurringCollector().update(signedRCAU); + } + + /** + * @notice Cancel an indexing agreement. + * + * @dev This function allows the indexer to cancel an indexing agreement. + * + * Requirements: + * - Agreement must be active + * - The indexer must be the service provider of the agreement + * + * Emits {IndexingAgreementCanceled} event + * + * @param self The indexing agreement storage manager + * @param indexer The indexer address + * @param agreementId The id of the agreement to cancel + */ + function cancel(StorageManager storage self, address indexer, bytes16 agreementId) external { + AgreementWrapper memory wrapper = _get(self, agreementId); + require(_isActive(wrapper), IndexingAgreementNotActive(agreementId)); + require( + wrapper.collectorAgreement.serviceProvider == indexer, + IndexingAgreementNonCancelableBy(wrapper.collectorAgreement.serviceProvider, indexer) + ); + _cancel( + self, + agreementId, + wrapper.agreement, + wrapper.collectorAgreement, + IRecurringCollector.CancelAgreementBy.ServiceProvider + ); + } + + /** + * @notice Cancel an allocation's indexing agreement if it exists. + * + * @dev This function is to be called by the data service when an allocation is closed. + * + * Requirements: + * - The allocation must have an active agreement + * - Agreement must be active + * + * Emits {IndexingAgreementCanceled} event + * + * @param self The indexing agreement storage manager + * @param _allocationId The allocation ID + * @param stale Whether the allocation is stale or not + * + */ + function onCloseAllocation(StorageManager storage self, address _allocationId, bool stale) external { + bytes16 agreementId = self.allocationToActiveAgreementId[_allocationId]; + if (agreementId == bytes16(0)) { + return; + } + + AgreementWrapper memory wrapper = _get(self, agreementId); + if (!_isActive(wrapper)) { + return; + } + + _cancel( + self, + agreementId, + wrapper.agreement, + wrapper.collectorAgreement, + stale + ? IRecurringCollector.CancelAgreementBy.ThirdParty + : IRecurringCollector.CancelAgreementBy.ServiceProvider + ); + } + + /** + * @notice Cancel an indexing agreement by the payer. + * + * @dev This function allows the payer to cancel an indexing agreement. + * + * Requirements: + * - Agreement must be active + * - The caller must be authorized to cancel the agreement in the collector on the payer's behalf + * + * Emits {IndexingAgreementCanceled} event + * + * @param self The indexing agreement storage manager + * @param agreementId The id of the agreement to cancel + */ + function cancelByPayer(StorageManager storage self, bytes16 agreementId) external { + AgreementWrapper memory wrapper = _get(self, agreementId); + require(_isActive(wrapper), IndexingAgreementNotActive(agreementId)); + require( + _directory().recurringCollector().isAuthorized(wrapper.collectorAgreement.payer, msg.sender), + IndexingAgreementNonCancelableBy(wrapper.collectorAgreement.payer, msg.sender) + ); + _cancel( + self, + agreementId, + wrapper.agreement, + wrapper.collectorAgreement, + IRecurringCollector.CancelAgreementBy.Payer + ); + } + + /** + * @notice Collect Indexing fees + * @dev Uses the {RecurringCollector} to collect payment from Graph Horizon payments protocol. + * Fees are distributed to service provider and delegators by {GraphPayments} + * + * Requirements: + * - Allocation must be open + * - Agreement must be active + * - Agreement must be of version V1 + * - The data must be encoded as per {IndexingAgreementDecoder.decodeCollectIndexingFeeDataV1} + * + * Emits a {IndexingFeesCollectedV1} event. + * + * @param self The indexing agreement storage manager + * @param allocations The mapping of allocation IDs to their states + * @param params The parameters for collecting indexing fees + * @return The address of the service provider that collected the fees + * @return The amount of fees collected + */ + function collect( + StorageManager storage self, + mapping(address allocationId => Allocation.State allocation) storage allocations, + CollectParams memory params + ) external returns (address, uint256) { + AgreementWrapper memory wrapper = _get(self, params.agreementId); + Allocation.State memory allocation = _requireValidAllocation( + allocations, + wrapper.agreement.allocationId, + wrapper.collectorAgreement.serviceProvider + ); + require(_isActive(wrapper), IndexingAgreementNotActive(params.agreementId)); + + require( + wrapper.agreement.version == IndexingAgreementVersion.V1, + IndexingAgreementInvalidVersion(wrapper.agreement.version) + ); + + CollectIndexingFeeDataV1 memory data = IndexingAgreementDecoder.decodeCollectIndexingFeeDataV1(params.data); + + uint256 expectedTokens = (data.entities == 0 && data.poi == bytes32(0)) + ? 0 + : _tokensToCollect(self, params.agreementId, wrapper.collectorAgreement, data.entities); + + // `tokensCollected` <= `expectedTokens` because the recurring collector will further narrow + // down the tokens allowed, based on the RCA terms. + uint256 tokensCollected = _directory().recurringCollector().collect( + IGraphPayments.PaymentTypes.IndexingFee, + abi.encode( + IRecurringCollector.CollectParams({ + agreementId: params.agreementId, + collectionId: bytes32(uint256(uint160(wrapper.agreement.allocationId))), + tokens: expectedTokens, + dataServiceCut: 0, + receiverDestination: params.receiverDestination + }) + ) + ); + + emit IndexingFeesCollectedV1( + wrapper.collectorAgreement.serviceProvider, + wrapper.collectorAgreement.payer, + params.agreementId, + wrapper.agreement.allocationId, + allocation.subgraphDeploymentId, + params.currentEpoch, + tokensCollected, + data.entities, + data.poi, + data.poiBlockNumber, + data.metadata + ); + + return (wrapper.collectorAgreement.serviceProvider, tokensCollected); + } + + /** + * @notice Get the indexing agreement for a given agreement ID. + * + * @param self The indexing agreement storage manager + * @param agreementId The id of the indexing agreement + * @return The indexing agreement wrapper containing the agreement state and collector agreement data + */ + function get(StorageManager storage self, bytes16 agreementId) external view returns (AgreementWrapper memory) { + AgreementWrapper memory wrapper = _get(self, agreementId); + require(wrapper.collectorAgreement.dataService == address(this), IndexingAgreementNotActive(agreementId)); + + return wrapper; + } + + /** + * @notice Get the storage manager for indexing agreements. + * @dev This function retrieves the storage manager for indexing agreements. + * @return m The storage manager for indexing agreements + */ + function _getStorageManager() internal pure returns (StorageManager storage m) { + // solhint-disable-next-line no-inline-assembly + assembly { + m.slot := INDEXING_AGREEMENT_STORAGE_MANAGER_LOCATION + } + } + + /** + * @notice Set the terms for an indexing agreement of version V1. + * @dev This function updates the terms of an indexing agreement in the storage manager. + * @param _manager The indexing agreement storage manager + * @param _agreementId The id of the agreement to update + * @param _data The encoded terms data + */ + function _setTermsV1(StorageManager storage _manager, bytes16 _agreementId, bytes memory _data) private { + IndexingAgreementTermsV1 memory newTerms = IndexingAgreementDecoder.decodeIndexingAgreementTermsV1(_data); + _manager.termsV1[_agreementId].tokensPerSecond = newTerms.tokensPerSecond; + _manager.termsV1[_agreementId].tokensPerEntityPerSecond = newTerms.tokensPerEntityPerSecond; + } + + /** + * @notice Cancel an indexing agreement. + * + * @dev This function does the actual agreement cancelation. + * + * Emits {IndexingAgreementCanceled} event + * + * @param _manager The indexing agreement storage manager + * @param _agreementId The id of the agreement to cancel + * @param _agreement The indexing agreement state + * @param _collectorAgreement The collector agreement data + * @param _cancelBy The entity that is canceling the agreement + */ + function _cancel( + StorageManager storage _manager, + bytes16 _agreementId, + State memory _agreement, + IRecurringCollector.AgreementData memory _collectorAgreement, + IRecurringCollector.CancelAgreementBy _cancelBy + ) private { + // Delete the allocation to active agreement link, so that the allocation + // can be assigned a new indexing agreement in the future. + delete _manager.allocationToActiveAgreementId[_agreement.allocationId]; + + emit IndexingAgreementCanceled( + _collectorAgreement.serviceProvider, + _collectorAgreement.payer, + _agreementId, + _cancelBy == IRecurringCollector.CancelAgreementBy.Payer + ? _collectorAgreement.payer + : _collectorAgreement.serviceProvider + ); + + _directory().recurringCollector().cancel(_agreementId, _cancelBy); + } + + /** + * @notice Requires that the allocation is valid and owned by the indexer. + * + * Requirements: + * - Allocation must belong to the indexer + * - Allocation must be open + * + * @param _allocations The mapping of allocation IDs to their states + * @param _allocationId The id of the allocation + * @param _indexer The address of the indexer + * @return The allocation state + */ + function _requireValidAllocation( + mapping(address => Allocation.State) storage _allocations, + address _allocationId, + address _indexer + ) private view returns (Allocation.State memory) { + Allocation.State memory allocation = _allocations.get(_allocationId); + require( + allocation.indexer == _indexer, + ISubgraphService.SubgraphServiceAllocationNotAuthorized(_indexer, _allocationId) + ); + require(allocation.isOpen(), AllocationHandler.AllocationHandlerAllocationClosed(_allocationId)); + + return allocation; + } + + /** + * @notice Calculate the number of tokens to collect for an indexing agreement. + * + * @dev This function calculates the number of tokens to collect based on the agreement terms and the collection time. + * + * @param _manager The indexing agreement storage manager + * @param _agreementId The id of the agreement + * @param _agreement The collector agreement data + * @param _entities The number of entities indexed + * @return The number of tokens to collect + */ + function _tokensToCollect( + StorageManager storage _manager, + bytes16 _agreementId, + IRecurringCollector.AgreementData memory _agreement, + uint256 _entities + ) private view returns (uint256) { + IndexingAgreementTermsV1 memory termsV1 = _manager.termsV1[_agreementId]; + + uint256 collectionSeconds = block.timestamp; + collectionSeconds -= _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; + + return collectionSeconds * (termsV1.tokensPerSecond + termsV1.tokensPerEntityPerSecond * _entities); + } + + /** + * @notice Checks if the agreement is active + * Requirements: + * - The underlying collector agreement has been accepted + * - The underlying collector agreement's data service is this contract + * - The indexing agreement has been accepted and has a valid allocation ID + * @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data + * @return True if the agreement is active, false otherwise + **/ + function _isActive(AgreementWrapper memory wrapper) private view returns (bool) { + return + wrapper.collectorAgreement.dataService == address(this) && + wrapper.collectorAgreement.state == IRecurringCollector.AgreementState.Accepted && + wrapper.agreement.allocationId != address(0); + } + + /** + * @notice Gets the Directory + * @return The Directory contract + */ + function _directory() private view returns (Directory) { + return Directory(address(this)); + } + + /** + * @notice Gets the indexing agreement wrapper for a given agreement ID. + * @dev This function retrieves the indexing agreement wrapper containing the agreement state and collector agreement data. + * @param self The indexing agreement storage manager + * @param agreementId The id of the indexing agreement + * @return The indexing agreement wrapper containing the agreement state and collector agreement data + */ + function _get(StorageManager storage self, bytes16 agreementId) private view returns (AgreementWrapper memory) { + return + AgreementWrapper({ + agreement: self.agreements[agreementId], + collectorAgreement: _directory().recurringCollector().getAgreement(agreementId) + }); + } +} diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol new file mode 100644 index 000000000..f8f5af811 --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { IndexingAgreementDecoderRaw } from "./IndexingAgreementDecoderRaw.sol"; +import { IndexingAgreement } from "./IndexingAgreement.sol"; + +library IndexingAgreementDecoder { + /** + * @notice Thrown when the data can't be decoded as expected + * @param t The type of data that was expected + * @param data The invalid data + */ + error IndexingAgreementDecoderInvalidData(string t, bytes data); + + /** + * @notice Decodes the data for collecting indexing fees. + * + * @param data The data to decode. + * @return agreementId The agreement ID + * @return nestedData The nested encoded data + */ + function decodeCollectData(bytes memory data) public pure returns (bytes16, bytes memory) { + try IndexingAgreementDecoderRaw.decodeCollectData(data) returns (bytes16 agreementId, bytes memory nestedData) { + return (agreementId, nestedData); + } catch { + revert IndexingAgreementDecoderInvalidData("decodeCollectData", data); + } + } + + /** + * @notice Decodes the RCA metadata. + * + * @param data The data to decode. + * @return The decoded data. See {IndexingAgreement.AcceptIndexingAgreementMetadata} + */ + function decodeRCAMetadata( + bytes memory data + ) public pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { + try IndexingAgreementDecoderRaw.decodeRCAMetadata(data) returns ( + IndexingAgreement.AcceptIndexingAgreementMetadata memory decoded + ) { + return decoded; + } catch { + revert IndexingAgreementDecoderInvalidData("decodeRCAMetadata", data); + } + } + + /** + * @notice Decodes the RCAU metadata. + * + * @param data The data to decode. + * @return The decoded data. See {IndexingAgreement.UpdateIndexingAgreementMetadata} + */ + function decodeRCAUMetadata( + bytes memory data + ) public pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { + try IndexingAgreementDecoderRaw.decodeRCAUMetadata(data) returns ( + IndexingAgreement.UpdateIndexingAgreementMetadata memory decoded + ) { + return decoded; + } catch { + revert IndexingAgreementDecoderInvalidData("decodeRCAUMetadata", data); + } + } + + /** + * @notice Decodes the collect data for indexing fees V1. + * + * @param data The data to decode. + * @return The decoded data structure. See {IndexingAgreement.CollectIndexingFeeDataV1} + */ + function decodeCollectIndexingFeeDataV1( + bytes memory data + ) public pure returns (IndexingAgreement.CollectIndexingFeeDataV1 memory) { + try IndexingAgreementDecoderRaw.decodeCollectIndexingFeeDataV1(data) returns ( + IndexingAgreement.CollectIndexingFeeDataV1 memory decoded + ) { + return decoded; + } catch { + revert IndexingAgreementDecoderInvalidData("decodeCollectIndexingFeeDataV1", data); + } + } + + /** + * @notice Decodes the data for indexing agreement terms V1. + * + * @param data The data to decode. + * @return The decoded data structure. See {IndexingAgreement.IndexingAgreementTermsV1} + */ + function decodeIndexingAgreementTermsV1( + bytes memory data + ) public pure returns (IndexingAgreement.IndexingAgreementTermsV1 memory) { + try IndexingAgreementDecoderRaw.decodeIndexingAgreementTermsV1(data) returns ( + IndexingAgreement.IndexingAgreementTermsV1 memory decoded + ) { + return decoded; + } catch { + revert IndexingAgreementDecoderInvalidData("decodeCollectIndexingFeeData", data); + } + } +} diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol new file mode 100644 index 000000000..93b1718bf --- /dev/null +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { IndexingAgreement } from "./IndexingAgreement.sol"; + +library IndexingAgreementDecoderRaw { + /** + * @notice See {IndexingAgreementDecoder.decodeCollectIndexingFeeData} + * @param data The data to decode + * @return agreementId The agreement ID + * @return nestedData The nested encoded data + */ + function decodeCollectData(bytes calldata data) public pure returns (bytes16, bytes memory) { + return abi.decode(data, (bytes16, bytes)); + } + + /** + * @notice See {IndexingAgreementDecoder.decodeRCAMetadata} + * @dev The data should be encoded as {IndexingAgreement.AcceptIndexingAgreementMetadata} + * @param data The data to decode + * @return The decoded data + */ + function decodeRCAMetadata( + bytes calldata data + ) public pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { + return abi.decode(data, (IndexingAgreement.AcceptIndexingAgreementMetadata)); + } + + /** + * @notice See {IndexingAgreementDecoder.decodeRCAUMetadata} + * @dev The data should be encoded as {IndexingAgreement.UpdateIndexingAgreementMetadata} + * @param data The data to decode + * @return The decoded data + */ + function decodeRCAUMetadata( + bytes calldata data + ) public pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { + return abi.decode(data, (IndexingAgreement.UpdateIndexingAgreementMetadata)); + } + + /** + * @notice See {IndexingAgreementDecoder.decodeCollectIndexingFeeDataV1} + * @dev The data should be encoded as (uint256 entities, bytes32 poi, uint256 epoch) + * @param data The data to decode + * @return The decoded collect indexing fee V1 data + * + */ + function decodeCollectIndexingFeeDataV1( + bytes memory data + ) public pure returns (IndexingAgreement.CollectIndexingFeeDataV1 memory) { + return abi.decode(data, (IndexingAgreement.CollectIndexingFeeDataV1)); + } + + /** + * @notice See {IndexingAgreementDecoder.decodeIndexingAgreementTermsV1} + * @dev The data should be encoded as {IndexingAgreement.IndexingAgreementTermsV1} + * @param data The data to decode + * @return The decoded indexing agreement terms + */ + function decodeIndexingAgreementTermsV1( + bytes memory data + ) public pure returns (IndexingAgreement.IndexingAgreementTermsV1 memory) { + return abi.decode(data, (IndexingAgreement.IndexingAgreementTermsV1)); + } +} diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index 78e5fa190..bc64d0eb6 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -1,20 +1,18 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.27; -import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; import { IGraphToken } from "@graphprotocol/contracts/contracts/token/IGraphToken.sol"; -import { IHorizonStakingTypes } from "@graphprotocol/horizon/contracts/interfaces/internal/IHorizonStakingTypes.sol"; import { GraphDirectory } from "@graphprotocol/horizon/contracts/utilities/GraphDirectory.sol"; import { AllocationManagerV1Storage } from "./AllocationManagerStorage.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; -import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { EIP712Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; import { Allocation } from "../libraries/Allocation.sol"; import { LegacyAllocation } from "../libraries/LegacyAllocation.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { ProvisionTracker } from "@graphprotocol/horizon/contracts/data-service/libraries/ProvisionTracker.sol"; +import { AllocationHandler } from "../libraries/AllocationHandler.sol"; /** * @title AllocationManager contract @@ -36,122 +34,6 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca bytes32 private constant EIP712_ALLOCATION_ID_PROOF_TYPEHASH = keccak256("AllocationIdProof(address indexer,address allocationId)"); - /** - * @notice Emitted when an indexer creates an allocation - * @param indexer The address of the indexer - * @param allocationId The id of the allocation - * @param subgraphDeploymentId The id of the subgraph deployment - * @param tokens The amount of tokens allocated - * @param currentEpoch The current epoch - */ - event AllocationCreated( - address indexed indexer, - address indexed allocationId, - bytes32 indexed subgraphDeploymentId, - uint256 tokens, - uint256 currentEpoch - ); - - /** - * @notice Emitted when an indexer collects indexing rewards for an allocation - * @param indexer The address of the indexer - * @param allocationId The id of the allocation - * @param subgraphDeploymentId The id of the subgraph deployment - * @param tokensRewards The amount of tokens collected - * @param tokensIndexerRewards The amount of tokens collected for the indexer - * @param tokensDelegationRewards The amount of tokens collected for delegators - * @param poi The POI presented - * @param currentEpoch The current epoch - * @param poiMetadata The metadata associated with the POI - */ - event IndexingRewardsCollected( - address indexed indexer, - address indexed allocationId, - bytes32 indexed subgraphDeploymentId, - uint256 tokensRewards, - uint256 tokensIndexerRewards, - uint256 tokensDelegationRewards, - bytes32 poi, - bytes poiMetadata, - uint256 currentEpoch - ); - - /** - * @notice Emitted when an indexer resizes an allocation - * @param indexer The address of the indexer - * @param allocationId The id of the allocation - * @param subgraphDeploymentId The id of the subgraph deployment - * @param newTokens The new amount of tokens allocated - * @param oldTokens The old amount of tokens allocated - */ - event AllocationResized( - address indexed indexer, - address indexed allocationId, - bytes32 indexed subgraphDeploymentId, - uint256 newTokens, - uint256 oldTokens - ); - - /** - * @dev Emitted when an indexer closes an allocation - * @param indexer The address of the indexer - * @param allocationId The id of the allocation - * @param subgraphDeploymentId The id of the subgraph deployment - * @param tokens The amount of tokens allocated - * @param forceClosed Whether the allocation was force closed - */ - event AllocationClosed( - address indexed indexer, - address indexed allocationId, - bytes32 indexed subgraphDeploymentId, - uint256 tokens, - bool forceClosed - ); - - /** - * @notice Emitted when a legacy allocation is migrated into the subgraph service - * @param indexer The address of the indexer - * @param allocationId The id of the allocation - * @param subgraphDeploymentId The id of the subgraph deployment - */ - event LegacyAllocationMigrated( - address indexed indexer, - address indexed allocationId, - bytes32 indexed subgraphDeploymentId - ); - - /** - * @notice Emitted when the maximum POI staleness is updated - * @param maxPOIStaleness The max POI staleness in seconds - */ - event MaxPOIStalenessSet(uint256 maxPOIStaleness); - - /** - * @notice Thrown when an allocation proof is invalid - * Both `signer` and `allocationId` should match for a valid proof. - * @param signer The address that signed the proof - * @param allocationId The id of the allocation - */ - error AllocationManagerInvalidAllocationProof(address signer, address allocationId); - - /** - * @notice Thrown when attempting to create an allocation with a zero allocation id - */ - error AllocationManagerInvalidZeroAllocationId(); - - /** - * @notice Thrown when attempting to collect indexing rewards on a closed allocationl - * @param allocationId The id of the allocation - */ - error AllocationManagerAllocationClosed(address allocationId); - - /** - * @notice Thrown when attempting to resize an allocation with the same size - * @param allocationId The id of the allocation - * @param tokens The amount of tokens - */ - error AllocationManagerAllocationSameSize(address allocationId, uint256 tokens); - /** * @notice Initializes the contract and parent contracts * @param _name The name to use for EIP712 domain separation @@ -177,7 +59,7 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca */ function _migrateLegacyAllocation(address _indexer, address _allocationId, bytes32 _subgraphDeploymentId) internal { _legacyAllocations.migrate(_indexer, _allocationId, _subgraphDeploymentId); - emit LegacyAllocationMigrated(_indexer, _allocationId, _subgraphDeploymentId); + emit AllocationHandler.LegacyAllocationMigrated(_indexer, _allocationId, _subgraphDeploymentId); } /** @@ -204,34 +86,24 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca bytes memory _allocationProof, uint32 _delegationRatio ) internal { - require(_allocationId != address(0), AllocationManagerInvalidZeroAllocationId()); - - _verifyAllocationProof(_indexer, _allocationId, _allocationProof); - - // Ensure allocation id is not reused - // need to check both subgraph service (on allocations.create()) and legacy allocations - _legacyAllocations.revertIfExists(_graphStaking(), _allocationId); - - uint256 currentEpoch = _graphEpochManager().currentEpoch(); - Allocation.State memory allocation = _allocations.create( - _indexer, - _allocationId, - _subgraphDeploymentId, - _tokens, - _graphRewardsManager().onSubgraphAllocationUpdate(_subgraphDeploymentId), - currentEpoch + AllocationHandler.allocate( + _allocations, + _legacyAllocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + AllocationHandler.AllocateParams({ + _allocationId: _allocationId, + _allocationProof: _allocationProof, + _encodeAllocationProof: _encodeAllocationProof(_indexer, _allocationId), + _delegationRatio: _delegationRatio, + _indexer: _indexer, + _subgraphDeploymentId: _subgraphDeploymentId, + _tokens: _tokens, + currentEpoch: _graphEpochManager().currentEpoch(), + graphRewardsManager: _graphRewardsManager(), + graphStaking: _graphStaking() + }) ); - - // Check that the indexer has enough tokens available - // Note that the delegation ratio ensures overdelegation cannot be used - allocationProvisionTracker.lock(_graphStaking(), _indexer, _tokens, _delegationRatio); - - // Update total allocated tokens for the subgraph deployment - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] + - allocation.tokens; - - emit AllocationCreated(_indexer, _allocationId, _subgraphDeploymentId, allocation.tokens, currentEpoch); } /** @@ -268,76 +140,25 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca uint32 _delegationRatio, address _paymentsDestination ) internal returns (uint256) { - Allocation.State memory allocation = _allocations.get(_allocationId); - require(allocation.isOpen(), AllocationManagerAllocationClosed(_allocationId)); - - // Mint indexing rewards if all conditions are met - uint256 tokensRewards = (!allocation.isStale(maxPOIStaleness) && - !allocation.isAltruistic() && - _poi != bytes32(0)) && _graphEpochManager().currentEpoch() > allocation.createdAtEpoch - ? _graphRewardsManager().takeRewards(_allocationId) - : 0; - - // ... but we still take a snapshot to ensure the rewards are not accumulated for the next valid POI - _allocations.snapshotRewards( - _allocationId, - _graphRewardsManager().onSubgraphAllocationUpdate(allocation.subgraphDeploymentId) - ); - _allocations.presentPOI(_allocationId); - - // Any pending rewards should have been collected now - _allocations.clearPendingRewards(_allocationId); - - uint256 tokensIndexerRewards = 0; - uint256 tokensDelegationRewards = 0; - if (tokensRewards != 0) { - // Distribute rewards to delegators - uint256 delegatorCut = _graphStaking().getDelegationFeeCut( - allocation.indexer, - address(this), - IGraphPayments.PaymentTypes.IndexingRewards - ); - IHorizonStakingTypes.DelegationPool memory delegationPool = _graphStaking().getDelegationPool( - allocation.indexer, - address(this) + return + AllocationHandler.presentPOI( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + AllocationHandler.PresentParams({ + maxPOIStaleness: maxPOIStaleness, + graphEpochManager: _graphEpochManager(), + graphStaking: _graphStaking(), + graphRewardsManager: _graphRewardsManager(), + graphToken: _graphToken(), + dataService: address(this), + _allocationId: _allocationId, + _poi: _poi, + _poiMetadata: _poiMetadata, + _delegationRatio: _delegationRatio, + _paymentsDestination: _paymentsDestination + }) ); - // If delegation pool has no shares then we don't need to distribute rewards to delegators - tokensDelegationRewards = delegationPool.shares > 0 ? tokensRewards.mulPPM(delegatorCut) : 0; - if (tokensDelegationRewards > 0) { - _graphToken().approve(address(_graphStaking()), tokensDelegationRewards); - _graphStaking().addToDelegationPool(allocation.indexer, address(this), tokensDelegationRewards); - } - - // Distribute rewards to indexer - tokensIndexerRewards = tokensRewards - tokensDelegationRewards; - if (tokensIndexerRewards > 0) { - if (_paymentsDestination == address(0)) { - _graphToken().approve(address(_graphStaking()), tokensIndexerRewards); - _graphStaking().stakeToProvision(allocation.indexer, address(this), tokensIndexerRewards); - } else { - _graphToken().pushTokens(_paymentsDestination, tokensIndexerRewards); - } - } - } - - emit IndexingRewardsCollected( - allocation.indexer, - _allocationId, - allocation.subgraphDeploymentId, - tokensRewards, - tokensIndexerRewards, - tokensDelegationRewards, - _poi, - _poiMetadata, - _graphEpochManager().currentEpoch() - ); - - // Check if the indexer is over-allocated and force close the allocation if necessary - if (_isOverAllocated(allocation.indexer, _delegationRatio)) { - _closeAllocation(_allocationId, true); - } - - return tokensRewards; } /** @@ -358,42 +179,16 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca * @param _delegationRatio The delegation ratio to consider when locking tokens */ function _resizeAllocation(address _allocationId, uint256 _tokens, uint32 _delegationRatio) internal { - Allocation.State memory allocation = _allocations.get(_allocationId); - require(allocation.isOpen(), AllocationManagerAllocationClosed(_allocationId)); - require(_tokens != allocation.tokens, AllocationManagerAllocationSameSize(_allocationId, _tokens)); - - // Update provision tracker - uint256 oldTokens = allocation.tokens; - if (_tokens > oldTokens) { - allocationProvisionTracker.lock(_graphStaking(), allocation.indexer, _tokens - oldTokens, _delegationRatio); - } else { - allocationProvisionTracker.release(allocation.indexer, oldTokens - _tokens); - } - - // Calculate rewards that have been accrued since the last snapshot but not yet issued - uint256 accRewardsPerAllocatedToken = _graphRewardsManager().onSubgraphAllocationUpdate( - allocation.subgraphDeploymentId - ); - uint256 accRewardsPerAllocatedTokenPending = !allocation.isAltruistic() - ? accRewardsPerAllocatedToken - allocation.accRewardsPerAllocatedToken - : 0; - - // Update the allocation - _allocations[_allocationId].tokens = _tokens; - _allocations[_allocationId].accRewardsPerAllocatedToken = accRewardsPerAllocatedToken; - _allocations[_allocationId].accRewardsPending += _graphRewardsManager().calcRewards( - oldTokens, - accRewardsPerAllocatedTokenPending + AllocationHandler.resizeAllocation( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + _graphStaking(), + _graphRewardsManager(), + _allocationId, + _tokens, + _delegationRatio ); - - // Update total allocated tokens for the subgraph deployment - if (_tokens > oldTokens) { - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] += (_tokens - oldTokens); - } else { - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] -= (oldTokens - _tokens); - } - - emit AllocationResized(allocation.indexer, _allocationId, allocation.subgraphDeploymentId, _tokens, oldTokens); } /** @@ -409,27 +204,12 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca * @param _forceClosed Whether the allocation was force closed */ function _closeAllocation(address _allocationId, bool _forceClosed) internal { - Allocation.State memory allocation = _allocations.get(_allocationId); - - // Take rewards snapshot to prevent other allos from counting tokens from this allo - _allocations.snapshotRewards( + AllocationHandler.closeAllocation( + _allocations, + allocationProvisionTracker, + _subgraphAllocatedTokens, + _graphRewardsManager(), _allocationId, - _graphRewardsManager().onSubgraphAllocationUpdate(allocation.subgraphDeploymentId) - ); - - _allocations.close(_allocationId); - allocationProvisionTracker.release(allocation.indexer, allocation.tokens); - - // Update total allocated tokens for the subgraph deployment - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] - - allocation.tokens; - - emit AllocationClosed( - allocation.indexer, - _allocationId, - allocation.subgraphDeploymentId, - allocation.tokens, _forceClosed ); } @@ -441,7 +221,7 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca */ function _setMaxPOIStaleness(uint256 _maxPOIStaleness) internal { maxPOIStaleness = _maxPOIStaleness; - emit MaxPOIStalenessSet(_maxPOIStaleness); + emit AllocationHandler.MaxPOIStalenessSet(_maxPOIStaleness); } /** @@ -461,19 +241,7 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca * @return True if the allocation is over-allocated, false otherwise */ function _isOverAllocated(address _indexer, uint32 _delegationRatio) internal view returns (bool) { - return !allocationProvisionTracker.check(_graphStaking(), _indexer, _delegationRatio); - } - - /** - * @notice Verifies ownership of an allocation id by verifying an EIP712 allocation proof - * @dev Requirements: - * - Signer must be the allocation id address - * @param _indexer The address of the indexer - * @param _allocationId The id of the allocation - * @param _proof The EIP712 proof, an EIP712 signed message of (indexer,allocationId) - */ - function _verifyAllocationProof(address _indexer, address _allocationId, bytes memory _proof) private view { - address signer = ECDSA.recover(_encodeAllocationProof(_indexer, _allocationId), _proof); - require(signer == _allocationId, AllocationManagerInvalidAllocationProof(signer, _allocationId)); + return + AllocationHandler.isOverAllocated(allocationProvisionTracker, _graphStaking(), _indexer, _delegationRatio); } } diff --git a/packages/subgraph-service/contracts/utilities/Directory.sol b/packages/subgraph-service/contracts/utilities/Directory.sol index d068c74b3..8b58d31b4 100644 --- a/packages/subgraph-service/contracts/utilities/Directory.sol +++ b/packages/subgraph-service/contracts/utilities/Directory.sol @@ -4,6 +4,7 @@ pragma solidity 0.8.27; import { IDisputeManager } from "../interfaces/IDisputeManager.sol"; import { ISubgraphService } from "../interfaces/ISubgraphService.sol"; import { IGraphTallyCollector } from "@graphprotocol/horizon/contracts/interfaces/IGraphTallyCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; import { ICuration } from "@graphprotocol/contracts/contracts/curation/ICuration.sol"; /** @@ -25,6 +26,10 @@ abstract contract Directory { /// @dev Required to collect payments via Graph Horizon payments protocol IGraphTallyCollector private immutable GRAPH_TALLY_COLLECTOR; + /// @notice The Recurring Collector contract address + /// @dev Required to collect indexing agreement payments via Graph Horizon payments protocol + IRecurringCollector private immutable RECURRING_COLLECTOR; + /// @notice The Curation contract address /// @dev Required for curation fees distribution ICuration private immutable CURATION; @@ -35,12 +40,14 @@ abstract contract Directory { * @param disputeManager The Dispute Manager contract address * @param graphTallyCollector The Graph Tally Collector contract address * @param curation The Curation contract address + * @param recurringCollector The Recurring Collector contract address */ event SubgraphServiceDirectoryInitialized( address subgraphService, address disputeManager, address graphTallyCollector, - address curation + address curation, + address recurringCollector ); /** @@ -67,14 +74,36 @@ abstract contract Directory { * @param disputeManager The Dispute Manager contract address * @param graphTallyCollector The Graph Tally Collector contract address * @param curation The Curation contract address + * @param recurringCollector_ The Recurring Collector contract address */ - constructor(address subgraphService, address disputeManager, address graphTallyCollector, address curation) { + constructor( + address subgraphService, + address disputeManager, + address graphTallyCollector, + address curation, + address recurringCollector_ + ) { SUBGRAPH_SERVICE = ISubgraphService(subgraphService); DISPUTE_MANAGER = IDisputeManager(disputeManager); GRAPH_TALLY_COLLECTOR = IGraphTallyCollector(graphTallyCollector); CURATION = ICuration(curation); + RECURRING_COLLECTOR = IRecurringCollector(recurringCollector_); - emit SubgraphServiceDirectoryInitialized(subgraphService, disputeManager, graphTallyCollector, curation); + emit SubgraphServiceDirectoryInitialized( + subgraphService, + disputeManager, + graphTallyCollector, + curation, + recurringCollector_ + ); + } + + /** + * @notice Returns the Recurring Collector contract address + * @return The Recurring Collector contract + */ + function recurringCollector() external view returns (IRecurringCollector) { + return RECURRING_COLLECTOR; } /** diff --git a/packages/subgraph-service/package.json b/packages/subgraph-service/package.json index c9b23e0f5..0b000778c 100644 --- a/packages/subgraph-service/package.json +++ b/packages/subgraph-service/package.json @@ -17,9 +17,10 @@ "scripts": { "lint": "pnpm lint:ts && pnpm lint:sol", "lint:ts": "eslint '**/*.{js,ts}' --fix --no-warn-ignored", - "lint:sol": "pnpm lint:sol:prettier && pnpm lint:sol:solhint", + "lint:sol": "pnpm lint:sol:prettier && pnpm lint:sol:solhint && pnpm lint:sol:solhint:test", "lint:sol:prettier": "prettier --write \"contracts/**/*.sol\" \"test/**/*.sol\"", "lint:sol:solhint": "solhint --noPrompt --fix \"contracts/**/*.sol\" --config node_modules/solhint-graph-config/index.js", + "lint:sol:solhint:test": "solhint --noPrompt --fix \"test/unit/subgraphService/indexing-agreement/*\" --config node_modules/solhint-graph-config/index.js", "lint:sol:natspec": "natspec-smells --config natspec-smells.config.js", "clean": "rm -rf build dist cache cache_forge typechain-types", "build": "hardhat compile", diff --git a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol index 0f59013be..639f183d1 100644 --- a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol +++ b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol @@ -14,6 +14,7 @@ import { IHorizonStaking } from "@graphprotocol/horizon/contracts/interfaces/IHo import { IPaymentsEscrow } from "@graphprotocol/horizon/contracts/interfaces/IPaymentsEscrow.sol"; import { IGraphTallyCollector } from "@graphprotocol/horizon/contracts/interfaces/IGraphTallyCollector.sol"; import { GraphTallyCollector } from "@graphprotocol/horizon/contracts/payments/collectors/GraphTallyCollector.sol"; +import { RecurringCollector } from "@graphprotocol/horizon/contracts/payments/collectors/RecurringCollector.sol"; import { PaymentsEscrow } from "@graphprotocol/horizon/contracts/payments/PaymentsEscrow.sol"; import { UnsafeUpgrades } from "openzeppelin-foundry-upgrades/Upgrades.sol"; @@ -43,6 +44,7 @@ abstract contract SubgraphBaseTest is Utils, Constants { GraphPayments graphPayments; IPaymentsEscrow escrow; GraphTallyCollector graphTallyCollector; + RecurringCollector recurringCollector; HorizonStaking private stakingBase; HorizonStakingExtension private stakingExtension; @@ -156,12 +158,20 @@ abstract contract SubgraphBaseTest is Utils, Constants { address(controller), revokeSignerThawingPeriod ); + recurringCollector = new RecurringCollector( + "RecurringCollector", + "1", + address(controller), + revokeSignerThawingPeriod + ); + address subgraphServiceImplementation = address( new SubgraphService( address(controller), address(disputeManager), address(graphTallyCollector), - address(curation) + address(curation), + address(recurringCollector) ) ); address subgraphServiceProxy = UnsafeUpgrades.deployTransparentProxy( diff --git a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol index 720460bc4..6df3474b2 100644 --- a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol @@ -69,7 +69,11 @@ contract DisputeManagerTest is SubgraphServiceSharedTest { assertEq(address(disputeManager.subgraphService()), _subgraphService, "Subgraph service should be set."); } - function _createIndexingDispute(address _allocationId, bytes32 _poi, uint256 _blockNumber) internal returns (bytes32) { + function _createIndexingDispute( + address _allocationId, + bytes32 _poi, + uint256 _blockNumber + ) internal returns (bytes32) { (, address fisherman, ) = vm.readCallers(); bytes32 expectedDisputeId = keccak256(abi.encodePacked(_allocationId, _poi, _blockNumber)); uint256 disputeDeposit = disputeManager.disputeDeposit(); @@ -88,7 +92,7 @@ contract DisputeManagerTest is SubgraphServiceSharedTest { fisherman, disputeDeposit, _allocationId, - _poi, + _poi, _blockNumber, stakeSnapshot, cancellableAt diff --git a/packages/subgraph-service/test/unit/disputeManager/disputes/indexing/create.t.sol b/packages/subgraph-service/test/unit/disputeManager/disputes/indexing/create.t.sol index 62b368835..6ebafebed 100644 --- a/packages/subgraph-service/test/unit/disputeManager/disputes/indexing/create.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/disputes/indexing/create.t.sol @@ -105,9 +105,7 @@ contract DisputeManagerIndexingCreateDisputeTest is DisputeManagerTest { vm.stopPrank(); } - function test_Indexing_Create_DisputesSamePOIAndAllo( - uint256 tokens - ) public useIndexer useAllocation(tokens) { + function test_Indexing_Create_DisputesSamePOIAndAllo(uint256 tokens) public useIndexer useAllocation(tokens) { resetPrank(users.fisherman); bytes32 disputeID = _createIndexingDispute(allocationID, bytes32("POI1"), block.number); @@ -158,7 +156,10 @@ contract DisputeManagerIndexingCreateDisputeTest is DisputeManagerTest { disputeManager.createIndexingDispute(allocationID, bytes32("POI1"), block.number); } - function test_Indexing_Create_DontRevertIf_IndexerIsBelowStake_WithDelegation(uint256 tokens, uint256 delegationTokens) public useIndexer useAllocation(tokens) { + function test_Indexing_Create_DontRevertIf_IndexerIsBelowStake_WithDelegation( + uint256 tokens, + uint256 delegationTokens + ) public useIndexer useAllocation(tokens) { // Close allocation bytes memory data = abi.encode(allocationID); _stopService(users.indexer, data); diff --git a/packages/subgraph-service/test/unit/disputeManager/disputes/query/create.t.sol b/packages/subgraph-service/test/unit/disputeManager/disputes/query/create.t.sol index 94f2fe615..c2b8f1ab3 100644 --- a/packages/subgraph-service/test/unit/disputeManager/disputes/query/create.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/disputes/query/create.t.sol @@ -156,7 +156,10 @@ contract DisputeManagerQueryCreateDisputeTest is DisputeManagerTest { disputeManager.createQueryDispute(attestationData); } - function test_Query_Create_DontRevertIf_IndexerIsBelowStake_WithDelegation(uint256 tokens, uint256 delegationTokens) public useIndexer useAllocation(tokens) { + function test_Query_Create_DontRevertIf_IndexerIsBelowStake_WithDelegation( + uint256 tokens, + uint256 delegationTokens + ) public useIndexer useAllocation(tokens) { // Close allocation bytes memory data = abi.encode(allocationID); _stopService(users.indexer, data); diff --git a/packages/subgraph-service/test/unit/libraries/IndexingAgreement.sol b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.sol new file mode 100644 index 000000000..4afc6707e --- /dev/null +++ b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; +import { IndexingAgreement } from "../../../contracts/libraries/IndexingAgreement.sol"; + +contract IndexingAgreementTest is Test { + function test_StorageManagerLocation() public pure { + assertEq( + IndexingAgreement.INDEXING_AGREEMENT_STORAGE_MANAGER_LOCATION, + keccak256( + abi.encode( + uint256(keccak256("graphprotocol.subgraph-service.storage.StorageManager.IndexingAgreement")) - 1 + ) + ) & ~bytes32(uint256(0xff)) + ); + } +} diff --git a/packages/subgraph-service/test/unit/shared/SubgraphServiceShared.t.sol b/packages/subgraph-service/test/unit/shared/SubgraphServiceShared.t.sol index 9c018d282..8a1c403bb 100644 --- a/packages/subgraph-service/test/unit/shared/SubgraphServiceShared.t.sol +++ b/packages/subgraph-service/test/unit/shared/SubgraphServiceShared.t.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.27; import "forge-std/Test.sol"; import { Allocation } from "../../../contracts/libraries/Allocation.sol"; -import { AllocationManager } from "../../../contracts/utilities/AllocationManager.sol"; +import { AllocationHandler } from "../../../contracts/libraries/AllocationHandler.sol"; import { IDataService } from "@graphprotocol/horizon/contracts/data-service/interfaces/IDataService.sol"; import { ISubgraphService } from "../../../contracts/interfaces/ISubgraphService.sol"; @@ -103,7 +103,7 @@ abstract contract SubgraphServiceSharedTest is HorizonStakingSharedTest { vm.expectEmit(address(subgraphService)); emit IDataService.ServiceStarted(_indexer, _data); - emit AllocationManager.AllocationCreated(_indexer, allocationId, subgraphDeploymentId, tokens, currentEpoch); + emit AllocationHandler.AllocationCreated(_indexer, allocationId, subgraphDeploymentId, tokens, currentEpoch); // TODO: improve this uint256 accRewardsPerAllocatedToken = 0; @@ -141,7 +141,7 @@ abstract contract SubgraphServiceSharedTest is HorizonStakingSharedTest { ); vm.expectEmit(address(subgraphService)); - emit AllocationManager.AllocationClosed( + emit AllocationHandler.AllocationClosed( _indexer, allocationId, allocation.subgraphDeploymentId, diff --git a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol index 3b1a74e18..4f3444c62 100644 --- a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol @@ -10,11 +10,11 @@ import { IHorizonStakingTypes } from "@graphprotocol/horizon/contracts/interface import { IGraphTallyCollector } from "@graphprotocol/horizon/contracts/interfaces/IGraphTallyCollector.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { LinkedList } from "@graphprotocol/horizon/contracts/libraries/LinkedList.sol"; -import { IDataServiceFees } from "@graphprotocol/horizon/contracts/data-service/interfaces/IDataServiceFees.sol"; import { IHorizonStakingTypes } from "@graphprotocol/horizon/contracts/interfaces/internal/IHorizonStakingTypes.sol"; +import { StakeClaims } from "@graphprotocol/horizon/contracts/data-service/libraries/StakeClaims.sol"; import { Allocation } from "../../../contracts/libraries/Allocation.sol"; -import { AllocationManager } from "../../../contracts/utilities/AllocationManager.sol"; +import { AllocationHandler } from "../../../contracts/libraries/AllocationHandler.sol"; import { ISubgraphService } from "../../../contracts/interfaces/ISubgraphService.sol"; import { LegacyAllocation } from "../../../contracts/libraries/LegacyAllocation.sol"; import { SubgraphServiceSharedTest } from "../shared/SubgraphServiceShared.t.sol"; @@ -114,7 +114,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { } vm.expectEmit(address(subgraphService)); - emit AllocationManager.AllocationResized( + emit AllocationHandler.AllocationResized( _indexer, _allocationId, subgraphDeploymentId, @@ -156,7 +156,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { ); vm.expectEmit(address(subgraphService)); - emit AllocationManager.AllocationClosed( + emit AllocationHandler.AllocationClosed( allocation.indexer, _allocationId, allocation.subgraphDeploymentId, @@ -205,7 +205,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { uint256 paymentCollected = 0; address allocationId; IndexingRewardsData memory indexingRewardsData; - CollectPaymentData memory collectPaymentDataBefore = _collectPaymentDataBefore(_indexer); + CollectPaymentData memory collectPaymentDataBefore = _collectPaymentData(_indexer); if (_paymentType == IGraphPayments.PaymentTypes.QueryFee) { paymentCollected = _handleQueryFeeCollection(_indexer, _data); @@ -219,7 +219,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { // collect rewards subgraphService.collect(_indexer, _paymentType, _data); - CollectPaymentData memory collectPaymentDataAfter = _collectPaymentDataAfter(_indexer); + CollectPaymentData memory collectPaymentDataAfter = _collectPaymentData(_indexer); if (_paymentType == IGraphPayments.PaymentTypes.QueryFee) { _verifyQueryFeeCollection( @@ -240,42 +240,24 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { } } - function _collectPaymentDataBefore(address _indexer) private view returns (CollectPaymentData memory) { + function _collectPaymentData( + address _indexer + ) internal view returns (CollectPaymentData memory collectPaymentData) { address paymentsDestination = subgraphService.paymentsDestination(_indexer); - CollectPaymentData memory collectPaymentDataBefore; - collectPaymentDataBefore.rewardsDestinationBalance = token.balanceOf(paymentsDestination); - collectPaymentDataBefore.indexerProvisionBalance = staking.getProviderTokensAvailable( + collectPaymentData.rewardsDestinationBalance = token.balanceOf(paymentsDestination); + collectPaymentData.indexerProvisionBalance = staking.getProviderTokensAvailable( _indexer, address(subgraphService) ); - collectPaymentDataBefore.delegationPoolBalance = staking.getDelegatedTokensAvailable( + collectPaymentData.delegationPoolBalance = staking.getDelegatedTokensAvailable( _indexer, address(subgraphService) ); - collectPaymentDataBefore.indexerBalance = token.balanceOf(_indexer); - collectPaymentDataBefore.curationBalance = token.balanceOf(address(curation)); - collectPaymentDataBefore.lockedTokens = subgraphService.feesProvisionTracker(_indexer); - collectPaymentDataBefore.indexerStake = staking.getStake(_indexer); - return collectPaymentDataBefore; - } - - function _collectPaymentDataAfter(address _indexer) private view returns (CollectPaymentData memory) { - CollectPaymentData memory collectPaymentDataAfter; - address paymentsDestination = subgraphService.paymentsDestination(_indexer); - collectPaymentDataAfter.rewardsDestinationBalance = token.balanceOf(paymentsDestination); - collectPaymentDataAfter.indexerProvisionBalance = staking.getProviderTokensAvailable( - _indexer, - address(subgraphService) - ); - collectPaymentDataAfter.delegationPoolBalance = staking.getDelegatedTokensAvailable( - _indexer, - address(subgraphService) - ); - collectPaymentDataAfter.indexerBalance = token.balanceOf(_indexer); - collectPaymentDataAfter.curationBalance = token.balanceOf(address(curation)); - collectPaymentDataAfter.lockedTokens = subgraphService.feesProvisionTracker(_indexer); - collectPaymentDataAfter.indexerStake = staking.getStake(_indexer); - return collectPaymentDataAfter; + collectPaymentData.indexerBalance = token.balanceOf(_indexer); + collectPaymentData.curationBalance = token.balanceOf(address(curation)); + collectPaymentData.lockedTokens = subgraphService.feesProvisionTracker(_indexer); + collectPaymentData.indexerStake = staking.getStake(_indexer); + return collectPaymentData; } function _handleQueryFeeCollection( @@ -359,7 +341,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { indexingRewardsData.tokensIndexerRewards = paymentCollected - indexingRewardsData.tokensDelegationRewards; vm.expectEmit(address(subgraphService)); - emit AllocationManager.IndexingRewardsCollected( + emit AllocationHandler.IndexingRewardsCollected( allocation.indexer, allocationId, allocation.subgraphDeploymentId, @@ -381,7 +363,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { CollectPaymentData memory collectPaymentDataBefore, CollectPaymentData memory collectPaymentDataAfter ) private view { - (IGraphTallyCollector.SignedRAV memory signedRav, uint256 tokensToCollect) = abi.decode( + (IGraphTallyCollector.SignedRAV memory signedRav, ) = abi.decode( _data, (IGraphTallyCollector.SignedRAV, uint256) ); @@ -422,7 +404,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { // Check the stake claim LinkedList.List memory claimsList = _getClaimList(_indexer); bytes32 claimId = _buildStakeClaimId(_indexer, claimsList.nonce - 1); - IDataServiceFees.StakeClaim memory stakeClaim = _getStakeClaim(claimId); + StakeClaims.StakeClaim memory stakeClaim = _getStakeClaim(claimId); uint64 disputePeriod = disputeManager.getDisputePeriod(); assertEq(stakeClaim.tokens, tokensToLock); assertEq(stakeClaim.createdAt, block.timestamp); @@ -486,7 +468,7 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { function _migrateLegacyAllocation(address _indexer, address _allocationId, bytes32 _subgraphDeploymentID) internal { vm.expectEmit(address(subgraphService)); - emit AllocationManager.LegacyAllocationMigrated(_indexer, _allocationId, _subgraphDeploymentID); + emit AllocationHandler.LegacyAllocationMigrated(_indexer, _allocationId, _subgraphDeploymentID); subgraphService.migrateLegacyAllocation(_indexer, _allocationId, _subgraphDeploymentID); @@ -531,12 +513,12 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { } function _buildStakeClaimId(address _indexer, uint256 _nonce) private view returns (bytes32) { - return keccak256(abi.encodePacked(address(subgraphService), _indexer, _nonce)); + return StakeClaims.buildStakeClaimId(address(subgraphService), _indexer, _nonce); } - function _getStakeClaim(bytes32 _claimId) private view returns (IDataServiceFees.StakeClaim memory) { + function _getStakeClaim(bytes32 _claimId) private view returns (StakeClaims.StakeClaim memory) { (uint256 tokens, uint256 createdAt, uint256 releasableAt, bytes32 nextClaim) = subgraphService.claims(_claimId); - return IDataServiceFees.StakeClaim(tokens, createdAt, releasableAt, nextClaim); + return StakeClaims.StakeClaim(tokens, createdAt, releasableAt, nextClaim); } // This doesn't matter for testing because the metadata is not decoded onchain but it's expected to be of the form: diff --git a/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol b/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol index c9984bdba..ad70e3abc 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.27; import "forge-std/Test.sol"; import { Allocation } from "../../../../contracts/libraries/Allocation.sol"; -import { AllocationManager } from "../../../../contracts/utilities/AllocationManager.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphService.sol"; import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; @@ -86,7 +86,7 @@ contract SubgraphServiceAllocationResizeTest is SubgraphServiceTest { uint256 tokens ) public useIndexer useAllocation(tokens) { vm.expectRevert( - abi.encodeWithSelector(AllocationManager.AllocationManagerAllocationSameSize.selector, allocationID, tokens) + abi.encodeWithSelector(AllocationHandler.AllocationHandlerAllocationSameSize.selector, allocationID, tokens) ); subgraphService.resizeAllocation(users.indexer, allocationID, tokens); } @@ -99,7 +99,7 @@ contract SubgraphServiceAllocationResizeTest is SubgraphServiceTest { bytes memory data = abi.encode(allocationID); _stopService(users.indexer, data); vm.expectRevert( - abi.encodeWithSelector(AllocationManager.AllocationManagerAllocationClosed.selector, allocationID) + abi.encodeWithSelector(AllocationHandler.AllocationHandlerAllocationClosed.selector, allocationID) ); subgraphService.resizeAllocation(users.indexer, allocationID, resizeTokens); } diff --git a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol index 2f132e132..0a762e958 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol @@ -8,7 +8,7 @@ import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/ import { ProvisionTracker } from "@graphprotocol/horizon/contracts/data-service/libraries/ProvisionTracker.sol"; import { Allocation } from "../../../../contracts/libraries/Allocation.sol"; -import { AllocationManager } from "../../../../contracts/utilities/AllocationManager.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphService.sol"; import { LegacyAllocation } from "../../../../contracts/libraries/LegacyAllocation.sol"; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; @@ -97,7 +97,7 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { bytes32 digest = subgraphService.encodeAllocationProof(users.indexer, address(0)); (uint8 v, bytes32 r, bytes32 s) = vm.sign(allocationIDPrivateKey, digest); bytes memory data = abi.encode(subgraphDeployment, tokens, address(0), abi.encodePacked(r, s, v)); - vm.expectRevert(abi.encodeWithSelector(AllocationManager.AllocationManagerInvalidZeroAllocationId.selector)); + vm.expectRevert(abi.encodeWithSelector(AllocationHandler.AllocationHandlerInvalidZeroAllocationId.selector)); subgraphService.startService(users.indexer, data); } @@ -113,7 +113,7 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { bytes memory data = abi.encode(subgraphDeployment, tokens, allocationID, abi.encodePacked(r, s, v)); vm.expectRevert( abi.encodeWithSelector( - AllocationManager.AllocationManagerInvalidAllocationProof.selector, + AllocationHandler.AllocationHandlerInvalidAllocationProof.selector, signer, allocationID ) diff --git a/packages/subgraph-service/test/unit/subgraphService/allocation/stop.t.sol b/packages/subgraph-service/test/unit/subgraphService/allocation/stop.t.sol index 2c4391cb2..456ed081f 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/stop.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/stop.t.sol @@ -8,7 +8,6 @@ import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/ import { ProvisionTracker } from "@graphprotocol/horizon/contracts/data-service/libraries/ProvisionTracker.sol"; import { Allocation } from "../../../../contracts/libraries/Allocation.sol"; -import { AllocationManager } from "../../../../contracts/utilities/AllocationManager.sol"; import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphService.sol"; import { LegacyAllocation } from "../../../../contracts/libraries/LegacyAllocation.sol"; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol deleted file mode 100644 index aff11d578..000000000 --- a/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.27; - -import "forge-std/Test.sol"; - -import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; - -import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphService.sol"; -import { SubgraphServiceTest } from "../SubgraphService.t.sol"; - -contract SubgraphServiceCollectTest is SubgraphServiceTest { - /* - * TESTS - */ - - function test_SubgraphService_Collect_RevertWhen_InvalidPayment( - uint256 tokens - ) public useIndexer useAllocation(tokens) { - IGraphPayments.PaymentTypes invalidPaymentType = IGraphPayments.PaymentTypes.IndexingFee; - vm.expectRevert( - abi.encodeWithSelector(ISubgraphService.SubgraphServiceInvalidPaymentType.selector, invalidPaymentType) - ); - subgraphService.collect(users.indexer, invalidPaymentType, ""); - } -} diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/indexing/indexing.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/indexing/indexing.t.sol index c97416157..a1beb5f30 100644 --- a/packages/subgraph-service/test/unit/subgraphService/collect/indexing/indexing.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/collect/indexing/indexing.t.sol @@ -172,7 +172,9 @@ contract SubgraphServiceCollectIndexingTest is SubgraphServiceTest { subgraphService.collect(newIndexer, paymentType, data); } - function test_SubgraphService_Collect_Indexing_RevertWhen_IncorrectPaymentType(uint256 tokens) public useIndexer useAllocation(tokens) { + function test_SubgraphService_Collect_Indexing_RevertWhen_IncorrectPaymentType( + uint256 tokens + ) public useIndexer useAllocation(tokens) { bytes memory data = abi.encode(allocationID, bytes32("POI"), _getHardcodedPOIMetadata()); // skip time to ensure allocation gets rewards diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol new file mode 100644 index 000000000..29f83126c --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; + +import { Allocation } from "../../../../contracts/libraries/Allocation.sol"; +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; +import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; +import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphService.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenPaused( + address allocationId, + address operator, + IRecurringCollector.SignedRCA calldata signedRCA + ) public withSafeIndexerOrOperator(operator) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + resetPrank(operator); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenNotAuthorized( + address allocationId, + address operator, + IRecurringCollector.SignedRCA calldata signedRCA + ) public withSafeIndexerOrOperator(operator) { + vm.assume(operator != signedRCA.rca.serviceProvider); + resetPrank(operator); + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerNotAuthorized.selector, + signedRCA.rca.serviceProvider, + operator + ); + vm.expectRevert(expectedErr); + subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidProvision( + address indexer, + uint256 unboundedTokens, + address allocationId, + IRecurringCollector.SignedRCA memory signedRCA + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, 1, minimumProvisionTokens - 1); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + + signedRCA.rca.serviceProvider = indexer; + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + minimumProvisionTokens, + maximumProvisionTokens + ); + vm.expectRevert(expectedErr); + subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenIndexerNotRegistered( + address indexer, + uint256 unboundedTokens, + address allocationId, + IRecurringCollector.SignedRCA memory signedRCA + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + signedRCA.rca.serviceProvider = indexer; + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.acceptIndexingAgreement(allocationId, signedRCA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenNotDataService( + Seed memory seed, + address incorrectDataService + ) public { + vm.assume(incorrectDataService != address(subgraphService)); + + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + acceptable.rca.dataService = incorrectDataService; + IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( + acceptable.rca, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementWrongDataService.selector, + address(subgraphService), + unacceptable.rca.dataService + ); + vm.expectRevert(expectedErr); + vm.prank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidMetadata(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + acceptable.rca.metadata = bytes("invalid"); + IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( + acceptable.rca, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeRCAMetadata", + unacceptable.rca.metadata + ); + vm.expectRevert(expectedErr); + vm.prank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidAllocation( + Seed memory seed, + address invalidAllocationId + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + + bytes memory expectedErr = abi.encodeWithSelector( + Allocation.AllocationDoesNotExist.selector, + invalidAllocationId + ); + vm.expectRevert(expectedErr); + vm.prank(indexerState.addr); + subgraphService.acceptIndexingAgreement(invalidAllocationId, acceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAllocationNotAuthorized(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerStateA = _withIndexer(ctx); + IndexerState memory indexerStateB = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptableA = _generateAcceptableSignedRCA(ctx, indexerStateA.addr); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceAllocationNotAuthorized.selector, + indexerStateA.addr, + indexerStateB.allocationId + ); + vm.expectRevert(expectedErr); + vm.prank(indexerStateA.addr); + subgraphService.acceptIndexingAgreement(indexerStateB.allocationId, acceptableA); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAllocationClosed(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + + resetPrank(indexerState.addr); + subgraphService.stopService(indexerState.addr, abi.encode(indexerState.allocationId)); + + bytes memory expectedErr = abi.encodeWithSelector( + AllocationHandler.AllocationHandlerAllocationClosed.selector, + indexerState.allocationId + ); + vm.expectRevert(expectedErr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenDeploymentIdMismatch( + Seed memory seed, + bytes32 wrongSubgraphDeploymentId + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + vm.assume(indexerState.subgraphDeploymentId != wrongSubgraphDeploymentId); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + acceptable.rca.metadata = abi.encode(_newAcceptIndexingAgreementMetadataV1(wrongSubgraphDeploymentId)); + IRecurringCollector.SignedRCA memory unacceptable = _recurringCollectorHelper.generateSignedRCA( + acceptable.rca, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementDeploymentIdMismatch.selector, + wrongSubgraphDeploymentId, + indexerState.allocationId, + indexerState.subgraphDeploymentId + ); + vm.expectRevert(expectedErr); + vm.prank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, unacceptable); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAccepted(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementAlreadyAccepted.selector, + accepted.rca.agreementId + ); + vm.expectRevert(expectedErr); + resetPrank(ctx.indexers[0].addr); + subgraphService.acceptIndexingAgreement(ctx.indexers[0].allocationId, accepted); + } + + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAllocated() public {} + + function test_SubgraphService_AcceptIndexingAgreement(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = abi.decode( + acceptable.rca.metadata, + (IndexingAgreement.AcceptIndexingAgreementMetadata) + ); + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingAgreementAccepted( + acceptable.rca.serviceProvider, + acceptable.rca.payer, + acceptable.rca.agreementId, + indexerState.allocationId, + metadata.subgraphDeploymentId, + metadata.version, + metadata.terms + ); + + resetPrank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptable); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol new file mode 100644 index 000000000..822cc21d7 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementBaseTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_Revert_WhenUnsafeAddress_WhenProxyAdmin(address indexer, bytes16 agreementId) public { + address operator = _transparentUpgradeableProxyAdmin(); + assertFalse(_isSafeSubgraphServiceCaller(operator)); + + vm.expectRevert(TransparentUpgradeableProxy.ProxyDeniedAdminAccess.selector); + resetPrank(address(operator)); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_Revert_WhenUnsafeAddress_WhenGraphProxyAdmin(uint256 unboundedTokens) public { + address indexer = GRAPH_PROXY_ADMIN_ADDRESS; + assertFalse(_isSafeSubgraphServiceCaller(indexer)); + + uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + mint(indexer, tokens); + resetPrank(indexer); + vm.expectRevert("Cannot fallback to proxy target"); + staking.provision(indexer, address(subgraphService), tokens, maxSlashingPercentage, disputePeriod); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol new file mode 100644 index 000000000..60a28169c --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; + +import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphService.sol"; +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenPaused( + address rando, + bytes16 agreementId + ) public withSafeIndexerOrOperator(rando) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + resetPrank(rando); + subgraphService.cancelIndexingAgreementByPayer(agreementId); + } + + function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenNotAuthorized( + Seed memory seed, + address rando + ) public withSafeIndexerOrOperator(rando) { + Context storage ctx = _newCtx(seed); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNonCancelableBy.selector, + accepted.rca.payer, + rando + ); + vm.expectRevert(expectedErr); + resetPrank(rando); + subgraphService.cancelIndexingAgreementByPayer(accepted.rca.agreementId); + } + + function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenNotAccepted( + Seed memory seed, + bytes16 agreementId + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + + resetPrank(indexerState.addr); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + agreementId + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreementByPayer(agreementId); + } + + function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenCanceled( + Seed memory seed, + bool cancelSource + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + IRecurringCollector.CancelAgreementBy by = cancelSource + ? IRecurringCollector.CancelAgreementBy.ServiceProvider + : IRecurringCollector.CancelAgreementBy.Payer; + _cancelAgreement(ctx, accepted.rca.agreementId, indexerState.addr, accepted.rca.payer, by); + + resetPrank(indexerState.addr); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + accepted.rca.agreementId + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreementByPayer(accepted.rca.agreementId); + } + + function test_SubgraphService_CancelIndexingAgreementByPayer(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); + + _cancelAgreement( + ctx, + accepted.rca.agreementId, + accepted.rca.serviceProvider, + accepted.rca.payer, + IRecurringCollector.CancelAgreementBy.Payer + ); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenPaused( + address operator, + address indexer, + bytes16 agreementId + ) public withSafeIndexerOrOperator(operator) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + resetPrank(operator); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenNotAuthorized( + address operator, + address indexer, + bytes16 agreementId + ) public withSafeIndexerOrOperator(operator) { + vm.assume(operator != indexer); + resetPrank(operator); + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerNotAuthorized.selector, + indexer, + operator + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenInvalidProvision( + address indexer, + bytes16 agreementId, + uint256 unboundedTokens + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, 1, minimumProvisionTokens - 1); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + minimumProvisionTokens, + maximumProvisionTokens + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenIndexerNotRegistered( + address indexer, + bytes16 agreementId, + uint256 unboundedTokens + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexer, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenNotAccepted( + Seed memory seed, + bytes16 agreementId + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + + resetPrank(indexerState.addr); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + agreementId + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexerState.addr, agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_Revert_WhenCanceled( + Seed memory seed, + bool cancelSource + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + IRecurringCollector.CancelAgreementBy by = cancelSource + ? IRecurringCollector.CancelAgreementBy.ServiceProvider + : IRecurringCollector.CancelAgreementBy.Payer; + _cancelAgreement(ctx, accepted.rca.agreementId, accepted.rca.serviceProvider, accepted.rca.payer, by); + + resetPrank(indexerState.addr); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + accepted.rca.agreementId + ); + vm.expectRevert(expectedErr); + subgraphService.cancelIndexingAgreement(indexerState.addr, accepted.rca.agreementId); + } + + function test_SubgraphService_CancelIndexingAgreement_OK(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); + + _cancelAgreement( + ctx, + accepted.rca.agreementId, + accepted.rca.serviceProvider, + accepted.rca.payer, + IRecurringCollector.CancelAgreementBy.ServiceProvider + ); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol new file mode 100644 index 000000000..85c203b6e --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IPaymentsCollector } from "@graphprotocol/horizon/contracts/interfaces/IPaymentsCollector.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; + +import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphService.sol"; +import { Allocation } from "../../../../contracts/libraries/Allocation.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_CollectIndexingFees_OK( + Seed memory seed, + uint256 entities, + bytes32 poi, + uint256 unboundedTokensCollected + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + + assertEq(subgraphService.feesProvisionTracker(indexerState.addr), 0, "Should be 0 before collect"); + + resetPrank(indexerState.addr); + subgraphService.setPaymentsDestination(indexerState.addr); + + bytes memory data = abi.encode( + IRecurringCollector.CollectParams({ + agreementId: accepted.rca.agreementId, + collectionId: bytes32(uint256(uint160(indexerState.allocationId))), + tokens: 0, + dataServiceCut: 0, + receiverDestination: indexerState.addr + }) + ); + uint256 tokensCollected = bound(unboundedTokensCollected, 1, indexerState.tokens / stakeToFeesRatio); + vm.mockCall( + address(recurringCollector), + abi.encodeWithSelector(IPaymentsCollector.collect.selector, IGraphPayments.PaymentTypes.IndexingFee, data), + abi.encode(tokensCollected) + ); + vm.expectCall( + address(recurringCollector), + abi.encodeCall(IPaymentsCollector.collect, (IGraphPayments.PaymentTypes.IndexingFee, data)) + ); + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingFeesCollectedV1( + indexerState.addr, + accepted.rca.payer, + accepted.rca.agreementId, + indexerState.allocationId, + indexerState.subgraphDeploymentId, + epochManager.currentEpoch(), + tokensCollected, + entities, + poi, + epochManager.currentEpochBlock(), + bytes("") + ); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, epochManager.currentEpochBlock(), bytes("")) + ); + + assertEq( + subgraphService.feesProvisionTracker(indexerState.addr), + tokensCollected * stakeToFeesRatio, + "Should be exactly locked tokens" + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenPaused( + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(indexer) { + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + resetPrank(indexer); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenNotAuthorized( + address operator, + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(operator) { + vm.assume(operator != indexer); + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + resetPrank(operator); + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerNotAuthorized.selector, + indexer, + operator + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenInvalidProvision( + uint256 unboundedTokens, + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, 1, minimumProvisionTokens - 1); + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + minimumProvisionTokens, + maximumProvisionTokens + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenIndexerNotRegistered( + uint256 unboundedTokens, + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenInvalidAgreement( + Seed memory seed, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + + bytes memory expectedErr = abi.encodeWithSelector(Allocation.AllocationDoesNotExist.selector, address(0)); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Reverts_WhenStopService( + Seed memory seed, + uint256 entities, + bytes32 poi + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + + resetPrank(indexerState.addr); + subgraphService.stopService(indexerState.addr, abi.encode(indexerState.allocationId)); + + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + + bytes memory expectedErr = abi.encodeWithSelector( + AllocationHandler.AllocationHandlerAllocationClosed.selector, + indexerState.allocationId + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + + function test_SubgraphService_CollectIndexingFees_Reverts_WhenCloseStaleAllocation( + Seed memory seed, + uint256 entities, + bytes32 poi + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + + skip(maxPOIStaleness + 1); + resetPrank(indexerState.addr); + subgraphService.closeStaleAllocation(indexerState.allocationId); + + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + + bytes memory expectedErr = abi.encodeWithSelector( + AllocationHandler.AllocationHandlerAllocationClosed.selector, + indexerState.allocationId + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol new file mode 100644 index 000000000..433ee0103 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; +import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; + +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndexingAgreementSharedTest { + using PPMMath for uint256; + + struct TestState { + uint256 escrowBalance; + uint256 indexerBalance; + uint256 indexerTokensLocked; + } + + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_CollectIndexingFee_Integration( + Seed memory seed, + uint256 fuzzyTokensCollected + ) public { + uint256 expectedTotalTokensCollected = bound(fuzzyTokensCollected, 1000, 1_000_000); + uint256 expectedTokensLocked = stakeToFeesRatio * expectedTotalTokensCollected; + uint256 expectedProtocolTokensBurnt = expectedTotalTokensCollected.mulPPMRoundUp( + graphPayments.PROTOCOL_PAYMENT_CUT() + ); + uint256 expectedIndexerTokensCollected = expectedTotalTokensCollected - expectedProtocolTokensBurnt; + + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + _addTokensToProvision(indexerState, expectedTokensLocked); + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + ctx.ctxInternal.seed.rca + ); + uint256 agreementTokensPerSecond = 1; + rca.deadline = uint64(block.timestamp); // accept now + rca.endsAt = type(uint64).max; // no expiration + rca.maxInitialTokens = 0; // no initial payment + rca.maxOngoingTokensPerSecond = type(uint32).max; // unlimited tokens per second + rca.minSecondsPerCollection = 1; // 1 second between collections + rca.maxSecondsPerCollection = type(uint32).max; // no maximum time between collections + rca.serviceProvider = indexerState.addr; // service provider is the indexer + rca.dataService = address(subgraphService); // data service is the subgraph service + rca.metadata = _encodeAcceptIndexingAgreementMetadataV1( + indexerState.subgraphDeploymentId, + IndexingAgreement.IndexingAgreementTermsV1({ + tokensPerSecond: agreementTokensPerSecond, + tokensPerEntityPerSecond: 0 // no payment for entities + }) + ); + + _setupPayerWithEscrow(rca.payer, ctx.payer.signerPrivateKey, indexerState.addr, expectedTotalTokensCollected); + + resetPrank(indexerState.addr); + // Set the payments destination to the indexer address + subgraphService.setPaymentsDestination(indexerState.addr); + // Accept the Indexing Agreement + subgraphService.acceptIndexingAgreement( + indexerState.allocationId, + _recurringCollectorHelper.generateSignedRCA(rca, ctx.payer.signerPrivateKey) + ); + // Skip ahead to collection point + skip(expectedTotalTokensCollected / agreementTokensPerSecond); + // vm.assume(block.timestamp < type(uint64).max); + TestState memory beforeCollect = _getState(rca.payer, indexerState.addr); + bytes16 agreementId = rca.agreementId; + uint256 tokensCollected = subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1( + agreementId, + 1, + keccak256(abi.encodePacked("poi")), + epochManager.currentEpochBlock(), + bytes("") + ) + ); + TestState memory afterCollect = _getState(rca.payer, indexerState.addr); + uint256 indexerTokensCollected = afterCollect.indexerBalance - beforeCollect.indexerBalance; + uint256 protocolTokensBurnt = tokensCollected - indexerTokensCollected; + assertEq( + afterCollect.escrowBalance, + beforeCollect.escrowBalance - tokensCollected, + "Escrow balance should be reduced by the amount collected" + ); + assertEq(tokensCollected, expectedTotalTokensCollected, "Total tokens collected should match"); + assertEq(expectedProtocolTokensBurnt, protocolTokensBurnt, "Protocol tokens burnt should match"); + assertEq(indexerTokensCollected, expectedIndexerTokensCollected, "Indexer tokens collected should match"); + assertEq( + afterCollect.indexerTokensLocked, + beforeCollect.indexerTokensLocked + expectedTokensLocked, + "Locked tokens should match" + ); + } + + /* solhint-enable graph/func-name-mixedcase */ + + function _addTokensToProvision(IndexerState memory _indexerState, uint256 _tokensToAddToProvision) private { + deal({ token: address(token), to: _indexerState.addr, give: _tokensToAddToProvision }); + vm.startPrank(_indexerState.addr); + _addToProvision(_indexerState.addr, _tokensToAddToProvision); + vm.stopPrank(); + } + + function _setupPayerWithEscrow( + address _payer, + uint256 _signerPrivateKey, + address _indexer, + uint256 _escrowTokens + ) private { + _recurringCollectorHelper.authorizeSignerWithChecks(_payer, _signerPrivateKey); + + deal({ token: address(token), to: _payer, give: _escrowTokens }); + vm.startPrank(_payer); + _escrow(_escrowTokens, _indexer); + vm.stopPrank(); + } + + function _escrow(uint256 _tokens, address _indexer) private { + token.approve(address(escrow), _tokens); + escrow.deposit(address(recurringCollector), _indexer, _tokens); + } + + function _getState(address _payer, address _indexer) private view returns (TestState memory) { + CollectPaymentData memory collect = _collectPaymentData(_indexer); + + return + TestState({ + escrowBalance: escrow.getBalance(_payer, address(recurringCollector), _indexer), + indexerBalance: collect.indexerBalance, + indexerTokensLocked: collect.lockedTokens + }); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol new file mode 100644 index 000000000..8574e60e7 --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -0,0 +1,380 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; + +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; + +import { Bounder } from "@graphprotocol/horizon/test/unit/utils/Bounder.t.sol"; +import { RecurringCollectorHelper } from "@graphprotocol/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol"; +import { SubgraphServiceTest } from "../SubgraphService.t.sol"; + +contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Bounder { + struct Context { + PayerState payer; + IndexerState[] indexers; + mapping(address allocationId => address indexer) allocations; + ContextInternal ctxInternal; + } + + struct IndexerState { + address addr; + address allocationId; + bytes32 subgraphDeploymentId; + uint256 tokens; + } + + struct PayerState { + address signer; + uint256 signerPrivateKey; + } + + struct ContextInternal { + IndexerSeed[] indexers; + Seed seed; + bool initialized; + } + + struct Seed { + IndexerSeed indexer0; + IndexerSeed indexer1; + IRecurringCollector.RecurringCollectionAgreement rca; + IRecurringCollector.RecurringCollectionAgreementUpdate rcau; + IndexingAgreement.IndexingAgreementTermsV1 termsV1; + PayerSeed payer; + } + + struct IndexerSeed { + address addr; + string label; + uint256 unboundedProvisionTokens; + uint256 unboundedAllocationPrivateKey; + bytes32 subgraphDeploymentId; + } + + struct PayerSeed { + uint256 unboundedSignerPrivateKey; + } + + Context internal _context; + + bytes32 internal constant TRANSPARENT_UPGRADEABLE_PROXY_ADMIN_ADDRESS_SLOT = + 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + address internal constant GRAPH_PROXY_ADMIN_ADDRESS = 0x15c603B7eaA8eE1a272a69C4af3462F926de777F; + + RecurringCollectorHelper internal _recurringCollectorHelper; + + modifier withSafeIndexerOrOperator(address operator) { + vm.assume(_isSafeSubgraphServiceCaller(operator)); + _; + } + + function setUp() public override { + super.setUp(); + + _recurringCollectorHelper = new RecurringCollectorHelper(recurringCollector); + } + + /* + * HELPERS + */ + + function _subgraphServiceSafePrank(address _addr) internal returns (address) { + address originalPrankAddress = msg.sender; + vm.assume(_isSafeSubgraphServiceCaller(_addr)); + resetPrank(_addr); + + return originalPrankAddress; + } + + function _stopOrResetPrank(address _originalSender) internal { + if (_originalSender == 0x1804c8AB1F12E6bbf3894d4083f33e07309d1f38) { + vm.stopPrank(); + } else { + resetPrank(_originalSender); + } + } + + function _cancelAgreement( + Context storage _ctx, + bytes16 _agreementId, + address _indexer, + address _payer, + IRecurringCollector.CancelAgreementBy _by + ) internal { + bool byIndexer = _by == IRecurringCollector.CancelAgreementBy.ServiceProvider; + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingAgreementCanceled(_indexer, _payer, _agreementId, byIndexer ? _indexer : _payer); + + if (byIndexer) { + _subgraphServiceSafePrank(_indexer); + subgraphService.cancelIndexingAgreement(_indexer, _agreementId); + } else { + _subgraphServiceSafePrank(_ctx.payer.signer); + subgraphService.cancelIndexingAgreementByPayer(_agreementId); + } + } + + function _withIndexer(Context storage _ctx) internal returns (IndexerState memory) { + require(_ctx.ctxInternal.indexers.length > 0, "No indexer seeds available"); + + IndexerSeed memory indexerSeed = _ctx.ctxInternal.indexers[_ctx.ctxInternal.indexers.length - 1]; + _ctx.ctxInternal.indexers.pop(); + + indexerSeed.label = string.concat("_withIndexer-", Strings.toString(_ctx.ctxInternal.indexers.length)); + + return _setupIndexer(_ctx, indexerSeed); + } + + function _setupIndexer(Context storage _ctx, IndexerSeed memory _seed) internal returns (IndexerState memory) { + vm.assume(_getIndexer(_ctx, _seed.addr).addr == address(0)); + + (uint256 allocationKey, address allocationId) = boundKeyAndAddr(_seed.unboundedAllocationPrivateKey); + vm.assume(_ctx.allocations[allocationId] == address(0)); + _ctx.allocations[allocationId] = _seed.addr; + + uint256 tokens = bound(_seed.unboundedProvisionTokens, minimumProvisionTokens, MAX_TOKENS); + + IndexerState memory indexer = IndexerState({ + addr: _seed.addr, + allocationId: allocationId, + subgraphDeploymentId: _seed.subgraphDeploymentId, + tokens: tokens + }); + vm.label(indexer.addr, string.concat("_setupIndexer-", _seed.label)); + + // Mint tokens to the indexer + mint(_seed.addr, tokens); + + // Create the indexer + address originalPrank = _subgraphServiceSafePrank(indexer.addr); + _createProvision(indexer.addr, indexer.tokens, fishermanRewardPercentage, disputePeriod); + _register(indexer.addr, abi.encode("url", "geoHash", address(0))); + bytes memory data = _createSubgraphAllocationData( + indexer.addr, + indexer.subgraphDeploymentId, + allocationKey, + indexer.tokens + ); + _startService(indexer.addr, data); + + _ctx.indexers.push(indexer); + + _stopOrResetPrank(originalPrank); + + return indexer; + } + + function _withAcceptedIndexingAgreement( + Context storage _ctx, + IndexerState memory _indexerState + ) internal returns (IRecurringCollector.SignedRCA memory) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _ctx.ctxInternal.seed.rca; + + IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = _newAcceptIndexingAgreementMetadataV1( + _indexerState.subgraphDeploymentId + ); + rca.serviceProvider = _indexerState.addr; + rca.dataService = address(subgraphService); + rca.metadata = abi.encode(metadata); + + rca = _recurringCollectorHelper.sensibleRCA(rca); + + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA( + rca, + _ctx.payer.signerPrivateKey + ); + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, _ctx.payer.signerPrivateKey); + + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingAgreementAccepted( + rca.serviceProvider, + rca.payer, + rca.agreementId, + _indexerState.allocationId, + metadata.subgraphDeploymentId, + metadata.version, + metadata.terms + ); + _subgraphServiceSafePrank(_indexerState.addr); + subgraphService.acceptIndexingAgreement(_indexerState.allocationId, signedRCA); + + return signedRCA; + } + + function _newCtx(Seed memory _seed) internal returns (Context storage) { + require(_context.ctxInternal.initialized == false, "Context already initialized"); + Context storage ctx = _context; + + // Initialize + ctx.ctxInternal.initialized = true; + + // Setup seeds + ctx.ctxInternal.seed = _seed; + ctx.ctxInternal.indexers.push(_seed.indexer0); + ctx.ctxInternal.indexers.push(_seed.indexer1); + + // Setup payer + ctx.payer.signerPrivateKey = boundKey(ctx.ctxInternal.seed.payer.unboundedSignerPrivateKey); + ctx.payer.signer = vm.addr(ctx.payer.signerPrivateKey); + + return ctx; + } + + function _generateAcceptableSignedRCA( + Context storage _ctx, + address _indexerAddress + ) internal returns (IRecurringCollector.SignedRCA memory) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _generateAcceptableRecurringCollectionAgreement( + _ctx, + _indexerAddress + ); + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, _ctx.payer.signerPrivateKey); + + return _recurringCollectorHelper.generateSignedRCA(rca, _ctx.payer.signerPrivateKey); + } + + function _generateAcceptableRecurringCollectionAgreement( + Context storage _ctx, + address _indexerAddress + ) internal view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + IndexerState memory indexer = _requireIndexer(_ctx, _indexerAddress); + IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = _newAcceptIndexingAgreementMetadataV1( + indexer.subgraphDeploymentId + ); + IRecurringCollector.RecurringCollectionAgreement memory rca = _ctx.ctxInternal.seed.rca; + rca.serviceProvider = indexer.addr; + rca.dataService = address(subgraphService); + rca.metadata = abi.encode(metadata); + + return _recurringCollectorHelper.sensibleRCA(rca); + } + + function _generateAcceptableSignedRCAU( + Context storage _ctx, + IRecurringCollector.RecurringCollectionAgreement memory _rca + ) internal view returns (IRecurringCollector.SignedRCAU memory) { + return + _recurringCollectorHelper.generateSignedRCAU( + _generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca), + _ctx.payer.signerPrivateKey + ); + } + + function _generateAcceptableRecurringCollectionAgreementUpdate( + Context storage _ctx, + IRecurringCollector.RecurringCollectionAgreement memory _rca + ) internal view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory) { + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _ctx.ctxInternal.seed.rcau; + rcau.agreementId = _rca.agreementId; + rcau.metadata = _encodeUpdateIndexingAgreementMetadataV1( + _newUpdateIndexingAgreementMetadataV1( + _ctx.ctxInternal.seed.termsV1.tokensPerSecond, + _ctx.ctxInternal.seed.termsV1.tokensPerEntityPerSecond + ) + ); + return _recurringCollectorHelper.sensibleRCAU(rcau); + } + + function _requireIndexer(Context storage _ctx, address _indexer) internal view returns (IndexerState memory) { + IndexerState memory indexerState = _getIndexer(_ctx, _indexer); + require(indexerState.addr != address(0), "Indexer not found in context"); + + return indexerState; + } + + function _getIndexer(Context storage _ctx, address _indexer) internal view returns (IndexerState memory zero) { + for (uint256 i = 0; i < _ctx.indexers.length; i++) { + if (_ctx.indexers[i].addr == _indexer) { + return _ctx.indexers[i]; + } + } + + return zero; + } + + function _isSafeSubgraphServiceCaller(address _candidate) internal view returns (bool) { + return + _candidate != address(0) && + _candidate != address(_transparentUpgradeableProxyAdmin()) && + _candidate != address(proxyAdmin); + } + + function _transparentUpgradeableProxyAdmin() internal view returns (address) { + return + address( + uint160(uint256(vm.load(address(subgraphService), TRANSPARENT_UPGRADEABLE_PROXY_ADMIN_ADDRESS_SLOT))) + ); + } + + function _newAcceptIndexingAgreementMetadataV1( + bytes32 _subgraphDeploymentId + ) internal pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { + return + IndexingAgreement.AcceptIndexingAgreementMetadata({ + subgraphDeploymentId: _subgraphDeploymentId, + version: IndexingAgreement.IndexingAgreementVersion.V1, + terms: abi.encode( + IndexingAgreement.IndexingAgreementTermsV1({ tokensPerSecond: 0, tokensPerEntityPerSecond: 0 }) + ) + }); + } + + function _newUpdateIndexingAgreementMetadataV1( + uint256 _tokensPerSecond, + uint256 _tokensPerEntityPerSecond + ) internal pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { + return + IndexingAgreement.UpdateIndexingAgreementMetadata({ + version: IndexingAgreement.IndexingAgreementVersion.V1, + terms: abi.encode( + IndexingAgreement.IndexingAgreementTermsV1({ + tokensPerSecond: _tokensPerSecond, + tokensPerEntityPerSecond: _tokensPerEntityPerSecond + }) + ) + }); + } + + function _encodeCollectDataV1( + bytes16 _agreementId, + uint256 _entities, + bytes32 _poi, + uint256 _poiBlock, + bytes memory _metadata + ) internal pure returns (bytes memory) { + return + abi.encode( + _agreementId, + abi.encode( + IndexingAgreement.CollectIndexingFeeDataV1({ + entities: _entities, + poi: _poi, + poiBlockNumber: _poiBlock, + metadata: _metadata + }) + ) + ); + } + + function _encodeAcceptIndexingAgreementMetadataV1( + bytes32 _subgraphDeploymentId, + IndexingAgreement.IndexingAgreementTermsV1 memory _terms + ) internal pure returns (bytes memory) { + return + abi.encode( + IndexingAgreement.AcceptIndexingAgreementMetadata({ + subgraphDeploymentId: _subgraphDeploymentId, + version: IndexingAgreement.IndexingAgreementVersion.V1, + terms: abi.encode(_terms) + }) + ); + } + + function _encodeUpdateIndexingAgreementMetadataV1( + IndexingAgreement.UpdateIndexingAgreementMetadata memory _t + ) internal pure returns (bytes memory) { + return abi.encode(_t); + } +} diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol new file mode 100644 index 000000000..336ef97de --- /dev/null +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; + +import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphService.sol"; +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; +import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_UpdateIndexingAgreementIndexingAgreement_Revert_WhenPaused( + address operator, + IRecurringCollector.SignedRCAU calldata signedRCAU + ) public withSafeIndexerOrOperator(operator) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + resetPrank(operator); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + subgraphService.updateIndexingAgreement(operator, signedRCAU); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAuthorized( + address indexer, + address notAuthorized, + IRecurringCollector.SignedRCAU calldata signedRCAU + ) public withSafeIndexerOrOperator(notAuthorized) { + vm.assume(notAuthorized != indexer); + resetPrank(notAuthorized); + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerNotAuthorized.selector, + indexer, + notAuthorized + ); + vm.expectRevert(expectedErr); + subgraphService.updateIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenInvalidProvision( + address indexer, + uint256 unboundedTokens, + IRecurringCollector.SignedRCAU memory signedRCAU + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, 1, minimumProvisionTokens - 1); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + minimumProvisionTokens, + maximumProvisionTokens + ); + vm.expectRevert(expectedErr); + subgraphService.updateIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenIndexerNotRegistered( + address indexer, + uint256 unboundedTokens, + IRecurringCollector.SignedRCAU memory signedRCAU + ) public withSafeIndexerOrOperator(indexer) { + uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.updateIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAccepted(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU( + ctx, + _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr) + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotActive.selector, + acceptableUpdate.rcau.agreementId + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.updateIndexingAgreement(indexerState.addr, acceptableUpdate); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAuthorizedForAgreement( + Seed memory seed + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerStateA = _withIndexer(ctx); + IndexerState memory indexerStateB = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerStateA); + IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU(ctx, accepted.rca); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotAuthorized.selector, + acceptableUpdate.rcau.agreementId, + indexerStateB.addr + ); + vm.expectRevert(expectedErr); + resetPrank(indexerStateB.addr); + subgraphService.updateIndexingAgreement(indexerStateB.addr, acceptableUpdate); + } + + function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenInvalidMetadata(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + IRecurringCollector.RecurringCollectionAgreementUpdate + memory acceptableUpdate = _generateAcceptableRecurringCollectionAgreementUpdate(ctx, accepted.rca); + acceptableUpdate.metadata = bytes("invalid"); + IRecurringCollector.SignedRCAU memory unacceptableUpdate = _recurringCollectorHelper.generateSignedRCAU( + acceptableUpdate, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeRCAUMetadata", + unacceptableUpdate.rcau.metadata + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.updateIndexingAgreement(indexerState.addr, unacceptableUpdate); + } + + function test_SubgraphService_UpdateIndexingAgreement_OK(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU(ctx, accepted.rca); + + IndexingAgreement.UpdateIndexingAgreementMetadata memory metadata = abi.decode( + acceptableUpdate.rcau.metadata, + (IndexingAgreement.UpdateIndexingAgreementMetadata) + ); + + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingAgreementUpdated( + accepted.rca.serviceProvider, + accepted.rca.payer, + acceptableUpdate.rcau.agreementId, + indexerState.allocationId, + metadata.version, + metadata.terms + ); + + resetPrank(indexerState.addr); + subgraphService.updateIndexingAgreement(indexerState.addr, acceptableUpdate); + } + /* solhint-enable graph/func-name-mixedcase */ +}