From a7fb8758ffccad6a6f80dadcc68b12306ac0f615 Mon Sep 17 00:00:00 2001 From: Matias Date: Fri, 23 May 2025 08:50:57 -0300 Subject: [PATCH 01/59] feat: implement Indexing Agreements --- .../extensions/DataServiceFees.sol | 74 +- .../extensions/DataServiceFeesStorage.sol | 4 +- .../interfaces/IDataServiceFees.sol | 64 -- .../data-service/libraries/StakeClaims.sol | 213 +++++ .../utilities/ProvisionManager.sol | 36 +- .../interfaces/IRecurringCollector.sol | 416 ++++++++++ .../collectors/GraphTallyCollector.sol | 5 +- .../collectors/RecurringCollector.sol | 540 +++++++++++++ packages/horizon/package.json | 3 +- .../extensions/DataServiceFees.t.sol | 12 +- .../PaymentsEscrowMock.t.sol | 25 + .../RecurringCollectorAuthorizableTest.t.sol | 20 + .../RecurringCollectorControllerMock.t.sol | 25 + .../RecurringCollectorHelper.t.sol | 148 ++++ .../payments/recurring-collector/accept.t.sol | 51 ++ .../payments/recurring-collector/cancel.t.sol | 53 ++ .../recurring-collector/collect.t.sol | 267 +++++++ .../payments/recurring-collector/shared.t.sol | 194 +++++ .../payments/recurring-collector/update.t.sol | 157 ++++ .../test/unit/utilities/Authorizable.t.sol | 33 +- .../GraphDirectoryImplementation.sol | 1 + .../horizon/test/unit/utils/Bounder.t.sol | 29 +- .../contracts/DisputeManager.sol | 84 ++ .../contracts/SubgraphService.sol | 219 +++++- .../contracts/interfaces/IDisputeManager.sol | 65 +- .../contracts/interfaces/ISubgraphService.sol | 40 +- .../contracts/libraries/AllocationHandler.sol | 597 ++++++++++++++ .../contracts/libraries/IndexingAgreement.sol | 730 ++++++++++++++++++ .../libraries/IndexingAgreementDecoder.sol | 101 +++ .../libraries/IndexingAgreementDecoderRaw.sol | 65 ++ .../contracts/utilities/AllocationManager.sol | 340 ++------ .../contracts/utilities/Directory.sol | 35 +- packages/subgraph-service/package.json | 3 +- .../test/unit/SubgraphBaseTest.t.sol | 12 +- .../unit/disputeManager/DisputeManager.t.sol | 8 +- .../disputes/indexing/create.t.sol | 9 +- .../disputes/query/create.t.sol | 5 +- .../test/unit/libraries/IndexingAgreement.sol | 18 + .../unit/shared/SubgraphServiceShared.t.sol | 6 +- .../subgraphService/SubgraphService.t.sol | 66 +- .../subgraphService/allocation/resize.t.sol | 6 +- .../subgraphService/allocation/start.t.sol | 6 +- .../subgraphService/allocation/stop.t.sol | 1 - .../subgraphService/collect/collect.t.sol | 25 - .../collect/indexing/indexing.t.sol | 4 +- .../indexing-agreement/accept.t.sol | 251 ++++++ .../indexing-agreement/base.t.sol | 35 + .../indexing-agreement/cancel.t.sol | 215 ++++++ .../indexing-agreement/collect.t.sol | 251 ++++++ .../indexing-agreement/integration.t.sol | 142 ++++ .../indexing-agreement/shared.t.sol | 380 +++++++++ .../indexing-agreement/update.t.sol | 170 ++++ 52 files changed, 5718 insertions(+), 541 deletions(-) create mode 100644 packages/horizon/contracts/data-service/libraries/StakeClaims.sol create mode 100644 packages/horizon/contracts/interfaces/IRecurringCollector.sol create mode 100644 packages/horizon/contracts/payments/collectors/RecurringCollector.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorControllerMock.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/accept.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/collect.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/shared.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/update.t.sol create mode 100644 packages/subgraph-service/contracts/libraries/AllocationHandler.sol create mode 100644 packages/subgraph-service/contracts/libraries/IndexingAgreement.sol create mode 100644 packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol create mode 100644 packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol create mode 100644 packages/subgraph-service/test/unit/libraries/IndexingAgreement.sol delete mode 100644 packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol create mode 100644 packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol 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..a53439a7c --- /dev/null +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -0,0 +1,416 @@ +// 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 { + address dataService; + address payer; + address serviceProvider; + uint64 acceptedAt; + uint64 lastCollectionAt; + uint64 endsAt; + uint256 maxInitialTokens; + uint256 maxOngoingTokensPerSecond; + uint32 minSecondsPerCollection; + uint32 maxSecondsPerCollection; + uint64 canceledAt; + 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..919c1d7ed 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -7,6 +7,7 @@ import { IGraphTallyCollector } from "@graphprotocol/horizon/contracts/interface import { IRewardsIssuer } from "@graphprotocol/contracts/contracts/rewards/IRewardsIssuer.sol"; import { IDataService } from "@graphprotocol/horizon/contracts/data-service/interfaces/IDataService.sol"; import { ISubgraphService } from "./interfaces/ISubgraphService.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; import { MulticallUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/MulticallUpgradeable.sol"; @@ -22,6 +23,8 @@ import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils. import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { Allocation } from "./libraries/Allocation.sol"; import { LegacyAllocation } from "./libraries/LegacyAllocation.sol"; +import { IndexingAgreementDecoder } from "./libraries/IndexingAgreementDecoder.sol"; +import { IndexingAgreement } from "./libraries/IndexingAgreement.sol"; /** * @title SubgraphService contract @@ -45,13 +48,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 +66,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 +235,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 +256,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 +272,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 +284,10 @@ contract SubgraphService is ) external override + whenNotPaused onlyAuthorizedForProvision(indexer) onlyValidProvision(indexer) onlyRegisteredIndexer(indexer) - whenNotPaused returns (uint256) { uint256 paymentCollected = 0; @@ -277,6 +296,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 +324,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 +393,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 +563,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 +584,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 +734,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..2a852ffce 100644 --- a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol +++ b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol @@ -3,7 +3,9 @@ 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 { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IndexingAgreement } from "../libraries/IndexingAgreement.sol"; import { Allocation } from "../libraries/Allocation.sol"; import { LegacyAllocation } from "../libraries/LegacyAllocation.sol"; @@ -108,7 +110,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 +259,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 */ +} From 1651716f094c2e3195f0afa03af260131f682be8 Mon Sep 17 00:00:00 2001 From: Matias Date: Mon, 16 Jun 2025 15:06:43 -0300 Subject: [PATCH 02/59] test: Add extra tests for Indexing Agreements --- packages/horizon/package.json | 4 +- .../utilities/ProvisionManager.t.sol | 53 +++++++++ .../utilities/ProvisionManagerImpl.t.sol | 15 +++ .../test/unit/libraries/StakeClaims.t.sol | 18 +++ .../test/unit/mocks/HorizonStakingMock.t.sol | 32 ++++++ .../unit/mocks/InvalidControllerMock.t.sol | 8 ++ .../unit/mocks/PartialControllerMock.t.sol | 33 ++++++ .../RecurringCollectorAuthorizableTest.t.sol | 10 +- .../RecurringCollectorControllerMock.t.sol | 25 ---- .../payments/recurring-collector/base.t.sol | 44 +++++++ .../payments/recurring-collector/shared.t.sol | 6 +- packages/subgraph-service/package.json | 4 +- .../test/unit/libraries/IndexingAgreement.sol | 18 --- .../unit/libraries/IndexingAgreement.t.sol | 107 ++++++++++++++++++ .../indexing-agreement/accept.t.sol | 19 ++++ .../indexing-agreement/base.t.sol | 21 ++++ .../indexing-agreement/collect.t.sol | 37 ++++++ .../indexing-agreement/shared.t.sol | 59 ++++++++-- 18 files changed, 446 insertions(+), 67 deletions(-) create mode 100644 packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol create mode 100644 packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol create mode 100644 packages/horizon/test/unit/libraries/StakeClaims.t.sol create mode 100644 packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol create mode 100644 packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol create mode 100644 packages/horizon/test/unit/mocks/PartialControllerMock.t.sol delete mode 100644 packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorControllerMock.t.sol create mode 100644 packages/horizon/test/unit/payments/recurring-collector/base.t.sol delete mode 100644 packages/subgraph-service/test/unit/libraries/IndexingAgreement.sol create mode 100644 packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol diff --git a/packages/horizon/package.json b/packages/horizon/package.json index 641fc61dd..4e2d86374 100644 --- a/packages/horizon/package.json +++ b/packages/horizon/package.json @@ -26,7 +26,9 @@ "build": "hardhat compile", "test": "forge test", "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test test/deployment/*.ts", - "test:integration": "./scripts/integration" + "test:integration": "./scripts/integration", + "test:coverage": "forge coverage --no-match-coverage \"test/*|contracts/mocks/*\"", + "test:coverage:lcov": "forge coverage --no-match-coverage \"test/*|contracts/mocks/*\" --report lcov" }, "devDependencies": { "@defi-wonderland/natspec-smells": "^1.1.6", diff --git a/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol b/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol new file mode 100644 index 000000000..3617e95a5 --- /dev/null +++ b/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { ProvisionManager } from "../../../../contracts/data-service/utilities/ProvisionManager.sol"; +import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; +import { PartialControllerMock } from "../../mocks/PartialControllerMock.t.sol"; +import { HorizonStakingMock } from "../../mocks/HorizonStakingMock.t.sol"; +import { ProvisionManagerImpl } from "./ProvisionManagerImpl.t.sol"; + +contract ProvisionManagerTest is Test { + ProvisionManagerImpl internal _provisionManager; + HorizonStakingMock internal _horizonStakingMock; + + function setUp() public { + _horizonStakingMock = new HorizonStakingMock(); + + PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](1); + entries[0] = PartialControllerMock.Entry({ name: "Staking", addr: address(_horizonStakingMock) }); + _provisionManager = new ProvisionManagerImpl(address(new PartialControllerMock(entries))); + } + + /* solhint-disable graph/func-name-mixedcase */ + + function test_OnlyValidProvision(address serviceProvider) public { + vm.expectRevert( + abi.encodeWithSelector(ProvisionManager.ProvisionManagerProvisionNotFound.selector, serviceProvider) + ); + _provisionManager.onlyValidProvision_(serviceProvider); + + IHorizonStakingTypes.Provision memory provision; + provision.createdAt = 1; + + _horizonStakingMock.setProvision(serviceProvider, address(_provisionManager), provision); + + _provisionManager.onlyValidProvision_(serviceProvider); + } + + function test_OnlyAuthorizedForProvision(address serviceProvider, address sender) public { + vm.expectRevert( + abi.encodeWithSelector(ProvisionManager.ProvisionManagerNotAuthorized.selector, serviceProvider, sender) + ); + vm.prank(sender); + _provisionManager.onlyAuthorizedForProvision_(serviceProvider); + + _horizonStakingMock.setIsAuthorized(serviceProvider, address(_provisionManager), sender, true); + vm.prank(sender); + _provisionManager.onlyAuthorizedForProvision_(serviceProvider); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol b/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol new file mode 100644 index 000000000..4170d17da --- /dev/null +++ b/packages/horizon/test/unit/data-service/utilities/ProvisionManagerImpl.t.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +import { ProvisionManager } from "../../../../contracts/data-service/utilities/ProvisionManager.sol"; +import { GraphDirectory } from "../../../../contracts/utilities/GraphDirectory.sol"; + +contract ProvisionManagerImpl is GraphDirectory, ProvisionManager { + constructor(address controller) GraphDirectory(controller) {} + + function onlyValidProvision_(address serviceProvider) public view onlyValidProvision(serviceProvider) {} + + function onlyAuthorizedForProvision_( + address serviceProvider + ) public view onlyAuthorizedForProvision(serviceProvider) {} +} diff --git a/packages/horizon/test/unit/libraries/StakeClaims.t.sol b/packages/horizon/test/unit/libraries/StakeClaims.t.sol new file mode 100644 index 000000000..d98bdf78e --- /dev/null +++ b/packages/horizon/test/unit/libraries/StakeClaims.t.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { StakeClaims } from "../../../contracts/data-service/libraries/StakeClaims.sol"; + +contract StakeClaimsTest is Test { + /* solhint-disable graph/func-name-mixedcase */ + + function test_BuildStakeClaimId(address dataService, address serviceProvider, uint256 nonce) public pure { + bytes32 id = StakeClaims.buildStakeClaimId(dataService, serviceProvider, nonce); + bytes32 expectedId = keccak256(abi.encodePacked(dataService, serviceProvider, nonce)); + assertEq(id, expectedId, "StakeClaim ID does not match expected value"); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol b/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol new file mode 100644 index 000000000..647df06f7 --- /dev/null +++ b/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IHorizonStakingTypes } from "../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; + +contract HorizonStakingMock { + mapping(address => mapping(address => IHorizonStakingTypes.Provision)) public provisions; + mapping(address => mapping(address => mapping(address => bool))) public authorizations; + + function setProvision( + address serviceProvider, + address verifier, + IHorizonStakingTypes.Provision memory provision + ) external { + provisions[serviceProvider][verifier] = provision; + } + + function getProvision( + address serviceProvider, + address verifier + ) external view returns (IHorizonStakingTypes.Provision memory) { + return provisions[serviceProvider][verifier]; + } + + function isAuthorized(address serviceProvider, address verifier, address operator) external view returns (bool) { + return authorizations[serviceProvider][verifier][operator]; + } + + function setIsAuthorized(address serviceProvider, address verifier, address operator, bool authorized) external { + authorizations[serviceProvider][verifier][operator] = authorized; + } +} diff --git a/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol b/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol new file mode 100644 index 000000000..f4d31da12 --- /dev/null +++ b/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { PartialControllerMock } from "./PartialControllerMock.t.sol"; + +contract InvalidControllerMock is PartialControllerMock { + constructor() PartialControllerMock(new PartialControllerMock.Entry[](0)) {} +} diff --git a/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol b/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol new file mode 100644 index 000000000..f315ff5ea --- /dev/null +++ b/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { ControllerMock } from "../../../contracts/mocks/ControllerMock.sol"; + +contract PartialControllerMock is ControllerMock, Test { + struct Entry { + string name; + address addr; + } + + address private _invalidContractAddress; + + Entry[] private _contracts; + + constructor(Entry[] memory contracts) ControllerMock(address(0)) { + for (uint256 i = 0; i < contracts.length; i++) { + _contracts.push(Entry({ name: contracts[i].name, addr: contracts[i].addr })); + } + _invalidContractAddress = makeAddr("invalidContractAddress"); + } + + function getContractProxy(bytes32 data) external view override returns (address) { + for (uint256 i = 0; i < _contracts.length; i++) { + if (keccak256(abi.encodePacked(_contracts[i].name)) == data) { + return _contracts[i].addr; + } + } + return _invalidContractAddress; + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol index ff5e39848..91244fea1 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol @@ -5,16 +5,10 @@ import { IAuthorizable } from "../../../../contracts/interfaces/IAuthorizable.so import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; import { AuthorizableTest } from "../../../unit/utilities/Authorizable.t.sol"; -import { RecurringCollectorControllerMock } from "./RecurringCollectorControllerMock.t.sol"; +import { InvalidControllerMock } from "../../mocks/InvalidControllerMock.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 - ); + return new RecurringCollector("RecurringCollector", "1", address(new InvalidControllerMock()), 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 deleted file mode 100644 index 3425e8b01..000000000 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorControllerMock.t.sol +++ /dev/null @@ -1,25 +0,0 @@ -// 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/base.t.sol b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol new file mode 100644 index 000000000..9512fbf87 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorBaseTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_RecoverRCASigner(FuzzyTestAccept memory fuzzyTestAccept) public view { + uint256 signerKey = boundKey(fuzzyTestAccept.unboundedSignerKey); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA( + fuzzyTestAccept.rca, + signerKey + ); + + assertEq( + _recurringCollector.recoverRCASigner(signedRCA), + vm.addr(signerKey), + "Recovered RCA signer does not match" + ); + } + + function test_RecoverRCAUSigner(FuzzyTestUpdate memory fuzzyTestUpdate) public view { + uint256 signerKey = boundKey(fuzzyTestUpdate.fuzzyTestAccept.unboundedSignerKey); + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + fuzzyTestUpdate.rcau, + signerKey + ); + + assertEq( + _recurringCollector.recoverRCAUSigner(signedRCAU), + vm.addr(signerKey), + "Recovered RCAU signer does not match" + ); + } + + /* 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 index 397925600..8dd270b2f 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -9,7 +9,7 @@ import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurring import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; import { Bounder } from "../../../unit/utils/Bounder.t.sol"; -import { RecurringCollectorControllerMock } from "./RecurringCollectorControllerMock.t.sol"; +import { PartialControllerMock } from "../../mocks/PartialControllerMock.t.sol"; import { PaymentsEscrowMock } from "./PaymentsEscrowMock.t.sol"; import { RecurringCollectorHelper } from "./RecurringCollectorHelper.t.sol"; @@ -35,10 +35,12 @@ contract RecurringCollectorSharedTest is Test, Bounder { function setUp() public { _paymentsEscrow = new PaymentsEscrowMock(); + PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](1); + entries[0] = PartialControllerMock.Entry({ name: "PaymentsEscrow", addr: address(_paymentsEscrow) }); _recurringCollector = new RecurringCollector( "RecurringCollector", "1", - address(new RecurringCollectorControllerMock(address(_paymentsEscrow))), + address(new PartialControllerMock(entries)), 1 ); _recurringCollectorHelper = new RecurringCollectorHelper(_recurringCollector); diff --git a/packages/subgraph-service/package.json b/packages/subgraph-service/package.json index 0b000778c..6138c0c61 100644 --- a/packages/subgraph-service/package.json +++ b/packages/subgraph-service/package.json @@ -26,7 +26,9 @@ "build": "hardhat compile", "test": "forge test", "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test test/deployment/*.ts", - "test:integration": "./scripts/integration" + "test:integration": "./scripts/integration", + "test:coverage": "forge coverage --no-match-coverage \"test/*|contracts/mocks/*\"", + "test:coverage:lcov": "forge coverage --no-match-coverage \"test/*|contracts/mocks/*\" --report lcov" }, "devDependencies": { "@defi-wonderland/natspec-smells": "^1.1.6", diff --git a/packages/subgraph-service/test/unit/libraries/IndexingAgreement.sol b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.sol deleted file mode 100644 index 4afc6707e..000000000 --- a/packages/subgraph-service/test/unit/libraries/IndexingAgreement.sol +++ /dev/null @@ -1,18 +0,0 @@ -// 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/libraries/IndexingAgreement.t.sol b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol new file mode 100644 index 000000000..a545c8571 --- /dev/null +++ b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.27; + +import { Test } from "forge-std/Test.sol"; + +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IndexingAgreement } from "../../../contracts/libraries/IndexingAgreement.sol"; +import { Directory } from "../../../contracts/utilities/Directory.sol"; + +contract IndexingAgreementTest is Test { + IndexingAgreement.StorageManager private _storageManager; + address private _mockCollector; + + function setUp() public { + _mockCollector = makeAddr("mockCollector"); + } + + function test_IndexingAgreement_Get(bytes16 agreementId) public { + vm.assume(agreementId != bytes16(0)); + + vm.mockCall( + address(this), + abi.encodeWithSelector(Directory.recurringCollector.selector), + abi.encode(IRecurringCollector(_mockCollector)) + ); + + IRecurringCollector.AgreementData memory collectorAgreement; + vm.mockCall( + _mockCollector, + abi.encodeWithSelector(IRecurringCollector.getAgreement.selector, agreementId), + abi.encode(collectorAgreement) + ); + + vm.expectRevert(abi.encodeWithSelector(IndexingAgreement.IndexingAgreementNotActive.selector, agreementId)); + IndexingAgreement.get(_storageManager, agreementId); + + collectorAgreement.dataService = address(this); + vm.mockCall( + _mockCollector, + abi.encodeWithSelector(IRecurringCollector.getAgreement.selector, agreementId), + abi.encode(collectorAgreement) + ); + + IndexingAgreement.AgreementWrapper memory wrapper = IndexingAgreement.get(_storageManager, agreementId); + assertEq(wrapper.collectorAgreement.dataService, address(this)); + } + + function test_IndexingAgreement_OnCloseAllocation(bytes16 agreementId, address allocationId, bool stale) public { + vm.assume(agreementId != bytes16(0)); + vm.assume(allocationId != address(0)); + + delete _storageManager; + vm.clearMockedCalls(); + + // No active agreement for allocation ID, returns early, no assertions needed + IndexingAgreement.onCloseAllocation(_storageManager, allocationId, stale); + + // Active agreement for allocation ID, but collector agreement is not set, returns early, no assertions needed + _storageManager.allocationToActiveAgreementId[allocationId] = agreementId; + + IRecurringCollector.AgreementData memory collectorAgreement; + + vm.mockCall( + address(this), + abi.encodeWithSelector(Directory.recurringCollector.selector), + abi.encode(IRecurringCollector(_mockCollector)) + ); + + vm.mockCall( + _mockCollector, + abi.encodeWithSelector(IRecurringCollector.getAgreement.selector, agreementId), + abi.encode(collectorAgreement) + ); + + IndexingAgreement.onCloseAllocation(_storageManager, allocationId, stale); + + // Active agreement for allocation ID, collector agreement is set, should cancel the agreement + collectorAgreement.dataService = address(this); + collectorAgreement.state = IRecurringCollector.AgreementState.Accepted; + + _storageManager.agreements[agreementId] = IndexingAgreement.State({ + allocationId: allocationId, + version: IndexingAgreement.IndexingAgreementVersion.V1 + }); + + vm.mockCall( + _mockCollector, + abi.encodeWithSelector(IRecurringCollector.getAgreement.selector, agreementId), + abi.encode(collectorAgreement) + ); + + vm.expectCall(_mockCollector, abi.encodeWithSelector(IRecurringCollector.cancel.selector, agreementId)); + + IndexingAgreement.onCloseAllocation(_storageManager, allocationId, stale); + } + + function test_IndexingAgreement_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/subgraphService/indexing-agreement/accept.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol index 29f83126c..77b18308c 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -225,6 +225,25 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAllocated() public {} + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidTermsData(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + bytes memory invalidTermsData = bytes("invalid terms data"); + acceptable.rca.metadata = abi.encode( + _newAcceptIndexingAgreementMetadataV1Terms(indexerState.subgraphDeploymentId, invalidTermsData) + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeCollectIndexingFeeData", + invalidTermsData + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptable); + } + function test_SubgraphService_AcceptIndexingAgreement(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); 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 index 822cc21d7..2eda9dfc0 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol @@ -2,7 +2,9 @@ pragma solidity 0.8.27; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; contract SubgraphServiceIndexingAgreementBaseTest is SubgraphServiceIndexingAgreementSharedTest { @@ -11,6 +13,25 @@ contract SubgraphServiceIndexingAgreementBaseTest is SubgraphServiceIndexingAgre */ /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_GetIndexingAgreement(Seed memory seed, address operator, bytes16 agreementId) public { + vm.assume(_isSafeSubgraphServiceCaller(operator)); + + resetPrank(address(operator)); + + // Get unkown indexing agreement + vm.expectRevert(abi.encodeWithSelector(IndexingAgreement.IndexingAgreementNotActive.selector, agreementId)); + subgraphService.getIndexingAgreement(agreementId); + + // Accept an indexing agreement + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + IndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement( + accepted.rca.agreementId + ); + _assertEqualAgreement(accepted.rca, agreement); + } + function test_SubgraphService_Revert_WhenUnsafeAddress_WhenProxyAdmin(address indexer, bytes16 agreementId) public { address operator = _transparentUpgradeableProxyAdmin(); assertFalse(_isSafeSubgraphServiceCaller(operator)); 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 index 85c203b6e..57a7a907f 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -11,6 +11,7 @@ import { ISubgraphService } from "../../../../contracts/interfaces/ISubgraphServ import { Allocation } from "../../../../contracts/libraries/Allocation.sol"; import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; +import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; @@ -175,6 +176,21 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ); } + function test_SubgraphService_CollectIndexingFees_Revert_WhenInvalidData(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + + bytes memory invalidData = bytes("invalid data"); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeCollectData", + invalidData + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.collect(indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, invalidData); + } + function test_SubgraphService_CollectIndexingFees_Revert_WhenInvalidAgreement( Seed memory seed, bytes16 agreementId, @@ -195,6 +211,27 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ); } + function test_SubgraphService_CollectIndexingFees_Reverts_WhenInvalidNestedData(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + + resetPrank(indexerState.addr); + + bytes memory invalidNestedData = bytes("invalid nested data"); + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeCollectIndexingFeeDataV1", + invalidNestedData + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectData(accepted.rca.agreementId, invalidNestedData) + ); + } + function test_SubgraphService_CollectIndexingFees_Reverts_WhenStopService( Seed memory seed, uint256 entities, 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 index 8574e60e7..2a5b2385a 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -310,14 +310,25 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun function _newAcceptIndexingAgreementMetadataV1( bytes32 _subgraphDeploymentId + ) internal pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { + return + _newAcceptIndexingAgreementMetadataV1Terms( + _subgraphDeploymentId, + abi.encode( + IndexingAgreement.IndexingAgreementTermsV1({ tokensPerSecond: 0, tokensPerEntityPerSecond: 0 }) + ) + ); + } + + function _newAcceptIndexingAgreementMetadataV1Terms( + bytes32 _subgraphDeploymentId, + bytes memory _terms ) internal pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { return IndexingAgreement.AcceptIndexingAgreementMetadata({ subgraphDeploymentId: _subgraphDeploymentId, version: IndexingAgreement.IndexingAgreementVersion.V1, - terms: abi.encode( - IndexingAgreement.IndexingAgreementTermsV1({ tokensPerSecond: 0, tokensPerEntityPerSecond: 0 }) - ) + terms: _terms }); } @@ -343,18 +354,28 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun bytes32 _poi, uint256 _poiBlock, bytes memory _metadata + ) internal pure returns (bytes memory) { + return _encodeCollectData(_agreementId, _encodeV1Data(_entities, _poi, _poiBlock, _metadata)); + } + + function _encodeCollectData(bytes16 _agreementId, bytes memory _nestedData) internal pure returns (bytes memory) { + return abi.encode(_agreementId, _nestedData); + } + + function _encodeV1Data( + 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 - }) - ) + IndexingAgreement.CollectIndexingFeeDataV1({ + entities: _entities, + poi: _poi, + poiBlockNumber: _poiBlock, + metadata: _metadata + }) ); } @@ -377,4 +398,18 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun ) internal pure returns (bytes memory) { return abi.encode(_t); } + + function _assertEqualAgreement( + IRecurringCollector.RecurringCollectionAgreement memory _expected, + IndexingAgreement.AgreementWrapper memory _actual + ) internal pure { + assertEq(_expected.dataService, _actual.collectorAgreement.dataService); + assertEq(_expected.payer, _actual.collectorAgreement.payer); + assertEq(_expected.serviceProvider, _actual.collectorAgreement.serviceProvider); + assertEq(_expected.endsAt, _actual.collectorAgreement.endsAt); + assertEq(_expected.maxInitialTokens, _actual.collectorAgreement.maxInitialTokens); + assertEq(_expected.maxOngoingTokensPerSecond, _actual.collectorAgreement.maxOngoingTokensPerSecond); + assertEq(_expected.minSecondsPerCollection, _actual.collectorAgreement.minSecondsPerCollection); + assertEq(_expected.maxSecondsPerCollection, _actual.collectorAgreement.maxSecondsPerCollection); + } } From b53ca01e3837392d80cc66050443dfd418e51eba Mon Sep 17 00:00:00 2001 From: Matias Date: Thu, 19 Jun 2025 14:37:42 -0300 Subject: [PATCH 03/59] fix: [TRST-H-1] IndexingAgreement.collect() on CanceledByPayer --- .../interfaces/IRecurringCollector.sol | 9 + .../collectors/RecurringCollector.sol | 16 +- .../contracts/libraries/IndexingAgreement.sol | 40 +++- .../indexing-agreement/integration.t.sol | 188 +++++++++++++----- 4 files changed, 196 insertions(+), 57 deletions(-) diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol index a53439a7c..954b1be94 100644 --- a/packages/horizon/contracts/interfaces/IRecurringCollector.sol +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -413,4 +413,13 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @return The AgreementData struct containing the agreement's data. */ function getAgreement(bytes16 agreementId) external view returns (AgreementData memory); + + /** + * @notice Checks if an agreement is collectable. + * @dev "Collectable" means the agreement is in a valid state that allows collection attempts, + * not that there are necessarily funds available to collect. + * @param agreement The agreement data + * @return The boolean indicating if the agreement is collectable + */ + function isCollectable(AgreementData memory agreement) external view returns (bool); } diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 99122a348..e1225f6fa 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -249,6 +249,11 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC return _getAgreement(agreementId); } + /// @inheritdoc IRecurringCollector + function isCollectable(AgreementData memory agreement) external pure returns (bool) { + return _isCollectable(agreement); + } + /** * @notice Decodes the collect data. * @param data The encoded collect parameters. @@ -270,7 +275,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC function _collect(CollectParams memory _params) private returns (uint256) { AgreementData storage agreement = _getAgreementStorage(_params.agreementId); require( - agreement.state == AgreementState.Accepted || agreement.state == AgreementState.CanceledByPayer, + _isCollectable(agreement), RecurringCollectorAgreementIncorrectState(_params.agreementId, agreement.state) ); @@ -537,4 +542,13 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) { return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; } + + /** + * @notice Requires that the agreement is collectable. + * @param _agreement The agreement data + * @return The boolean indicating if the agreement is collectable + */ + function _isCollectable(AgreementData memory _agreement) private pure returns (bool) { + return _agreement.state == AgreementState.Accepted || _agreement.state == AgreementState.CanceledByPayer; + } } diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index a3669fffc..d1bea35c8 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -254,6 +254,12 @@ library IndexingAgreement { */ error IndexingAgreementNotActive(bytes16 agreementId); + /** + * @notice Thrown when the agreement is not collectable + * @param agreementId The agreement ID + */ + error IndexingAgreementNotCollectable(bytes16 agreementId); + /** * @notice Thrown when trying to interact with an agreement not owned by the indexer * @param agreementId The agreement ID @@ -517,7 +523,7 @@ library IndexingAgreement { wrapper.agreement.allocationId, wrapper.collectorAgreement.serviceProvider ); - require(_isActive(wrapper), IndexingAgreementNotActive(params.agreementId)); + require(_isCollectable(wrapper), IndexingAgreementNotCollectable(params.agreementId)); require( wrapper.agreement.version == IndexingAgreementVersion.V1, @@ -692,17 +698,37 @@ library IndexingAgreement { /** * @notice Checks if the agreement is active * Requirements: + * - The indexing agreement is valid * - 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); + return _isValid(wrapper) && wrapper.collectorAgreement.state == IRecurringCollector.AgreementState.Accepted; + } + + /** + * @notice Checks if the agreement is collectable + * Requirements: + * - The indexing agreement is valid + * - The underlying collector agreement is collectable + * @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data + * @return True if the agreement is collectable, false otherwise + **/ + function _isCollectable(AgreementWrapper memory wrapper) private view returns (bool) { + return _isValid(wrapper) && _directory().recurringCollector().isCollectable(wrapper.collectorAgreement); + } + + /** + * @notice Checks if the agreement is valid + * Requirements: + * - 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 valid, false otherwise + **/ + function _isValid(AgreementWrapper memory wrapper) private view returns (bool) { + return wrapper.collectorAgreement.dataService == address(this) && wrapper.agreement.allocationId != address(0); } /** 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 index 433ee0103..5c8758370 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -18,6 +18,13 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex uint256 indexerTokensLocked; } + struct ExpectedTokens { + uint256 expectedTotalTokensCollected; + uint256 expectedTokensLocked; + uint256 expectedProtocolTokensBurnt; + uint256 expectedIndexerTokensCollected; + } + /* * TESTS */ @@ -27,81 +34,164 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex 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; - + // Setup + ExpectedTokens memory expectedTokens = _newExpectedTokens(fuzzyTokensCollected); Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - _addTokensToProvision(indexerState, expectedTokensLocked); + _addTokensToProvision(indexerState, expectedTokens.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 - }) - ); + _sharedSetup(ctx, rca, indexerState, expectedTokens); - _setupPayerWithEscrow(rca.payer, ctx.payer.signerPrivateKey, indexerState.addr, expectedTotalTokensCollected); + TestState memory beforeCollect = _getState(rca.payer, indexerState.addr); + // Collect 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) + uint256 tokensCollected = subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1( + rca.agreementId, + 1, + keccak256(abi.encodePacked("poi")), + epochManager.currentEpochBlock(), + bytes("") + ) ); - // Skip ahead to collection point - skip(expectedTotalTokensCollected / agreementTokensPerSecond); - // vm.assume(block.timestamp < type(uint64).max); + + TestState memory afterCollect = _getState(rca.payer, indexerState.addr); + _sharedAssert(beforeCollect, afterCollect, expectedTokens, tokensCollected); + } + + function test_SubgraphService_CollectIndexingFee_WhenCanceledByPayer_Integration( + Seed memory seed, + uint256 fuzzyTokensCollected + ) public { + // Setup + ExpectedTokens memory expectedTokens = _newExpectedTokens(fuzzyTokensCollected); + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + ctx.ctxInternal.seed.rca + ); + _sharedSetup(ctx, rca, indexerState, expectedTokens); + + // Cancel the indexing agreement by the payer + resetPrank(ctx.payer.signer); + subgraphService.cancelIndexingAgreementByPayer(rca.agreementId); + TestState memory beforeCollect = _getState(rca.payer, indexerState.addr); - bytes16 agreementId = rca.agreementId; + + // Collect + resetPrank(indexerState.addr); uint256 tokensCollected = subgraphService.collect( indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, _encodeCollectDataV1( - agreementId, + rca.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; + _sharedAssert(beforeCollect, afterCollect, expectedTokens, tokensCollected); + } + + /* solhint-enable graph/func-name-mixedcase */ + + function _sharedSetup( + Context storage _ctx, + IRecurringCollector.RecurringCollectionAgreement memory _rca, + IndexerState memory _indexerState, + ExpectedTokens memory _expectedTokens + ) internal { + _addTokensToProvision(_indexerState, _expectedTokens.expectedTokensLocked); + + IndexingAgreement.IndexingAgreementTermsV1 memory terms = IndexingAgreement.IndexingAgreementTermsV1({ + tokensPerSecond: 1, + tokensPerEntityPerSecond: 0 // no payment for entities + }); + _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, terms); + + _setupPayerWithEscrow( + _rca.payer, + _ctx.payer.signerPrivateKey, + _indexerState.addr, + _expectedTokens.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(_expectedTokens.expectedTotalTokensCollected / terms.tokensPerSecond); + } + + function _newExpectedTokens(uint256 _fuzzyTokensCollected) internal view returns (ExpectedTokens memory) { + uint256 expectedTotalTokensCollected = bound(_fuzzyTokensCollected, 1000, 1_000_000); + uint256 expectedTokensLocked = stakeToFeesRatio * expectedTotalTokensCollected; + uint256 expectedProtocolTokensBurnt = expectedTotalTokensCollected.mulPPMRoundUp( + graphPayments.PROTOCOL_PAYMENT_CUT() + ); + uint256 expectedIndexerTokensCollected = expectedTotalTokensCollected - expectedProtocolTokensBurnt; + return + ExpectedTokens({ + expectedTotalTokensCollected: expectedTotalTokensCollected, + expectedTokensLocked: expectedTokensLocked, + expectedProtocolTokensBurnt: expectedProtocolTokensBurnt, + expectedIndexerTokensCollected: expectedIndexerTokensCollected + }); + } + + function _sharedAssert( + TestState memory _beforeCollect, + TestState memory _afterCollect, + ExpectedTokens memory _expectedTokens, + uint256 _tokensCollected + ) internal pure { + uint256 indexerTokensCollected = _afterCollect.indexerBalance - _beforeCollect.indexerBalance; + assertEq(_expectedTokens.expectedTotalTokensCollected, _tokensCollected, "Total tokens collected should match"); assertEq( - afterCollect.escrowBalance, - beforeCollect.escrowBalance - tokensCollected, - "Escrow balance should be reduced by the amount collected" + _expectedTokens.expectedProtocolTokensBurnt, + _tokensCollected - indexerTokensCollected, + "Protocol tokens burnt should match" ); - 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" + _expectedTokens.expectedIndexerTokensCollected, + indexerTokensCollected, + "Indexer tokens collected should match" + ); + assertEq( + _afterCollect.escrowBalance, + _beforeCollect.escrowBalance - _expectedTokens.expectedTotalTokensCollected, + "_Escrow balance should be reduced by the amount collected" ); - } - /* solhint-enable graph/func-name-mixedcase */ + assertEq( + _afterCollect.indexerTokensLocked, + _beforeCollect.indexerTokensLocked + _expectedTokens.expectedTokensLocked, + "_Locked tokens should match" + ); + } function _addTokensToProvision(IndexerState memory _indexerState, uint256 _tokensToAddToProvision) private { deal({ token: address(token), to: _indexerState.addr, give: _tokensToAddToProvision }); From da42fb417c4119179e0874da48872a8ac62896dd Mon Sep 17 00:00:00 2001 From: Matias Date: Mon, 23 Jun 2025 11:55:05 -0300 Subject: [PATCH 04/59] fix: Remove PaymentType constraint from RecurringCollector --- .../collectors/RecurringCollector.sol | 16 +++---- .../recurring-collector/collect.t.sol | 46 ++++++------------- .../payments/recurring-collector/shared.t.sol | 17 ++++++- 3 files changed, 36 insertions(+), 43 deletions(-) diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index e1225f6fa..662dc549f 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -62,12 +62,8 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC * @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); + return _collect(paymentType, collectParams); } catch { revert RecurringCollectorInvalidCollectData(data); } @@ -269,10 +265,14 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC * * Emits {PaymentCollected} and {RCACollected} events. * + * @param _paymentType The type of payment to collect * @param _params The decoded parameters for the collection * @return The amount of tokens collected */ - function _collect(CollectParams memory _params) private returns (uint256) { + function _collect( + IGraphPayments.PaymentTypes _paymentType, + CollectParams memory _params + ) private returns (uint256) { AgreementData storage agreement = _getAgreementStorage(_params.agreementId); require( _isCollectable(agreement), @@ -289,7 +289,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens); _graphPaymentsEscrow().collect( - IGraphPayments.PaymentTypes.IndexingFee, + _paymentType, agreement.payer, agreement.serviceProvider, tokensToCollect, @@ -301,7 +301,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC agreement.lastCollectionAt = uint64(block.timestamp); emit PaymentCollected( - IGraphPayments.PaymentTypes.IndexingFee, + _paymentType, _params.collectionId, agreement.payer, agreement.serviceProvider, diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index 8942c21bf..4382fa852 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -1,8 +1,6 @@ // 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"; @@ -14,32 +12,14 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { /* 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 { + function test_Collect_Revert_WhenInvalidData(address caller, uint8 unboundedPaymentType, 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); + _recurringCollector.collect(_paymentType(unboundedPaymentType), data); } function test_Collect_Revert_WhenCallerNotDataService( @@ -61,7 +41,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ); vm.expectRevert(expectedErr); vm.prank(notDataService); - _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } function test_Collect_Revert_WhenUnknownAgreement(FuzzyTestCollect memory fuzzy, address dataService) public { @@ -74,7 +54,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ); vm.expectRevert(expectedErr); vm.prank(dataService); - _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } function test_Collect_Revert_WhenCanceledAgreementByServiceProvider(FuzzyTestCollect calldata fuzzy) public { @@ -97,7 +77,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ); vm.expectRevert(expectedErr); vm.prank(accepted.rca.dataService); - _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } function test_Collect_Revert_WhenCollectingTooSoon( @@ -116,7 +96,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ) ); vm.prank(accepted.rca.dataService); - _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); uint256 collectionSeconds = boundSkip(unboundedCollectionSeconds, 1, accepted.rca.minSecondsPerCollection - 1); skip(collectionSeconds); @@ -136,7 +116,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ); vm.expectRevert(expectedErr); vm.prank(accepted.rca.dataService); - _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } function test_Collect_Revert_WhenCollectingTooLate( @@ -163,7 +143,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ) ); vm.prank(accepted.rca.dataService); - _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); // skip beyond collectable time but still within the agreement endsAt uint256 collectionSeconds = boundSkip( @@ -189,7 +169,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ); vm.expectRevert(expectedErr); vm.prank(accepted.rca.dataService); - _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } function test_Collect_OK_WhenCollectingTooMuch( @@ -219,7 +199,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ) ); vm.prank(accepted.rca.dataService); - _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, initialData); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), initialData); } // skip to collectable time @@ -240,7 +220,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ); bytes memory data = _generateCollectData(collectParams); vm.prank(accepted.rca.dataService); - uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); assertEq(collected, maxTokens); } @@ -258,9 +238,9 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { unboundedTokens ); skip(collectionSeconds); - _expectCollectCallAndEmit(accepted.rca, fuzzy.collectParams, tokens); + _expectCollectCallAndEmit(accepted.rca, _paymentType(fuzzy.unboundedPaymentType), fuzzy.collectParams, tokens); vm.prank(accepted.rca.dataService); - uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), 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 index 8dd270b2f..2dbd0e1a0 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -16,6 +16,7 @@ import { RecurringCollectorHelper } from "./RecurringCollectorHelper.t.sol"; contract RecurringCollectorSharedTest is Test, Bounder { struct FuzzyTestCollect { FuzzyTestAccept fuzzyTestAccept; + uint8 unboundedPaymentType; IRecurringCollector.CollectParams collectParams; } @@ -106,6 +107,7 @@ contract RecurringCollectorSharedTest is Test, Bounder { function _expectCollectCallAndEmit( IRecurringCollector.RecurringCollectionAgreement memory _rca, + IGraphPayments.PaymentTypes __paymentType, IRecurringCollector.CollectParams memory _fuzzyParams, uint256 _tokens ) internal { @@ -114,7 +116,7 @@ contract RecurringCollectorSharedTest is Test, Bounder { abi.encodeCall( _paymentsEscrow.collect, ( - IGraphPayments.PaymentTypes.IndexingFee, + __paymentType, _rca.payer, _rca.serviceProvider, _tokens, @@ -126,7 +128,7 @@ contract RecurringCollectorSharedTest is Test, Bounder { ); vm.expectEmit(address(_recurringCollector)); emit IPaymentsCollector.PaymentCollected( - IGraphPayments.PaymentTypes.IndexingFee, + __paymentType, _fuzzyParams.collectionId, _rca.payer, _rca.serviceProvider, @@ -193,4 +195,15 @@ contract RecurringCollectorSharedTest is Test, Bounder { bound(_seed, 0, uint256(IRecurringCollector.CancelAgreementBy.Payer)) ); } + + function _paymentType(uint8 _unboundedPaymentType) internal pure returns (IGraphPayments.PaymentTypes) { + return + IGraphPayments.PaymentTypes( + bound( + _unboundedPaymentType, + uint256(type(IGraphPayments.PaymentTypes).min), + uint256(type(IGraphPayments.PaymentTypes).max) + ) + ); + } } From 7695c9ec5f03ed265f6f78fc80e2a192d83db823 Mon Sep 17 00:00:00 2001 From: Matias Date: Mon, 21 Jul 2025 14:38:04 -0300 Subject: [PATCH 05/59] fix: [TRST-H-2] Only agreement owner can collect indexing fee --- .../contracts/SubgraphService.sol | 10 ++++++- .../contracts/libraries/IndexingAgreement.sol | 6 ++++ .../indexing-agreement/collect.t.sol | 29 +++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 919c1d7ed..6e77e66f9 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -298,7 +298,12 @@ contract SubgraphService is paymentCollected = _collectIndexingRewards(indexer, data); } else if (paymentType == IGraphPayments.PaymentTypes.IndexingFee) { (bytes16 agreementId, bytes memory iaCollectionData) = IndexingAgreementDecoder.decodeCollectData(data); - paymentCollected = _collectIndexingFees(agreementId, paymentsDestination[indexer], iaCollectionData); + paymentCollected = _collectIndexingFees( + indexer, + agreementId, + paymentsDestination[indexer], + iaCollectionData + ); } else { revert SubgraphServiceInvalidPaymentType(paymentType); } @@ -754,12 +759,14 @@ contract SubgraphService is * Emits a {StakeClaimLocked} event. * Emits a {IndexingFeesCollectedV1} event. * + * @param _indexer The address of the indexer * @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( + address _indexer, bytes16 _agreementId, address _paymentsDestination, bytes memory _data @@ -767,6 +774,7 @@ contract SubgraphService is (address indexer, uint256 tokensCollected) = IndexingAgreement._getStorageManager().collect( _allocations, IndexingAgreement.CollectParams({ + indexer: _indexer, agreementId: _agreementId, currentEpoch: _graphEpochManager().currentEpoch(), receiverDestination: _paymentsDestination, diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index d1bea35c8..1b07922c7 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -75,12 +75,14 @@ library IndexingAgreement { /** * @notice Parameters for collecting indexing fees + * @param indexer The address of the indexer * @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 { + address indexer; bytes16 agreementId; uint256 currentEpoch; address receiverDestination; @@ -523,6 +525,10 @@ library IndexingAgreement { wrapper.agreement.allocationId, wrapper.collectorAgreement.serviceProvider ); + require( + allocation.indexer == params.indexer, + IndexingAgreementNotAuthorized(params.agreementId, params.indexer) + ); require(_isCollectable(wrapper), IndexingAgreementNotCollectable(params.agreementId)); require( 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 index 57a7a907f..6f9c2563d 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -232,6 +232,35 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ); } + function test_SubgraphService_CollectIndexingFees_Reverts_WhenIndexingAgreementNotAuthorized( + Seed memory seed, + uint256 entities, + bytes32 poi + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IndexerState memory otherIndexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + + vm.assume(otherIndexerState.addr != indexerState.addr); + + resetPrank(otherIndexerState.addr); + + uint256 currentEpochBlock = epochManager.currentEpochBlock(); + + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.IndexingAgreementNotAuthorized.selector, + accepted.rca.agreementId, + otherIndexerState.addr + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + otherIndexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, currentEpochBlock, bytes("")) + ); + } + function test_SubgraphService_CollectIndexingFees_Reverts_WhenStopService( Seed memory seed, uint256 entities, From 8048c4cbb45d3cb6c40444beb140e3882365eaeb Mon Sep 17 00:00:00 2001 From: Matias Date: Tue, 22 Jul 2025 15:46:32 -0300 Subject: [PATCH 06/59] fix: [TRST-H-3] collect() checks provision --- .../interfaces/IRecurringCollector.sol | 5 +++ .../collectors/RecurringCollector.sol | 11 ++++++ .../test/unit/mocks/HorizonStakingMock.t.sol | 5 +++ .../recurring-collector/collect.t.sol | 37 +++++++++++++++++++ .../payments/recurring-collector/shared.t.sol | 29 ++++++++++++++- 5 files changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol index 954b1be94..ef7ba05f7 100644 --- a/packages/horizon/contracts/interfaces/IRecurringCollector.sol +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -253,6 +253,11 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @param unauthorizedDataService The address of the unauthorized data service */ error RecurringCollectorDataServiceNotAuthorized(bytes16 agreementId, address unauthorizedDataService); + /** + * @notice Thrown when the data service is not authorized for the service provider + * @param dataService The address of the unauthorized data service + */ + error RecurringCollectorUnauthorizedDataService(address dataService); /** * @notice Thrown when interacting with an agreement with an elapsed deadline diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 662dc549f..5f43c482e 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -284,6 +284,17 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC RecurringCollectorDataServiceNotAuthorized(_params.agreementId, msg.sender) ); + // Check the service provider has an active provision with the data service + // This prevents an attack where the payer can deny the service provider from collecting payments + // by using a signer as data service to syphon off the tokens in the escrow to an account they control + { + uint256 tokensAvailable = _graphStaking().getProviderTokensAvailable( + agreement.serviceProvider, + agreement.dataService + ); + require(tokensAvailable > 0, RecurringCollectorUnauthorizedDataService(agreement.dataService)); + } + uint256 tokensToCollect = 0; if (_params.tokens != 0) { tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens); diff --git a/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol b/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol index 647df06f7..d08975e09 100644 --- a/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol +++ b/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol @@ -29,4 +29,9 @@ contract HorizonStakingMock { function setIsAuthorized(address serviceProvider, address verifier, address operator, bool authorized) external { authorizations[serviceProvider][verifier][operator] = authorized; } + + function getProviderTokensAvailable(address serviceProvider, address verifier) external view returns (uint256) { + IHorizonStakingTypes.Provision memory provision = provisions[serviceProvider][verifier]; + return provision.tokens - provision.tokensThawing; + } } diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index 4382fa852..0002c68af 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.27; import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; import { RecurringCollectorSharedTest } from "./shared.t.sol"; @@ -44,6 +45,42 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); } + function test_Collect_Revert_WhenUnauthorizedDataService(FuzzyTestCollect calldata fuzzy) public { + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; + + collectParams.agreementId = accepted.rca.agreementId; + collectParams.tokens = bound(collectParams.tokens, 1, type(uint256).max); + bytes memory data = _generateCollectData(collectParams); + + // Set up the scenario where service provider has no tokens staked with data service + // This simulates an unauthorized data service attack + _horizonStaking.setProvision( + accepted.rca.serviceProvider, + accepted.rca.dataService, + IHorizonStakingTypes.Provision({ + tokens: 0, // No tokens staked - this triggers the vulnerability + tokensThawing: 0, + sharesThawing: 0, + maxVerifierCut: 100000, + thawingPeriod: 604800, + createdAt: uint64(block.timestamp), + maxVerifierCutPending: 100000, + thawingPeriodPending: 604800, + lastParametersStagedAt: 0, + thawingNonce: 0 + }) + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorUnauthorizedDataService.selector, + accepted.rca.dataService + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); + } + function test_Collect_Revert_WhenUnknownAgreement(FuzzyTestCollect memory fuzzy, address dataService) public { bytes memory data = _generateCollectData(fuzzy.collectParams); diff --git a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol index 2dbd0e1a0..d8d9483e7 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -6,10 +6,12 @@ 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 { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; import { Bounder } from "../../../unit/utils/Bounder.t.sol"; import { PartialControllerMock } from "../../mocks/PartialControllerMock.t.sol"; +import { HorizonStakingMock } from "../../mocks/HorizonStakingMock.t.sol"; import { PaymentsEscrowMock } from "./PaymentsEscrowMock.t.sol"; import { RecurringCollectorHelper } from "./RecurringCollectorHelper.t.sol"; @@ -32,12 +34,15 @@ contract RecurringCollectorSharedTest is Test, Bounder { RecurringCollector internal _recurringCollector; PaymentsEscrowMock internal _paymentsEscrow; + HorizonStakingMock internal _horizonStaking; RecurringCollectorHelper internal _recurringCollectorHelper; function setUp() public { _paymentsEscrow = new PaymentsEscrowMock(); - PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](1); + _horizonStaking = new HorizonStakingMock(); + PartialControllerMock.Entry[] memory entries = new PartialControllerMock.Entry[](2); entries[0] = PartialControllerMock.Entry({ name: "PaymentsEscrow", addr: address(_paymentsEscrow) }); + entries[1] = PartialControllerMock.Entry({ name: "Staking", addr: address(_horizonStaking) }); _recurringCollector = new RecurringCollector( "RecurringCollector", "1", @@ -71,6 +76,9 @@ contract RecurringCollectorSharedTest is Test, Bounder { } function _accept(IRecurringCollector.SignedRCA memory _signedRCA) internal { + // Set up valid staking provision by default to allow collections to succeed + _setupValidProvision(_signedRCA.rca.serviceProvider, _signedRCA.rca.dataService); + vm.expectEmit(address(_recurringCollector)); emit IRecurringCollector.AgreementAccepted( _signedRCA.rca.dataService, @@ -88,6 +96,25 @@ contract RecurringCollectorSharedTest is Test, Bounder { _recurringCollector.accept(_signedRCA); } + function _setupValidProvision(address _serviceProvider, address _dataService) internal { + _horizonStaking.setProvision( + _serviceProvider, + _dataService, + IHorizonStakingTypes.Provision({ + tokens: 1000 ether, + tokensThawing: 0, + sharesThawing: 0, + maxVerifierCut: 100000, // 10% + thawingPeriod: 604800, // 7 days + createdAt: uint64(block.timestamp), + maxVerifierCutPending: 100000, + thawingPeriodPending: 604800, + lastParametersStagedAt: 0, + thawingNonce: 0 + }) + ); + } + function _cancel( IRecurringCollector.RecurringCollectionAgreement memory _rca, IRecurringCollector.CancelAgreementBy _by From 29dfdccadf74dce4b7a52ae658328ad026f59c9c Mon Sep 17 00:00:00 2001 From: Matias Date: Tue, 22 Jul 2025 15:51:03 -0300 Subject: [PATCH 07/59] fix: [TRST-M-1] correct TYPEHASH string for RCAU Fixes TRST-M-1 audit finding: Wrong TYPEHASH string is used for agreement updates, limiting functionality. * Fixed EIP712_RCAU_TYPEHASH to use correct uint64 types for deadline and endsAt fields (was incorrectly using uint256) * This prevents signature verification failures for RecurringCollectionAgreementUpdate --- .../contracts/payments/collectors/RecurringCollector.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 5f43c482e..4e11fb532 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -35,7 +35,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /// @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)" + "RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,bytes metadata)" ); /// @notice Tracks agreements From 345cfc8d6331e19e4e16900bde3b9348624b123c Mon Sep 17 00:00:00 2001 From: Matias Date: Tue, 22 Jul 2025 21:42:14 -0300 Subject: [PATCH 08/59] fix: [TRST-M-2] shared collection window logic Fixes TRST-M-2 audit finding: Collection for an elapsed or canceled agreement could be wrong due to temporal calculation inconsistencies between IndexingAgreement and RecurringCollector layers. * Replace isCollectable() with getCollectionInfo() that returns both collectability and duration * Make RecurringCollector the single source of truth for temporal logic * Update IndexingAgreement to call getCollectionInfo() once and pass duration to _tokensToCollect() --- .../interfaces/IRecurringCollector.sol | 20 ++-- .../collectors/RecurringCollector.sol | 92 +++++++++++-------- .../recurring-collector/collect.t.sol | 4 + .../contracts/libraries/IndexingAgreement.sol | 32 +++---- 4 files changed, 79 insertions(+), 69 deletions(-) diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol index ef7ba05f7..cb31125c3 100644 --- a/packages/horizon/contracts/interfaces/IRecurringCollector.sol +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -290,14 +290,6 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ 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 @@ -420,11 +412,13 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { function getAgreement(bytes16 agreementId) external view returns (AgreementData memory); /** - * @notice Checks if an agreement is collectable. - * @dev "Collectable" means the agreement is in a valid state that allows collection attempts, - * not that there are necessarily funds available to collect. + * @notice Get collection info for an agreement * @param agreement The agreement data - * @return The boolean indicating if the agreement is collectable + * @return isCollectable Whether the agreement is in a valid state that allows collection attempts, + * not that there are necessarily funds available to collect. + * @return collectionSeconds The valid collection duration in seconds (0 if not collectable) */ - function isCollectable(AgreementData memory agreement) external view returns (bool); + function getCollectionInfo( + AgreementData memory agreement + ) external view returns (bool isCollectable, uint256 collectionSeconds); } diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 4e11fb532..945f34279 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -246,8 +246,10 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC } /// @inheritdoc IRecurringCollector - function isCollectable(AgreementData memory agreement) external pure returns (bool) { - return _isCollectable(agreement); + function getCollectionInfo( + AgreementData memory agreement + ) external view returns (bool isCollectable, uint256 collectionSeconds) { + return _getCollectionInfo(agreement); } /** @@ -274,9 +276,14 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC CollectParams memory _params ) private returns (uint256) { AgreementData storage agreement = _getAgreementStorage(_params.agreementId); + + // Check if agreement exists first (for unknown agreements) + (bool isCollectable, uint256 collectionSeconds) = _getCollectionInfo(agreement); + require(isCollectable, RecurringCollectorAgreementIncorrectState(_params.agreementId, agreement.state)); + require( - _isCollectable(agreement), - RecurringCollectorAgreementIncorrectState(_params.agreementId, agreement.state) + collectionSeconds > 0, + RecurringCollectorZeroCollectionSeconds(_params.agreementId, block.timestamp, agreement.lastCollectionAt) ); require( @@ -297,7 +304,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC uint256 tokensToCollect = 0; if (_params.tokens != 0) { - tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens); + tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens, collectionSeconds); _graphPaymentsEscrow().collect( _paymentType, @@ -374,53 +381,37 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC * @param _agreement The agreement data * @param _agreementId The ID of the agreement * @param _tokens The number of tokens to collect + * @param _collectionSeconds Collection duration from _getCollectionInfo() * @return The number of tokens that can be collected */ function _requireValidCollect( AgreementData memory _agreement, bytes16 _agreementId, - uint256 _tokens + uint256 _tokens, + uint256 _collectionSeconds ) 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, + _collectionSeconds >= _agreement.minSecondsPerCollection, RecurringCollectorCollectionTooSoon( _agreementId, - uint32(collectionSeconds), + uint32(_collectionSeconds), _agreement.minSecondsPerCollection ) ); } require( - collectionSeconds <= _agreement.maxSecondsPerCollection, + _collectionSeconds <= _agreement.maxSecondsPerCollection, RecurringCollectorCollectionTooLate( _agreementId, - uint64(collectionSeconds), + uint64(_collectionSeconds), _agreement.maxSecondsPerCollection ) ); - uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * collectionSeconds; + uint256 maxTokens = _agreement.maxOngoingTokensPerSecond * _collectionSeconds; maxTokens += _agreement.lastCollectionAt == 0 ? _agreement.maxInitialTokens : 0; return Math.min(_tokens, maxTokens); @@ -546,20 +537,47 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC } /** - * @notice Gets the start time for the collection of an agreement. + * @notice Internal function to get collection info for an agreement + * @dev This is the single source of truth for collection window logic * @param _agreement The agreement data - * @return The start time for the collection of the agreement + * @return isCollectable Whether the agreement can be collected from + * @return collectionSeconds The valid collection duration in seconds (0 if not collectable) */ - function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) { - return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; + function _getCollectionInfo( + AgreementData memory _agreement + ) private view returns (bool isCollectable, uint256 collectionSeconds) { + // Check if agreement is in collectable state + isCollectable = + _agreement.state == AgreementState.Accepted || + _agreement.state == AgreementState.CanceledByPayer; + + if (!isCollectable) { + return (false, 0); + } + + bool canceledOrElapsed = _agreement.state == AgreementState.CanceledByPayer || + block.timestamp > _agreement.endsAt; + uint256 canceledOrNow = _agreement.state == AgreementState.CanceledByPayer + ? _agreement.canceledAt + : block.timestamp; + + uint256 collectionEnd = canceledOrElapsed ? Math.min(canceledOrNow, _agreement.endsAt) : block.timestamp; + uint256 collectionStart = _agreementCollectionStartAt(_agreement); + + if (collectionEnd < collectionStart) { + return (false, 0); + } + + collectionSeconds = collectionEnd - collectionStart; + return (isCollectable, collectionSeconds); } /** - * @notice Requires that the agreement is collectable. + * @notice Gets the start time for the collection of an agreement. * @param _agreement The agreement data - * @return The boolean indicating if the agreement is collectable + * @return The start time for the collection of the agreement */ - function _isCollectable(AgreementData memory _agreement) private pure returns (bool) { - return _agreement.state == AgreementState.Accepted || _agreement.state == AgreementState.CanceledByPayer; + function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) { + return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; } } diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index 0002c68af..c99098c7b 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -32,6 +32,8 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; + skip(1); + collectParams.agreementId = accepted.rca.agreementId; bytes memory data = _generateCollectData(collectParams); @@ -53,6 +55,8 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { collectParams.tokens = bound(collectParams.tokens, 1, type(uint256).max); bytes memory data = _generateCollectData(collectParams); + skip(1); + // Set up the scenario where service provider has no tokens staked with data service // This simulates an unauthorized data service attack _horizonStaking.setProvision( diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 1b07922c7..ce94418ac 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -529,7 +529,11 @@ library IndexingAgreement { allocation.indexer == params.indexer, IndexingAgreementNotAuthorized(params.agreementId, params.indexer) ); - require(_isCollectable(wrapper), IndexingAgreementNotCollectable(params.agreementId)); + // Get collection info from RecurringCollector (single source of truth for temporal logic) + (bool isCollectable, uint256 collectionSeconds) = _directory().recurringCollector().getCollectionInfo( + wrapper.collectorAgreement + ); + require(_isValid(wrapper) && isCollectable, IndexingAgreementNotCollectable(params.agreementId)); require( wrapper.agreement.version == IndexingAgreementVersion.V1, @@ -540,7 +544,7 @@ library IndexingAgreement { uint256 expectedTokens = (data.entities == 0 && data.poi == bytes32(0)) ? 0 - : _tokensToCollect(self, params.agreementId, wrapper.collectorAgreement, data.entities); + : _tokensToCollect(self, params.agreementId, data.entities, collectionSeconds); // `tokensCollected` <= `expectedTokens` because the recurring collector will further narrow // down the tokens allowed, based on the RCA terms. @@ -677,28 +681,21 @@ library IndexingAgreement { } /** - * @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 + * @notice Calculate tokens to collect based on pre-validated duration + * @param _manager The storage manager + * @param _agreementId The agreement ID * @param _entities The number of entities indexed + * @param _collectionSeconds Pre-calculated valid collection duration * @return The number of tokens to collect */ function _tokensToCollect( StorageManager storage _manager, bytes16 _agreementId, - IRecurringCollector.AgreementData memory _agreement, - uint256 _entities + uint256 _entities, + uint256 _collectionSeconds ) 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); + return _collectionSeconds * (termsV1.tokensPerSecond + termsV1.tokensPerEntityPerSecond * _entities); } /** @@ -721,9 +718,6 @@ library IndexingAgreement { * @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data * @return True if the agreement is collectable, false otherwise **/ - function _isCollectable(AgreementWrapper memory wrapper) private view returns (bool) { - return _isValid(wrapper) && _directory().recurringCollector().isCollectable(wrapper.collectorAgreement); - } /** * @notice Checks if the agreement is valid From 8b2e93a342fd1b5e22b3b314927849699e108c33 Mon Sep 17 00:00:00 2001 From: Matias Date: Wed, 23 Jul 2025 11:55:45 -0300 Subject: [PATCH 09/59] fix: [TRST-M-3] Add nonce-based replay protection Fixes signature replay attack vulnerability where old signed RecurringCollectionAgreementUpdate messages could be replayed to revert agreements to previous terms. ## Changes - Add `nonce` field to RecurringCollectionAgreementUpdate struct (uint32) - Add `updateNonce` field to AgreementData struct to track current nonce - Add nonce validation in RecurringCollector.update() to ensure sequential updates - Update EIP712_RCAU_TYPEHASH to include nonce field - Add comprehensive tests for nonce validation and replay attack prevention - Add RecurringCollectorInvalidUpdateNonce error for invalid nonce attempts ## Implementation Details - Nonces start at 0 when agreement is accepted - Each update must use current nonce + 1 - Nonce is incremented after successful update - Uses uint32 for gas optimization (supports 4B+ updates per agreement) - Single source of truth: nonce stored in AgreementData struct --- .../interfaces/IRecurringCollector.sol | 12 ++ .../collectors/RecurringCollector.sol | 12 +- .../RecurringCollectorHelper.t.sol | 11 ++ .../payments/recurring-collector/update.t.sol | 149 +++++++++++++++++- .../indexing-agreement/shared.t.sol | 10 +- .../indexing-agreement/update.t.sol | 2 + 6 files changed, 189 insertions(+), 7 deletions(-) diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol index cb31125c3..19388062b 100644 --- a/packages/horizon/contracts/interfaces/IRecurringCollector.sol +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -90,6 +90,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * 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 nonce The nonce for preventing replay attacks (must be current nonce + 1) * @param metadata Arbitrary metadata to extend functionality if a data service requires it */ struct RecurringCollectionAgreementUpdate { @@ -100,6 +101,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { uint256 maxOngoingTokensPerSecond; uint32 minSecondsPerCollection; uint32 maxSecondsPerCollection; + uint32 nonce; bytes metadata; } @@ -118,6 +120,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * 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 updateNonce The current nonce for updates (prevents replay attacks) * @param canceledAt The timestamp when the agreement was canceled * @param state The state of the agreement */ @@ -132,6 +135,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { uint256 maxOngoingTokensPerSecond; uint32 minSecondsPerCollection; uint32 maxSecondsPerCollection; + uint32 updateNonce; uint64 canceledAt; AgreementState state; } @@ -357,6 +361,14 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ error RecurringCollectorCollectionTooLate(bytes16 agreementId, uint64 secondsSinceLast, uint32 maxSeconds); + /** + * @notice Thrown when calling update() with an invalid nonce + * @param agreementId The agreement ID + * @param expected The expected nonce + * @param provided The provided nonce + */ + error RecurringCollectorInvalidUpdateNonce(bytes16 agreementId, uint32 expected, uint32 provided); + /** * @dev Accept an indexing agreement. * @param signedRCA The signed Recurring Collection Agreement which is to be accepted. diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 945f34279..e16db74db 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -35,7 +35,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /// @notice The EIP712 typehash for the RecurringCollectionAgreementUpdate struct bytes32 public constant EIP712_RCAU_TYPEHASH = keccak256( - "RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,bytes metadata)" + "RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,uint32 nonce,bytes metadata)" ); /// @notice Tracks agreements @@ -120,6 +120,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC agreement.maxOngoingTokensPerSecond = signedRCA.rca.maxOngoingTokensPerSecond; agreement.minSecondsPerCollection = signedRCA.rca.minSecondsPerCollection; agreement.maxSecondsPerCollection = signedRCA.rca.maxSecondsPerCollection; + agreement.updateNonce = 0; emit AgreementAccepted( agreement.dataService, @@ -193,6 +194,13 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC // check that the voucher is signed by the payer (or proxy) _requireAuthorizedRCAUSigner(signedRCAU, agreement.payer); + // validate nonce to prevent replay attacks + uint32 expectedNonce = agreement.updateNonce + 1; + require( + signedRCAU.rcau.nonce == expectedNonce, + RecurringCollectorInvalidUpdateNonce(signedRCAU.rcau.agreementId, expectedNonce, signedRCAU.rcau.nonce) + ); + _requireValidCollectionWindowParams( signedRCAU.rcau.endsAt, signedRCAU.rcau.minSecondsPerCollection, @@ -205,6 +213,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC agreement.maxOngoingTokensPerSecond = signedRCAU.rcau.maxOngoingTokensPerSecond; agreement.minSecondsPerCollection = signedRCAU.rcau.minSecondsPerCollection; agreement.maxSecondsPerCollection = signedRCAU.rcau.maxSecondsPerCollection; + agreement.updateNonce = signedRCAU.rcau.nonce; emit AgreementUpdated( agreement.dataService, @@ -482,6 +491,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC _rcau.maxOngoingTokensPerSecond, _rcau.minSecondsPerCollection, _rcau.maxSecondsPerCollection, + _rcau.nonce, keccak256(_rcau.metadata) ) ) diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol index b3ccbc3b8..611f554e7 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol @@ -45,6 +45,17 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder { return signedRCAU; } + function generateSignedRCAUWithCorrectNonce( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCAU memory) { + // Automatically set the correct nonce based on current agreement state + IRecurringCollector.AgreementData memory agreement = collector.getAgreement(rcau.agreementId); + rcau.nonce = agreement.updateNonce + 1; + + return generateSignedRCAU(rcau, signerPrivateKey); + } + function withElapsedAcceptDeadline( IRecurringCollector.RecurringCollectionAgreement memory rca ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) { diff --git a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol index 4fd8af1e7..1676fc0bc 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol @@ -76,7 +76,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { ); rcau.agreementId = accepted.rca.agreementId; - IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAUWithCorrectNonce( rcau, signerKey ); @@ -124,6 +124,8 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { fuzzyTestUpdate.rcau ); rcau.agreementId = accepted.rca.agreementId; + // Don't use fuzzed nonce - use correct nonce for first update + rcau.nonce = 1; IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( rcau, signerKey @@ -151,6 +153,151 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { assertEq(rcau.maxOngoingTokensPerSecond, agreement.maxOngoingTokensPerSecond); assertEq(rcau.minSecondsPerCollection, agreement.minSecondsPerCollection); assertEq(rcau.maxSecondsPerCollection, agreement.maxSecondsPerCollection); + assertEq(rcau.nonce, agreement.updateNonce); + } + + function test_Update_Revert_WhenInvalidNonce_TooLow(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; + rcau.nonce = 0; // Invalid: should be 1 for first update + + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + signerKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, + rcau.agreementId, + 1, // expected + 0 // provided + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenInvalidNonce_TooHigh(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; + rcau.nonce = 5; // Invalid: should be 1 for first update + + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + signerKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, + rcau.agreementId, + 1, // expected + 5 // provided + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU); + } + + function test_Update_Revert_WhenReplayAttack(FuzzyTestUpdate calldata fuzzyTestUpdate) public { + (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( + fuzzyTestUpdate.fuzzyTestAccept + ); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau1.agreementId = accepted.rca.agreementId; + rcau1.nonce = 1; + + // First update succeeds + IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU( + rcau1, + signerKey + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU1); + + // Second update with different terms and nonce 2 succeeds + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1; + rcau2.nonce = 2; + rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms + + IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU( + rcau2, + signerKey + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU2); + + // Attempting to replay first update should fail + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, + rcau1.agreementId, + 3, // expected (current nonce + 1) + 1 // provided (old nonce) + ); + vm.expectRevert(expectedErr); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU1); + } + + function test_Update_OK_NonceIncrementsCorrectly(FuzzyTestUpdate calldata fuzzyTestUpdate) public { + (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( + fuzzyTestUpdate.fuzzyTestAccept + ); + + // Initial nonce should be 0 + IRecurringCollector.AgreementData memory initialAgreement = _recurringCollector.getAgreement( + accepted.rca.agreementId + ); + assertEq(initialAgreement.updateNonce, 0); + + // First update with nonce 1 + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpdate.rcau + ); + rcau1.agreementId = accepted.rca.agreementId; + rcau1.nonce = 1; + + IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU( + rcau1, + signerKey + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU1); + + // Verify nonce incremented to 1 + IRecurringCollector.AgreementData memory updatedAgreement1 = _recurringCollector.getAgreement( + accepted.rca.agreementId + ); + assertEq(updatedAgreement1.updateNonce, 1); + + // Second update with nonce 2 + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = rcau1; + rcau2.nonce = 2; + rcau2.maxOngoingTokensPerSecond = rcau1.maxOngoingTokensPerSecond * 2; // Different terms + + IRecurringCollector.SignedRCAU memory signedRCAU2 = _recurringCollectorHelper.generateSignedRCAU( + rcau2, + signerKey + ); + vm.prank(accepted.rca.dataService); + _recurringCollector.update(signedRCAU2); + + // Verify nonce incremented to 2 + IRecurringCollector.AgreementData memory updatedAgreement2 = _recurringCollector.getAgreement( + accepted.rca.agreementId + ); + assertEq(updatedAgreement2.updateNonce, 2); } /* solhint-enable graph/func-name-mixedcase */ 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 index 2a5b2385a..c23727f20 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -255,11 +255,11 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun Context storage _ctx, IRecurringCollector.RecurringCollectionAgreement memory _rca ) internal view returns (IRecurringCollector.SignedRCAU memory) { - return - _recurringCollectorHelper.generateSignedRCAU( - _generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca), - _ctx.payer.signerPrivateKey - ); + IRecurringCollector.RecurringCollectionAgreementUpdate + memory rcau = _generateAcceptableRecurringCollectionAgreementUpdate(_ctx, _rca); + // Set correct nonce for first update (should be 1) + rcau.nonce = 1; + return _recurringCollectorHelper.generateSignedRCAU(rcau, _ctx.payer.signerPrivateKey); } function _generateAcceptableRecurringCollectionAgreementUpdate( 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 index 336ef97de..ebd9200d1 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -127,6 +127,8 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA IRecurringCollector.RecurringCollectionAgreementUpdate memory acceptableUpdate = _generateAcceptableRecurringCollectionAgreementUpdate(ctx, accepted.rca); acceptableUpdate.metadata = bytes("invalid"); + // Set correct nonce for first update (should be 1) + acceptableUpdate.nonce = 1; IRecurringCollector.SignedRCAU memory unacceptableUpdate = _recurringCollectorHelper.generateSignedRCAU( acceptableUpdate, ctx.payer.signerPrivateKey From aac9f8b7d9db82d854b73dd3c2c140e256ba13d4 Mon Sep 17 00:00:00 2001 From: Matias Date: Wed, 23 Jul 2025 16:06:59 -0300 Subject: [PATCH 10/59] fix: [TRST-L-3] Add deterministic agreement ID --- .../interfaces/IRecurringCollector.sol | 24 ++++- .../collectors/RecurringCollector.sol | 54 ++++++++-- .../RecurringCollectorHelper.t.sol | 40 ++++++- .../payments/recurring-collector/accept.t.sol | 16 ++- .../payments/recurring-collector/cancel.t.sol | 26 +++-- .../recurring-collector/collect.t.sol | 53 +++++++--- .../payments/recurring-collector/shared.t.sol | 55 +++++++--- .../payments/recurring-collector/update.t.sol | 100 +++++++++++------- .../contracts/SubgraphService.sol | 4 +- .../contracts/interfaces/ISubgraphService.sol | 6 +- .../contracts/libraries/IndexingAgreement.sol | 23 ++-- .../indexing-agreement/accept.t.sol | 27 ++++- .../indexing-agreement/base.t.sol | 19 ++-- .../indexing-agreement/cancel.t.sol | 43 +++++--- .../indexing-agreement/collect.t.sol | 79 ++++++++------ .../indexing-agreement/integration.t.sol | 16 +-- .../indexing-agreement/shared.t.sol | 28 ++++- .../indexing-agreement/update.t.sol | 6 +- 18 files changed, 449 insertions(+), 170 deletions(-) diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol index 19388062b..704515aa7 100644 --- a/packages/horizon/contracts/interfaces/IRecurringCollector.sol +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -40,7 +40,6 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { /** * @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 @@ -52,11 +51,11 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * 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 nonce A unique nonce for preventing collisions (user-chosen) * @param metadata Arbitrary metadata to extend functionality if a data service requires it * */ struct RecurringCollectionAgreement { - bytes16 agreementId; uint64 deadline; uint64 endsAt; address payer; @@ -66,6 +65,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { uint256 maxOngoingTokensPerSecond; uint32 minSecondsPerCollection; uint32 maxSecondsPerCollection; + uint256 nonce; bytes metadata; } @@ -372,8 +372,9 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { /** * @dev Accept an indexing agreement. * @param signedRCA The signed Recurring Collection Agreement which is to be accepted. + * @return agreementId The deterministically generated agreement ID */ - function accept(SignedRCA calldata signedRCA) external; + function accept(SignedRCA calldata signedRCA) external returns (bytes16 agreementId); /** * @dev Cancel an indexing agreement. @@ -433,4 +434,21 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { function getCollectionInfo( AgreementData memory agreement ) external view returns (bool isCollectable, uint256 collectionSeconds); + + /** + * @notice Generate a deterministic agreement ID from agreement parameters + * @param payer The address of the payer + * @param dataService The address of the data service + * @param serviceProvider The address of the service provider + * @param deadline The deadline for accepting the agreement + * @param nonce A unique nonce for preventing collisions + * @return agreementId The deterministically generated agreement ID + */ + function generateAgreementId( + address payer, + address dataService, + address serviceProvider, + uint64 deadline, + uint256 nonce + ) external pure returns (bytes16 agreementId); } diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index e16db74db..56d2b9d5b 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -29,7 +29,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /// @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)" + "RecurringCollectionAgreement(uint64 deadline,uint64 endsAt,address payer,address dataService,address serviceProvider,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,uint256 nonce,bytes metadata)" ); /// @notice The EIP712 typehash for the RecurringCollectionAgreementUpdate struct @@ -75,8 +75,16 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC * 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()); + function accept(SignedRCA calldata signedRCA) external returns (bytes16) { + bytes16 agreementId = _generateAgreementId( + signedRCA.rca.payer, + signedRCA.rca.dataService, + signedRCA.rca.serviceProvider, + signedRCA.rca.deadline, + signedRCA.rca.nonce + ); + + require(agreementId != bytes16(0), RecurringCollectorAgreementIdZero()); require( msg.sender == signedRCA.rca.dataService, RecurringCollectorUnauthorizedCaller(msg.sender, signedRCA.rca.dataService) @@ -102,11 +110,11 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC signedRCA.rca.maxSecondsPerCollection ); - AgreementData storage agreement = _getAgreementStorage(signedRCA.rca.agreementId); + AgreementData storage agreement = _getAgreementStorage(agreementId); // check that the agreement is not already accepted require( agreement.state == AgreementState.NotAccepted, - RecurringCollectorAgreementIncorrectState(signedRCA.rca.agreementId, agreement.state) + RecurringCollectorAgreementIncorrectState(agreementId, agreement.state) ); // accept the agreement @@ -126,7 +134,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC agreement.dataService, agreement.payer, agreement.serviceProvider, - signedRCA.rca.agreementId, + agreementId, agreement.acceptedAt, agreement.endsAt, agreement.maxInitialTokens, @@ -134,6 +142,8 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC agreement.minSecondsPerCollection, agreement.maxSecondsPerCollection ); + + return agreementId; } /** @@ -261,6 +271,17 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC return _getCollectionInfo(agreement); } + /// @inheritdoc IRecurringCollector + function generateAgreementId( + address payer, + address dataService, + address serviceProvider, + uint64 deadline, + uint256 nonce + ) external pure returns (bytes16) { + return _generateAgreementId(payer, dataService, serviceProvider, deadline, nonce); + } + /** * @notice Decodes the collect data. * @param data The encoded collect parameters. @@ -457,7 +478,6 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC keccak256( abi.encode( EIP712_RCA_TYPEHASH, - _rca.agreementId, _rca.deadline, _rca.endsAt, _rca.payer, @@ -467,6 +487,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC _rca.maxOngoingTokensPerSecond, _rca.minSecondsPerCollection, _rca.maxSecondsPerCollection, + _rca.nonce, keccak256(_rca.metadata) ) ) @@ -590,4 +611,23 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC function _agreementCollectionStartAt(AgreementData memory _agreement) private pure returns (uint256) { return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; } + + /** + * @notice Internal function to generate deterministic agreement ID + * @param _payer The address of the payer + * @param _dataService The address of the data service + * @param _serviceProvider The address of the service provider + * @param _deadline The deadline for accepting the agreement + * @param _nonce A unique nonce for preventing collisions + * @return agreementId The deterministically generated agreement ID + */ + function _generateAgreementId( + address _payer, + address _dataService, + address _serviceProvider, + uint64 _deadline, + uint256 _nonce + ) private pure returns (bytes16) { + return bytes16(keccak256(abi.encode(_payer, _dataService, _serviceProvider, _deadline, _nonce))); + } } diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol index 611f554e7..6ddbdfa0b 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol @@ -45,17 +45,47 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder { return signedRCAU; } - function generateSignedRCAUWithCorrectNonce( + function generateSignedRCAUForAgreement( + bytes16 agreementId, IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, uint256 signerPrivateKey ) public view returns (IRecurringCollector.SignedRCAU memory) { // Automatically set the correct nonce based on current agreement state - IRecurringCollector.AgreementData memory agreement = collector.getAgreement(rcau.agreementId); + IRecurringCollector.AgreementData memory agreement = collector.getAgreement(agreementId); rcau.nonce = agreement.updateNonce + 1; return generateSignedRCAU(rcau, signerPrivateKey); } + function generateSignedRCAUWithCorrectNonce( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCAU memory) { + // This is kept for backwards compatibility but should not be used with new interface + // since we can't determine agreementId without it being passed separately + return generateSignedRCAU(rcau, signerPrivateKey); + } + + function generateSignedRCAWithCalculatedId( + IRecurringCollector.RecurringCollectionAgreement memory rca, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCA memory, bytes16) { + // Ensure we have sensible values + rca = sensibleRCA(rca); + + // Calculate the agreement ID + bytes16 agreementId = collector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + + IRecurringCollector.SignedRCA memory signedRCA = generateSignedRCA(rca, signerPrivateKey); + return (signedRCA, agreementId); + } + function withElapsedAcceptDeadline( IRecurringCollector.RecurringCollectionAgreement memory rca ) public view returns (IRecurringCollector.RecurringCollectionAgreement memory) { @@ -76,11 +106,15 @@ contract RecurringCollectorHelper is AuthorizableHelper, Bounder { 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)); + // Ensure we have a nonce if it's zero + if (rca.nonce == 0) { + rca.nonce = 1; + } + rca.minSecondsPerCollection = _sensibleMinSecondsPerCollection(rca.minSecondsPerCollection); rca.maxSecondsPerCollection = _sensibleMaxSecondsPerCollection( rca.maxSecondsPerCollection, diff --git a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol index d9479b955..f7a4c3823 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol @@ -20,7 +20,15 @@ contract RecurringCollectorAcceptTest is RecurringCollectorSharedTest { IRecurringCollector.SignedRCA memory fuzzySignedRCA, uint256 unboundedSkip ) public { - vm.assume(fuzzySignedRCA.rca.agreementId != bytes16(0)); + // Generate deterministic agreement ID for validation + bytes16 agreementId = _recurringCollector.generateAgreementId( + fuzzySignedRCA.rca.payer, + fuzzySignedRCA.rca.dataService, + fuzzySignedRCA.rca.serviceProvider, + fuzzySignedRCA.rca.deadline, + fuzzySignedRCA.rca.nonce + ); + vm.assume(agreementId != bytes16(0)); skip(boundSkip(unboundedSkip, 1, type(uint64).max - block.timestamp)); fuzzySignedRCA.rca = _recurringCollectorHelper.withElapsedAcceptDeadline(fuzzySignedRCA.rca); @@ -35,11 +43,13 @@ contract RecurringCollectorAcceptTest is RecurringCollectorSharedTest { } function test_Accept_Revert_WhenAlreadyAccepted(FuzzyTestAccept calldata fuzzyTestAccept) public { - (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzyTestAccept + ); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, - accepted.rca.agreementId, + agreementId, IRecurringCollector.AgreementState.Accepted ); vm.expectRevert(expectedErr); diff --git a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol index fe938c825..fa3b595a0 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol @@ -13,22 +13,34 @@ contract RecurringCollectorCancelTest is RecurringCollectorSharedTest { /* solhint-disable graph/func-name-mixedcase */ function test_Cancel(FuzzyTestAccept calldata fuzzyTestAccept, uint8 unboundedCanceler) public { - _sensibleAuthorizeAndAccept(fuzzyTestAccept); - _cancel(fuzzyTestAccept.rca, _fuzzyCancelAgreementBy(unboundedCanceler)); + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzyTestAccept + ); + + _cancel(accepted.rca, agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); } function test_Cancel_Revert_WhenNotAccepted( IRecurringCollector.RecurringCollectionAgreement memory fuzzyRCA, uint8 unboundedCanceler ) public { + // Generate deterministic agreement ID + bytes16 agreementId = _recurringCollector.generateAgreementId( + fuzzyRCA.payer, + fuzzyRCA.dataService, + fuzzyRCA.serviceProvider, + fuzzyRCA.deadline, + fuzzyRCA.nonce + ); + bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, - fuzzyRCA.agreementId, + agreementId, IRecurringCollector.AgreementState.NotAccepted ); vm.expectRevert(expectedErr); vm.prank(fuzzyRCA.dataService); - _recurringCollector.cancel(fuzzyRCA.agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); + _recurringCollector.cancel(agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); } function test_Cancel_Revert_WhenNotDataService( @@ -38,16 +50,16 @@ contract RecurringCollectorCancelTest is RecurringCollectorSharedTest { ) public { vm.assume(fuzzyTestAccept.rca.dataService != notDataService); - _sensibleAuthorizeAndAccept(fuzzyTestAccept); + (, , bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzyTestAccept); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, - fuzzyTestAccept.rca.agreementId, + agreementId, notDataService ); vm.expectRevert(expectedErr); vm.prank(notDataService); - _recurringCollector.cancel(fuzzyTestAccept.rca.agreementId, _fuzzyCancelAgreementBy(unboundedCanceler)); + _recurringCollector.cancel(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 index c99098c7b..a972734a6 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -29,12 +29,11 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ) public { vm.assume(fuzzy.fuzzyTestAccept.rca.dataService != notDataService); - (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + (, , bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; skip(1); - - collectParams.agreementId = accepted.rca.agreementId; + collectParams.agreementId = agreementId; bytes memory data = _generateCollectData(collectParams); bytes memory expectedErr = abi.encodeWithSelector( @@ -48,10 +47,11 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { } function test_Collect_Revert_WhenUnauthorizedDataService(FuzzyTestCollect calldata fuzzy) public { - (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; - - collectParams.agreementId = accepted.rca.agreementId; + collectParams.agreementId = agreementId; collectParams.tokens = bound(collectParams.tokens, 1, type(uint256).max); bytes memory data = _generateCollectData(collectParams); @@ -99,12 +99,15 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { } function test_Collect_Revert_WhenCanceledAgreementByServiceProvider(FuzzyTestCollect calldata fuzzy) public { - (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); - _cancel(accepted.rca, IRecurringCollector.CancelAgreementBy.ServiceProvider); + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); + _cancel(accepted.rca, agreementId, 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, + agreementId, collectData.collectionId, collectData.tokens, collectData.dataServiceCut @@ -125,12 +128,15 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { FuzzyTestCollect calldata fuzzy, uint256 unboundedCollectionSeconds ) public { - (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); skip(accepted.rca.minSecondsPerCollection); bytes memory data = _generateCollectData( _generateCollectParams( accepted.rca, + agreementId, fuzzy.collectParams.collectionId, 1, fuzzy.collectParams.dataServiceCut @@ -144,6 +150,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( accepted.rca, + agreementId, fuzzy.collectParams.collectionId, bound(fuzzy.collectParams.tokens, 1, type(uint256).max), fuzzy.collectParams.dataServiceCut @@ -165,9 +172,12 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 unboundedFirstCollectionSeconds, uint256 unboundedSecondCollectionSeconds ) public { - (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); // skip to collectable time + skip( boundSkip( unboundedFirstCollectionSeconds, @@ -178,6 +188,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { bytes memory data = _generateCollectData( _generateCollectParams( accepted.rca, + agreementId, fuzzy.collectParams.collectionId, 1, fuzzy.collectParams.dataServiceCut @@ -197,6 +208,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { data = _generateCollectData( _generateCollectParams( accepted.rca, + agreementId, fuzzy.collectParams.collectionId, bound(fuzzy.collectParams.tokens, 1, type(uint256).max), fuzzy.collectParams.dataServiceCut @@ -204,7 +216,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { ); bytes memory expectedErr = abi.encodeWithSelector( IRecurringCollector.RecurringCollectorCollectionTooLate.selector, - accepted.rca.agreementId, + agreementId, collectionSeconds, accepted.rca.maxSecondsPerCollection ); @@ -220,7 +232,9 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 unboundedTokens, bool testInitialCollection ) public { - (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); if (!testInitialCollection) { // skip to collectable time @@ -234,6 +248,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { bytes memory initialData = _generateCollectData( _generateCollectParams( accepted.rca, + agreementId, fuzzy.collectParams.collectionId, 1, fuzzy.collectParams.dataServiceCut @@ -255,6 +270,7 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 tokens = bound(unboundedTokens, maxTokens + 1, type(uint256).max); IRecurringCollector.CollectParams memory collectParams = _generateCollectParams( accepted.rca, + agreementId, fuzzy.collectParams.collectionId, tokens, fuzzy.collectParams.dataServiceCut @@ -270,7 +286,9 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 unboundedCollectionSeconds, uint256 unboundedTokens ) public { - (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + (IRecurringCollector.SignedRCA memory accepted, , bytes16 agreementId) = _sensibleAuthorizeAndAccept( + fuzzy.fuzzyTestAccept + ); (bytes memory data, uint256 collectionSeconds, uint256 tokens) = _generateValidCollection( accepted.rca, @@ -278,8 +296,15 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { unboundedCollectionSeconds, unboundedTokens ); + skip(collectionSeconds); - _expectCollectCallAndEmit(accepted.rca, _paymentType(fuzzy.unboundedPaymentType), fuzzy.collectParams, tokens); + _expectCollectCallAndEmit( + accepted.rca, + agreementId, + _paymentType(fuzzy.unboundedPaymentType), + fuzzy.collectParams, + tokens + ); vm.prank(accepted.rca.dataService); uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); assertEq(collected, tokens); diff --git a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol index d8d9483e7..9a564086e 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -54,37 +54,47 @@ contract RecurringCollectorSharedTest is Test, Bounder { function _sensibleAuthorizeAndAccept( FuzzyTestAccept calldata _fuzzyTestAccept - ) internal returns (IRecurringCollector.SignedRCA memory, uint256 key) { + ) internal returns (IRecurringCollector.SignedRCA memory, uint256 key, bytes16 agreementId) { IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( _fuzzyTestAccept.rca ); key = boundKey(_fuzzyTestAccept.unboundedSignerKey); - return (_authorizeAndAccept(rca, key), key); + IRecurringCollector.SignedRCA memory signedRCA; + (signedRCA, agreementId) = _authorizeAndAccept(rca, key); + return (signedRCA, key, agreementId); } // authorizes signer, signs the RCA, and accepts it function _authorizeAndAccept( IRecurringCollector.RecurringCollectionAgreement memory _rca, uint256 _signerKey - ) internal returns (IRecurringCollector.SignedRCA memory) { + ) internal returns (IRecurringCollector.SignedRCA memory, bytes16 agreementId) { _recurringCollectorHelper.authorizeSignerWithChecks(_rca.payer, _signerKey); IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(_rca, _signerKey); - _accept(signedRCA); - - return signedRCA; + agreementId = _accept(signedRCA); + return (signedRCA, agreementId); } - function _accept(IRecurringCollector.SignedRCA memory _signedRCA) internal { + function _accept(IRecurringCollector.SignedRCA memory _signedRCA) internal returns (bytes16) { // Set up valid staking provision by default to allow collections to succeed _setupValidProvision(_signedRCA.rca.serviceProvider, _signedRCA.rca.dataService); + // Calculate the expected agreement ID for verification + bytes16 expectedAgreementId = _recurringCollector.generateAgreementId( + _signedRCA.rca.payer, + _signedRCA.rca.dataService, + _signedRCA.rca.serviceProvider, + _signedRCA.rca.deadline, + _signedRCA.rca.nonce + ); + vm.expectEmit(address(_recurringCollector)); emit IRecurringCollector.AgreementAccepted( _signedRCA.rca.dataService, _signedRCA.rca.payer, _signedRCA.rca.serviceProvider, - _signedRCA.rca.agreementId, + expectedAgreementId, uint64(block.timestamp), _signedRCA.rca.endsAt, _signedRCA.rca.maxInitialTokens, @@ -93,7 +103,11 @@ contract RecurringCollectorSharedTest is Test, Bounder { _signedRCA.rca.maxSecondsPerCollection ); vm.prank(_signedRCA.rca.dataService); - _recurringCollector.accept(_signedRCA); + bytes16 actualAgreementId = _recurringCollector.accept(_signedRCA); + + // Verify the agreement ID matches expectation + assertEq(actualAgreementId, expectedAgreementId); + return actualAgreementId; } function _setupValidProvision(address _serviceProvider, address _dataService) internal { @@ -117,6 +131,7 @@ contract RecurringCollectorSharedTest is Test, Bounder { function _cancel( IRecurringCollector.RecurringCollectionAgreement memory _rca, + bytes16 _agreementId, IRecurringCollector.CancelAgreementBy _by ) internal { vm.expectEmit(address(_recurringCollector)); @@ -124,16 +139,17 @@ contract RecurringCollectorSharedTest is Test, Bounder { _rca.dataService, _rca.payer, _rca.serviceProvider, - _rca.agreementId, + _agreementId, uint64(block.timestamp), _by ); vm.prank(_rca.dataService); - _recurringCollector.cancel(_rca.agreementId, _by); + _recurringCollector.cancel(_agreementId, _by); } function _expectCollectCallAndEmit( IRecurringCollector.RecurringCollectionAgreement memory _rca, + bytes16 _agreementId, IGraphPayments.PaymentTypes __paymentType, IRecurringCollector.CollectParams memory _fuzzyParams, uint256 _tokens @@ -168,7 +184,7 @@ contract RecurringCollectorSharedTest is Test, Bounder { _rca.dataService, _rca.payer, _rca.serviceProvider, - _rca.agreementId, + _agreementId, _fuzzyParams.collectionId, _tokens, _fuzzyParams.dataServiceCut @@ -187,8 +203,18 @@ contract RecurringCollectorSharedTest is Test, Bounder { _rca.maxSecondsPerCollection ); uint256 tokens = bound(_unboundedTokens, 1, _rca.maxOngoingTokensPerSecond * collectionSeconds); + + // Generate the agreement ID deterministically + bytes16 agreementId = _recurringCollector.generateAgreementId( + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.deadline, + _rca.nonce + ); + bytes memory data = _generateCollectData( - _generateCollectParams(_rca, _fuzzyParams.collectionId, tokens, _fuzzyParams.dataServiceCut) + _generateCollectParams(_rca, agreementId, _fuzzyParams.collectionId, tokens, _fuzzyParams.dataServiceCut) ); return (data, collectionSeconds, tokens); @@ -196,13 +222,14 @@ contract RecurringCollectorSharedTest is Test, Bounder { function _generateCollectParams( IRecurringCollector.RecurringCollectionAgreement memory _rca, + bytes16 _agreementId, bytes32 _collectionId, uint256 _tokens, uint256 _dataServiceCut ) internal pure returns (IRecurringCollector.CollectParams memory) { return IRecurringCollector.CollectParams({ - agreementId: _rca.agreementId, + agreementId: _agreementId, collectionId: _collectionId, tokens: _tokens, dataServiceCut: _dataServiceCut, diff --git a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol index 1676fc0bc..ea34f895b 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol @@ -19,7 +19,15 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { ) public { rca = _recurringCollectorHelper.sensibleRCA(rca); rcau = _recurringCollectorHelper.sensibleRCAU(rcau); - rcau.agreementId = rca.agreementId; + // Generate deterministic agreement ID + bytes16 agreementId = _recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + rcau.agreementId = agreementId; boundSkipCeil(unboundedUpdateSkip, type(uint64).max); rcau.deadline = uint64(bound(rcau.deadline, 0, block.timestamp - 1)); @@ -44,7 +52,15 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { ) public { rca = _recurringCollectorHelper.sensibleRCA(rca); rcau = _recurringCollectorHelper.sensibleRCAU(rcau); - rcau.agreementId = rca.agreementId; + // Generate deterministic agreement ID + bytes16 agreementId = _recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + rcau.agreementId = agreementId; rcau.deadline = uint64(block.timestamp); IRecurringCollector.SignedRCAU memory signedRCAU = IRecurringCollector.SignedRCAU({ @@ -67,14 +83,12 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { address notDataService ) public { vm.assume(fuzzyTestUpdate.fuzzyTestAccept.rca.dataService != notDataService); - (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( - fuzzyTestUpdate.fuzzyTestAccept - ); + (, uint256 signerKey, bytes16 agreementId) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( fuzzyTestUpdate.rcau ); - rcau.agreementId = accepted.rca.agreementId; + rcau.agreementId = agreementId; IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAUWithCorrectNonce( rcau, @@ -95,16 +109,18 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { FuzzyTestUpdate calldata fuzzyTestUpdate, uint256 unboundedInvalidSignerKey ) public { - (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( - fuzzyTestUpdate.fuzzyTestAccept - ); + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); uint256 invalidSignerKey = boundKey(unboundedInvalidSignerKey); vm.assume(signerKey != invalidSignerKey); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( fuzzyTestUpdate.rcau ); - rcau.agreementId = accepted.rca.agreementId; + rcau.agreementId = agreementId; IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( rcau, @@ -117,13 +133,15 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { } function test_Update_OK(FuzzyTestUpdate calldata fuzzyTestUpdate) public { - (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( - fuzzyTestUpdate.fuzzyTestAccept - ); + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( fuzzyTestUpdate.rcau ); - rcau.agreementId = accepted.rca.agreementId; + rcau.agreementId = agreementId; // Don't use fuzzed nonce - use correct nonce for first update rcau.nonce = 1; IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( @@ -147,7 +165,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { vm.prank(accepted.rca.dataService); _recurringCollector.update(signedRCAU); - IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(accepted.rca.agreementId); + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(agreementId); assertEq(rcau.endsAt, agreement.endsAt); assertEq(rcau.maxInitialTokens, agreement.maxInitialTokens); assertEq(rcau.maxOngoingTokensPerSecond, agreement.maxOngoingTokensPerSecond); @@ -157,13 +175,15 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { } function test_Update_Revert_WhenInvalidNonce_TooLow(FuzzyTestUpdate calldata fuzzyTestUpdate) public { - (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( - fuzzyTestUpdate.fuzzyTestAccept - ); + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( fuzzyTestUpdate.rcau ); - rcau.agreementId = accepted.rca.agreementId; + rcau.agreementId = agreementId; rcau.nonce = 0; // Invalid: should be 1 for first update IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( @@ -183,13 +203,15 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { } function test_Update_Revert_WhenInvalidNonce_TooHigh(FuzzyTestUpdate calldata fuzzyTestUpdate) public { - (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( - fuzzyTestUpdate.fuzzyTestAccept - ); + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _recurringCollectorHelper.sensibleRCAU( fuzzyTestUpdate.rcau ); - rcau.agreementId = accepted.rca.agreementId; + rcau.agreementId = agreementId; rcau.nonce = 5; // Invalid: should be 1 for first update IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( @@ -209,13 +231,15 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { } function test_Update_Revert_WhenReplayAttack(FuzzyTestUpdate calldata fuzzyTestUpdate) public { - (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( - fuzzyTestUpdate.fuzzyTestAccept - ); + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU( fuzzyTestUpdate.rcau ); - rcau1.agreementId = accepted.rca.agreementId; + rcau1.agreementId = agreementId; rcau1.nonce = 1; // First update succeeds @@ -251,21 +275,21 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { } function test_Update_OK_NonceIncrementsCorrectly(FuzzyTestUpdate calldata fuzzyTestUpdate) public { - (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( - fuzzyTestUpdate.fuzzyTestAccept - ); + ( + IRecurringCollector.SignedRCA memory accepted, + uint256 signerKey, + bytes16 agreementId + ) = _sensibleAuthorizeAndAccept(fuzzyTestUpdate.fuzzyTestAccept); // Initial nonce should be 0 - IRecurringCollector.AgreementData memory initialAgreement = _recurringCollector.getAgreement( - accepted.rca.agreementId - ); + IRecurringCollector.AgreementData memory initialAgreement = _recurringCollector.getAgreement(agreementId); assertEq(initialAgreement.updateNonce, 0); // First update with nonce 1 IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _recurringCollectorHelper.sensibleRCAU( fuzzyTestUpdate.rcau ); - rcau1.agreementId = accepted.rca.agreementId; + rcau1.agreementId = agreementId; rcau1.nonce = 1; IRecurringCollector.SignedRCAU memory signedRCAU1 = _recurringCollectorHelper.generateSignedRCAU( @@ -276,9 +300,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { _recurringCollector.update(signedRCAU1); // Verify nonce incremented to 1 - IRecurringCollector.AgreementData memory updatedAgreement1 = _recurringCollector.getAgreement( - accepted.rca.agreementId - ); + IRecurringCollector.AgreementData memory updatedAgreement1 = _recurringCollector.getAgreement(agreementId); assertEq(updatedAgreement1.updateNonce, 1); // Second update with nonce 2 @@ -294,9 +316,7 @@ contract RecurringCollectorUpdateTest is RecurringCollectorSharedTest { _recurringCollector.update(signedRCAU2); // Verify nonce incremented to 2 - IRecurringCollector.AgreementData memory updatedAgreement2 = _recurringCollector.getAgreement( - accepted.rca.agreementId - ); + IRecurringCollector.AgreementData memory updatedAgreement2 = _recurringCollector.getAgreement(agreementId); assertEq(updatedAgreement2.updateNonce, 2); } diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 6e77e66f9..d311c6c62 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -420,6 +420,7 @@ contract SubgraphService is * * @param allocationId The id of the allocation * @param signedRCA The signed Recurring Collection Agreement + * @return agreementId The ID of the accepted indexing agreement */ function acceptIndexingAgreement( address allocationId, @@ -430,8 +431,9 @@ contract SubgraphService is onlyAuthorizedForProvision(signedRCA.rca.serviceProvider) onlyValidProvision(signedRCA.rca.serviceProvider) onlyRegisteredIndexer(signedRCA.rca.serviceProvider) + returns (bytes16) { - IndexingAgreement._getStorageManager().accept(_allocations, allocationId, signedRCA); + return IndexingAgreement._getStorageManager().accept(_allocations, allocationId, signedRCA); } /** diff --git a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol index 2a852ffce..17ff4cbd0 100644 --- a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol +++ b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol @@ -263,8 +263,12 @@ interface ISubgraphService is IDataServiceFees { * @notice Accept an indexing agreement. * @param allocationId The id of the allocation * @param signedRCA The signed recurring collector agreement (RCA) that the indexer accepts + * @return agreementId The ID of the accepted indexing agreement */ - function acceptIndexingAgreement(address allocationId, IRecurringCollector.SignedRCA calldata signedRCA) external; + function acceptIndexingAgreement( + address allocationId, + IRecurringCollector.SignedRCA calldata signedRCA + ) external returns (bytes16); /** * @notice Update an indexing agreement. diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index ce94418ac..d1f42f2b5 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -293,7 +293,7 @@ library IndexingAgreement { mapping(address allocationId => Allocation.State allocation) storage allocations, address allocationId, IRecurringCollector.SignedRCA calldata signedRCA - ) external { + ) external returns (bytes16) { Allocation.State memory allocation = _requireValidAllocation( allocations, allocationId, @@ -309,9 +309,17 @@ library IndexingAgreement { signedRCA.rca.metadata ); - State storage agreement = self.agreements[signedRCA.rca.agreementId]; + bytes16 agreementId = _directory().recurringCollector().generateAgreementId( + signedRCA.rca.payer, + signedRCA.rca.dataService, + signedRCA.rca.serviceProvider, + signedRCA.rca.deadline, + signedRCA.rca.nonce + ); + + State storage agreement = self.agreements[agreementId]; - require(agreement.allocationId == address(0), IndexingAgreementAlreadyAccepted(signedRCA.rca.agreementId)); + require(agreement.allocationId == address(0), IndexingAgreementAlreadyAccepted(agreementId)); require( allocation.subgraphDeploymentId == metadata.subgraphDeploymentId, @@ -327,25 +335,26 @@ library IndexingAgreement { self.allocationToActiveAgreementId[allocationId] == bytes16(0), AllocationAlreadyHasIndexingAgreement(allocationId) ); - self.allocationToActiveAgreementId[allocationId] = signedRCA.rca.agreementId; + self.allocationToActiveAgreementId[allocationId] = agreementId; agreement.version = metadata.version; agreement.allocationId = allocationId; require(metadata.version == IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version)); - _setTermsV1(self, signedRCA.rca.agreementId, metadata.terms); + _setTermsV1(self, agreementId, metadata.terms); emit IndexingAgreementAccepted( signedRCA.rca.serviceProvider, signedRCA.rca.payer, - signedRCA.rca.agreementId, + agreementId, allocationId, metadata.subgraphDeploymentId, metadata.version, metadata.terms ); - _directory().recurringCollector().accept(signedRCA); + require(_directory().recurringCollector().accept(signedRCA) == agreementId, "internal: agreement ID mismatch"); + return agreementId; } /** 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 index 77b18308c..db2a859dd 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -212,11 +212,14 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg 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); + (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementAlreadyAccepted.selector, - accepted.rca.agreementId + agreementId ); vm.expectRevert(expectedErr); resetPrank(ctx.indexers[0].addr); @@ -229,10 +232,15 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + IRecurringCollector.RecurringCollectionAgreement memory notAcceptableRCA = acceptable.rca; bytes memory invalidTermsData = bytes("invalid terms data"); - acceptable.rca.metadata = abi.encode( + notAcceptableRCA.metadata = abi.encode( _newAcceptIndexingAgreementMetadataV1Terms(indexerState.subgraphDeploymentId, invalidTermsData) ); + IRecurringCollector.SignedRCA memory notAcceptable = _recurringCollectorHelper.generateSignedRCA( + notAcceptableRCA, + ctx.payer.signerPrivateKey + ); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, @@ -241,7 +249,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg ); vm.expectRevert(expectedErr); resetPrank(indexerState.addr); - subgraphService.acceptIndexingAgreement(indexerState.allocationId, acceptable); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, notAcceptable); } function test_SubgraphService_AcceptIndexingAgreement(Seed memory seed) public { @@ -252,11 +260,20 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg acceptable.rca.metadata, (IndexingAgreement.AcceptIndexingAgreementMetadata) ); + // Generate deterministic agreement ID for event expectation + bytes16 expectedAgreementId = recurringCollector.generateAgreementId( + acceptable.rca.payer, + acceptable.rca.dataService, + acceptable.rca.serviceProvider, + acceptable.rca.deadline, + acceptable.rca.nonce + ); + vm.expectEmit(address(subgraphService)); emit IndexingAgreement.IndexingAgreementAccepted( acceptable.rca.serviceProvider, acceptable.rca.payer, - acceptable.rca.agreementId, + expectedAgreementId, indexerState.allocationId, metadata.subgraphDeploymentId, metadata.version, 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 index 2eda9dfc0..5b7aba56f 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol @@ -13,22 +13,29 @@ contract SubgraphServiceIndexingAgreementBaseTest is SubgraphServiceIndexingAgre */ /* solhint-disable graph/func-name-mixedcase */ - function test_SubgraphService_GetIndexingAgreement(Seed memory seed, address operator, bytes16 agreementId) public { + function test_SubgraphService_GetIndexingAgreement( + Seed memory seed, + address operator, + bytes16 fuzzyAgreementId + ) public { vm.assume(_isSafeSubgraphServiceCaller(operator)); resetPrank(address(operator)); // Get unkown indexing agreement - vm.expectRevert(abi.encodeWithSelector(IndexingAgreement.IndexingAgreementNotActive.selector, agreementId)); - subgraphService.getIndexingAgreement(agreementId); + vm.expectRevert( + abi.encodeWithSelector(IndexingAgreement.IndexingAgreementNotActive.selector, fuzzyAgreementId) + ); + subgraphService.getIndexingAgreement(fuzzyAgreementId); // Accept an indexing agreement Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); - IndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement( - accepted.rca.agreementId + (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState ); + IndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); _assertEqualAgreement(accepted.rca, agreement); } 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 index 60a28169c..2c904f156 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol @@ -33,7 +33,10 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg address rando ) public withSafeIndexerOrOperator(rando) { Context storage ctx = _newCtx(seed); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); + (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( + ctx, + _withIndexer(ctx) + ); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementNonCancelableBy.selector, @@ -42,7 +45,7 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg ); vm.expectRevert(expectedErr); resetPrank(rando); - subgraphService.cancelIndexingAgreementByPayer(accepted.rca.agreementId); + subgraphService.cancelIndexingAgreementByPayer(agreementId); } function test_SubgraphService_CancelIndexingAgreementByPayer_Revert_WhenNotAccepted( @@ -67,28 +70,34 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); IRecurringCollector.CancelAgreementBy by = cancelSource ? IRecurringCollector.CancelAgreementBy.ServiceProvider : IRecurringCollector.CancelAgreementBy.Payer; - _cancelAgreement(ctx, accepted.rca.agreementId, indexerState.addr, accepted.rca.payer, by); + _cancelAgreement(ctx, acceptedAgreementId, indexerState.addr, accepted.rca.payer, by); resetPrank(indexerState.addr); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementNotActive.selector, - accepted.rca.agreementId + acceptedAgreementId ); vm.expectRevert(expectedErr); - subgraphService.cancelIndexingAgreementByPayer(accepted.rca.agreementId); + subgraphService.cancelIndexingAgreementByPayer(acceptedAgreementId); } function test_SubgraphService_CancelIndexingAgreementByPayer(Seed memory seed) public { Context storage ctx = _newCtx(seed); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + _withIndexer(ctx) + ); _cancelAgreement( ctx, - accepted.rca.agreementId, + acceptedAgreementId, accepted.rca.serviceProvider, accepted.rca.payer, IRecurringCollector.CancelAgreementBy.Payer @@ -184,28 +193,34 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _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); + _cancelAgreement(ctx, acceptedAgreementId, accepted.rca.serviceProvider, accepted.rca.payer, by); resetPrank(indexerState.addr); bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementNotActive.selector, - accepted.rca.agreementId + acceptedAgreementId ); vm.expectRevert(expectedErr); - subgraphService.cancelIndexingAgreement(indexerState.addr, accepted.rca.agreementId); + subgraphService.cancelIndexingAgreement(indexerState.addr, acceptedAgreementId); } function test_SubgraphService_CancelIndexingAgreement_OK(Seed memory seed) public { Context storage ctx = _newCtx(seed); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, _withIndexer(ctx)); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + _withIndexer(ctx) + ); _cancelAgreement( ctx, - accepted.rca.agreementId, + acceptedAgreementId, accepted.rca.serviceProvider, accepted.rca.payer, IRecurringCollector.CancelAgreementBy.ServiceProvider 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 index 6f9c2563d..711154be5 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -29,7 +29,10 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + (IRecurringCollector.SignedRCA memory accepted, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement( + ctx, + indexerState + ); assertEq(subgraphService.feesProvisionTracker(indexerState.addr), 0, "Should be 0 before collect"); @@ -38,7 +41,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA bytes memory data = abi.encode( IRecurringCollector.CollectParams({ - agreementId: accepted.rca.agreementId, + agreementId: acceptedAgreementId, collectionId: bytes32(uint256(uint160(indexerState.allocationId))), tokens: 0, dataServiceCut: 0, @@ -46,33 +49,18 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA }) ); 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("") - ); + _expectCollectCallAndEmit(data, indexerState, accepted, acceptedAgreementId, tokensCollected, entities, poi); + subgraphService.collect( indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, - _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, epochManager.currentEpochBlock(), bytes("")) + _encodeCollectDataV1(acceptedAgreementId, entities, poi, epochManager.currentEpochBlock(), bytes("")) ); assertEq( @@ -214,7 +202,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA function test_SubgraphService_CollectIndexingFees_Reverts_WhenInvalidNestedData(Seed memory seed) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); resetPrank(indexerState.addr); @@ -225,10 +213,11 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA invalidNestedData ); vm.expectRevert(expectedErr); + subgraphService.collect( indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, - _encodeCollectData(accepted.rca.agreementId, invalidNestedData) + _encodeCollectData(acceptedAgreementId, invalidNestedData) ); } @@ -240,7 +229,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); IndexerState memory otherIndexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); vm.assume(otherIndexerState.addr != indexerState.addr); @@ -250,14 +239,14 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreement.IndexingAgreementNotAuthorized.selector, - accepted.rca.agreementId, + acceptedAgreementId, otherIndexerState.addr ); vm.expectRevert(expectedErr); subgraphService.collect( otherIndexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, - _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, currentEpochBlock, bytes("")) + _encodeCollectDataV1(acceptedAgreementId, entities, poi, currentEpochBlock, bytes("")) ); } @@ -268,7 +257,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); resetPrank(indexerState.addr); subgraphService.stopService(indexerState.addr, abi.encode(indexerState.allocationId)); @@ -283,7 +272,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA subgraphService.collect( indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, - _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, currentEpochBlock, bytes("")) + _encodeCollectDataV1(acceptedAgreementId, entities, poi, currentEpochBlock, bytes("")) ); } @@ -294,7 +283,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ) public { Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); skip(maxPOIStaleness + 1); resetPrank(indexerState.addr); @@ -310,8 +299,38 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA subgraphService.collect( indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, - _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, currentEpochBlock, bytes("")) + _encodeCollectDataV1(acceptedAgreementId, entities, poi, currentEpochBlock, bytes("")) ); } + /* solhint-enable graph/func-name-mixedcase */ + + function _expectCollectCallAndEmit( + bytes memory _data, + IndexerState memory _indexerState, + IRecurringCollector.SignedRCA memory _accepted, + bytes16 _acceptedAgreementId, + uint256 _tokensCollected, + uint256 _entities, + bytes32 _poi + ) private { + vm.expectCall( + address(recurringCollector), + abi.encodeCall(IPaymentsCollector.collect, (IGraphPayments.PaymentTypes.IndexingFee, _data)) + ); + vm.expectEmit(address(subgraphService)); + emit IndexingAgreement.IndexingFeesCollectedV1( + _indexerState.addr, + _accepted.rca.payer, + _acceptedAgreementId, + _indexerState.allocationId, + _indexerState.subgraphDeploymentId, + epochManager.currentEpoch(), + _tokensCollected, + _entities, + _poi, + epochManager.currentEpochBlock(), + bytes("") + ); + } } 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 index 5c8758370..45b7db8d8 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -42,7 +42,7 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( ctx.ctxInternal.seed.rca ); - _sharedSetup(ctx, rca, indexerState, expectedTokens); + bytes16 acceptedAgreementId = _sharedSetup(ctx, rca, indexerState, expectedTokens); TestState memory beforeCollect = _getState(rca.payer, indexerState.addr); @@ -52,7 +52,7 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, _encodeCollectDataV1( - rca.agreementId, + acceptedAgreementId, 1, keccak256(abi.encodePacked("poi")), epochManager.currentEpochBlock(), @@ -75,11 +75,11 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( ctx.ctxInternal.seed.rca ); - _sharedSetup(ctx, rca, indexerState, expectedTokens); + bytes16 acceptedAgreementId = _sharedSetup(ctx, rca, indexerState, expectedTokens); // Cancel the indexing agreement by the payer resetPrank(ctx.payer.signer); - subgraphService.cancelIndexingAgreementByPayer(rca.agreementId); + subgraphService.cancelIndexingAgreementByPayer(acceptedAgreementId); TestState memory beforeCollect = _getState(rca.payer, indexerState.addr); @@ -89,7 +89,7 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, _encodeCollectDataV1( - rca.agreementId, + acceptedAgreementId, 1, keccak256(abi.encodePacked("poi")), epochManager.currentEpochBlock(), @@ -108,7 +108,7 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex IRecurringCollector.RecurringCollectionAgreement memory _rca, IndexerState memory _indexerState, ExpectedTokens memory _expectedTokens - ) internal { + ) internal returns (bytes16) { _addTokensToProvision(_indexerState, _expectedTokens.expectedTokensLocked); IndexingAgreement.IndexingAgreementTermsV1 memory terms = IndexingAgreement.IndexingAgreementTermsV1({ @@ -137,13 +137,15 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex subgraphService.setPaymentsDestination(_indexerState.addr); // Accept the Indexing Agreement - subgraphService.acceptIndexingAgreement( + bytes16 agreementId = subgraphService.acceptIndexingAgreement( _indexerState.allocationId, _recurringCollectorHelper.generateSignedRCA(_rca, _ctx.payer.signerPrivateKey) ); // Skip ahead to collection point skip(_expectedTokens.expectedTotalTokensCollected / terms.tokensPerSecond); + + return agreementId; } function _newExpectedTokens(uint256 _fuzzyTokensCollected) internal view returns (ExpectedTokens memory) { 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 index c23727f20..f04be267b 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -169,7 +169,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun function _withAcceptedIndexingAgreement( Context storage _ctx, IndexerState memory _indexerState - ) internal returns (IRecurringCollector.SignedRCA memory) { + ) internal returns (IRecurringCollector.SignedRCA memory, bytes16 agreementId) { IRecurringCollector.RecurringCollectionAgreement memory rca = _ctx.ctxInternal.seed.rca; IndexingAgreement.AcceptIndexingAgreementMetadata memory metadata = _newAcceptIndexingAgreementMetadataV1( @@ -187,20 +187,31 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun ); _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, _ctx.payer.signerPrivateKey); + // Generate deterministic agreement ID for event expectation + agreementId = recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + vm.expectEmit(address(subgraphService)); emit IndexingAgreement.IndexingAgreementAccepted( rca.serviceProvider, rca.payer, - rca.agreementId, + agreementId, _indexerState.allocationId, metadata.subgraphDeploymentId, metadata.version, metadata.terms ); _subgraphServiceSafePrank(_indexerState.addr); - subgraphService.acceptIndexingAgreement(_indexerState.allocationId, signedRCA); + bytes16 actualAgreementId = subgraphService.acceptIndexingAgreement(_indexerState.allocationId, signedRCA); - return signedRCA; + // Verify the agreement ID matches expectation + assertEq(actualAgreementId, agreementId); + return (signedRCA, agreementId); } function _newCtx(Seed memory _seed) internal returns (Context storage) { @@ -267,7 +278,14 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun IRecurringCollector.RecurringCollectionAgreement memory _rca ) internal view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory) { IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _ctx.ctxInternal.seed.rcau; - rcau.agreementId = _rca.agreementId; + // Generate deterministic agreement ID for the update + rcau.agreementId = recurringCollector.generateAgreementId( + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.deadline, + _rca.nonce + ); rcau.metadata = _encodeUpdateIndexingAgreementMetadataV1( _newUpdateIndexingAgreementMetadataV1( _ctx.ctxInternal.seed.termsV1.tokensPerSecond, 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 index ebd9200d1..ba14d8e55 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -107,7 +107,7 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA Context storage ctx = _newCtx(seed); IndexerState memory indexerStateA = _withIndexer(ctx); IndexerState memory indexerStateB = _withIndexer(ctx); - IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerStateA); + (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerStateA); IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU(ctx, accepted.rca); bytes memory expectedErr = abi.encodeWithSelector( @@ -123,7 +123,7 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA 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.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); IRecurringCollector.RecurringCollectionAgreementUpdate memory acceptableUpdate = _generateAcceptableRecurringCollectionAgreementUpdate(ctx, accepted.rca); acceptableUpdate.metadata = bytes("invalid"); @@ -147,7 +147,7 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA 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.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); IRecurringCollector.SignedRCAU memory acceptableUpdate = _generateAcceptableSignedRCAU(ctx, accepted.rca); IndexingAgreement.UpdateIndexingAgreementMetadata memory metadata = abi.decode( From 836c0c2ec01551a4cc09cd5143f88eab62e8ea9b Mon Sep 17 00:00:00 2001 From: Matias Date: Wed, 23 Jul 2025 21:55:11 -0300 Subject: [PATCH 11/59] fix: [TRST-L-5] Add slippage protection Implements slippage protection mechanism to prevent silent token loss during rate-limited collections in RecurringCollector agreements. The implementation uses type(uint256).max convention to disable slippage checks, providing users full control over acceptable token loss during rate limiting. Resolves audit finding TRST-L-5: "RecurringCollector silently reduces collected tokens without user consent" --- .../interfaces/IRecurringCollector.sol | 10 ++ .../collectors/RecurringCollector.sol | 6 + .../recurring-collector/collect.t.sol | 134 ++++++++++++++++++ .../payments/recurring-collector/shared.t.sol | 3 +- .../contracts/libraries/IndexingAgreement.sol | 5 +- .../indexing-agreement/collect.t.sol | 3 +- .../indexing-agreement/shared.t.sol | 3 +- 7 files changed, 160 insertions(+), 4 deletions(-) diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol index 704515aa7..5bf597090 100644 --- a/packages/horizon/contracts/interfaces/IRecurringCollector.sol +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -147,6 +147,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @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 + * @param maxSlippage Max acceptable tokens to lose due to rate limiting, or type(uint256).max to ignore */ struct CollectParams { bytes16 agreementId; @@ -154,6 +155,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { uint256 tokens; uint256 dataServiceCut; address receiverDestination; + uint256 maxSlippage; } /** @@ -369,6 +371,14 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ error RecurringCollectorInvalidUpdateNonce(bytes16 agreementId, uint32 expected, uint32 provided); + /** + * @notice Thrown when collected tokens are less than requested beyond the allowed slippage + * @param requested The amount of tokens requested to collect + * @param actual The actual amount that would be collected + * @param maxSlippage The maximum allowed slippage + */ + error RecurringCollectorExcessiveSlippage(uint256 requested, uint256 actual, uint256 maxSlippage); + /** * @dev Accept an indexing agreement. * @param signedRCA The signed Recurring Collection Agreement which is to be accepted. diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 56d2b9d5b..79f1d1a12 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -336,6 +336,12 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC if (_params.tokens != 0) { tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens, collectionSeconds); + uint256 slippage = _params.tokens - tokensToCollect; + require( + slippage <= _params.maxSlippage, + RecurringCollectorExcessiveSlippage(_params.tokens, tokensToCollect, _params.maxSlippage) + ); + _graphPaymentsEscrow().collect( _paymentType, agreement.payer, diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index a972734a6..d44284e9f 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -2,6 +2,7 @@ pragma solidity 0.8.27; import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol"; import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; import { RecurringCollectorSharedTest } from "./shared.t.sol"; @@ -309,5 +310,138 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { uint256 collected = _recurringCollector.collect(_paymentType(fuzzy.unboundedPaymentType), data); assertEq(collected, tokens); } + + function test_Collect_RevertWhen_ExceedsMaxSlippage() public { + // Setup: Create agreement with known parameters + IRecurringCollector.RecurringCollectionAgreement memory rca; + rca.deadline = uint64(block.timestamp + 1000); + rca.endsAt = uint64(block.timestamp + 2000); + rca.payer = address(0x123); + rca.dataService = address(0x456); + rca.serviceProvider = address(0x789); + rca.maxInitialTokens = 0; // No initial tokens to keep calculation simple + rca.maxOngoingTokensPerSecond = 1 ether; // 1 token per second + rca.minSecondsPerCollection = 60; // 1 minute + rca.maxSecondsPerCollection = 3600; // 1 hour + rca.nonce = 1; + rca.metadata = ""; + + // Accept the agreement + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, 1); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, 1); + bytes16 agreementId = _accept(signedRCA); + + // Do a first collection to use up initial tokens allowance + skip(rca.minSecondsPerCollection); + IRecurringCollector.CollectParams memory firstCollection = IRecurringCollector.CollectParams({ + agreementId: agreementId, + collectionId: keccak256("first"), + tokens: 1 ether, // Small amount + dataServiceCut: 0, + receiverDestination: rca.serviceProvider, + maxSlippage: type(uint256).max + }); + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, _generateCollectData(firstCollection)); + + // Wait minimum collection time again for second collection + skip(rca.minSecondsPerCollection); + + // Calculate expected narrowing: max allowed is 60 tokens (60 seconds * 1 token/second) + uint256 maxAllowed = rca.maxOngoingTokensPerSecond * rca.minSecondsPerCollection; // 60 tokens + uint256 requested = maxAllowed + 50 ether; // Request 110 tokens + uint256 expectedSlippage = requested - maxAllowed; // 50 tokens + uint256 maxSlippage = expectedSlippage - 1; // Allow up to 49 tokens slippage + + // Create collect params with slippage protection + IRecurringCollector.CollectParams memory collectParams = IRecurringCollector.CollectParams({ + agreementId: agreementId, + collectionId: keccak256("test"), + tokens: requested, + dataServiceCut: 0, + receiverDestination: rca.serviceProvider, + maxSlippage: maxSlippage + }); + + bytes memory data = _generateCollectData(collectParams); + + // Expect revert due to excessive slippage (50 > 49) + vm.expectRevert( + abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorExcessiveSlippage.selector, + requested, + maxAllowed, + maxSlippage + ) + ); + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function test_Collect_OK_WithMaxSlippageDisabled() public { + // Setup: Create agreement with known parameters + IRecurringCollector.RecurringCollectionAgreement memory rca; + rca.deadline = uint64(block.timestamp + 1000); + rca.endsAt = uint64(block.timestamp + 2000); + rca.payer = address(0x123); + rca.dataService = address(0x456); + rca.serviceProvider = address(0x789); + rca.maxInitialTokens = 0; // No initial tokens to keep calculation simple + rca.maxOngoingTokensPerSecond = 1 ether; // 1 token per second + rca.minSecondsPerCollection = 60; // 1 minute + rca.maxSecondsPerCollection = 3600; // 1 hour + rca.nonce = 1; + rca.metadata = ""; + + // Accept the agreement + _recurringCollectorHelper.authorizeSignerWithChecks(rca.payer, 1); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, 1); + bytes16 agreementId = _accept(signedRCA); + + // Do a first collection to use up initial tokens allowance + skip(rca.minSecondsPerCollection); + IRecurringCollector.CollectParams memory firstCollection = IRecurringCollector.CollectParams({ + agreementId: agreementId, + collectionId: keccak256("first"), + tokens: 1 ether, // Small amount + dataServiceCut: 0, + receiverDestination: rca.serviceProvider, + maxSlippage: type(uint256).max + }); + vm.prank(rca.dataService); + _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, _generateCollectData(firstCollection)); + + // Wait minimum collection time again for second collection + skip(rca.minSecondsPerCollection); + + // Calculate expected narrowing: max allowed is 60 tokens (60 seconds * 1 token/second) + uint256 maxAllowed = rca.maxOngoingTokensPerSecond * rca.minSecondsPerCollection; // 60 tokens + uint256 requested = maxAllowed + 50 ether; // Request 110 tokens (will be narrowed to 60) + + // Create collect params with slippage disabled (type(uint256).max) + IRecurringCollector.CollectParams memory collectParams = IRecurringCollector.CollectParams({ + agreementId: agreementId, + collectionId: keccak256("test"), + tokens: requested, + dataServiceCut: 0, + receiverDestination: rca.serviceProvider, + maxSlippage: type(uint256).max + }); + + bytes memory data = _generateCollectData(collectParams); + + // Should succeed despite slippage when maxSlippage is disabled + _expectCollectCallAndEmit( + rca, + agreementId, + IGraphPayments.PaymentTypes.IndexingFee, + collectParams, + maxAllowed // Will collect the narrowed amount + ); + + vm.prank(rca.dataService); + uint256 collected = _recurringCollector.collect(IGraphPayments.PaymentTypes.IndexingFee, data); + assertEq(collected, maxAllowed); + } /* 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 index 9a564086e..2e76c048e 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -233,7 +233,8 @@ contract RecurringCollectorSharedTest is Test, Bounder { collectionId: _collectionId, tokens: _tokens, dataServiceCut: _dataServiceCut, - receiverDestination: _rca.serviceProvider + receiverDestination: _rca.serviceProvider, + maxSlippage: type(uint256).max }); } diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index d1f42f2b5..1935a6197 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -96,12 +96,14 @@ library IndexingAgreement { * @param poi The proof of indexing (POI) * @param poiBlockNumber The block number of the POI * @param metadata Additional metadata associated with the collection + * @param maxSlippage Max acceptable tokens to lose due to rate limiting, or type(uint256).max to ignore */ struct CollectIndexingFeeDataV1 { uint256 entities; bytes32 poi; uint256 poiBlockNumber; bytes metadata; + uint256 maxSlippage; } /** @@ -565,7 +567,8 @@ library IndexingAgreement { collectionId: bytes32(uint256(uint160(wrapper.agreement.allocationId))), tokens: expectedTokens, dataServiceCut: 0, - receiverDestination: params.receiverDestination + receiverDestination: params.receiverDestination, + maxSlippage: data.maxSlippage }) ) ); 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 index 711154be5..f41bdf976 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -45,7 +45,8 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA collectionId: bytes32(uint256(uint160(indexerState.allocationId))), tokens: 0, dataServiceCut: 0, - receiverDestination: indexerState.addr + receiverDestination: indexerState.addr, + maxSlippage: type(uint256).max }) ); uint256 tokensCollected = bound(unboundedTokensCollected, 1, indexerState.tokens / stakeToFeesRatio); 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 index f04be267b..b51008c20 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -392,7 +392,8 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun entities: _entities, poi: _poi, poiBlockNumber: _poiBlock, - metadata: _metadata + metadata: _metadata, + maxSlippage: type(uint256).max }) ); } From e3d2787b6d123b53ff87cebdc5e735403f5157a9 Mon Sep 17 00:00:00 2001 From: Matias Date: Thu, 24 Jul 2025 10:32:26 -0300 Subject: [PATCH 12/59] fix: [TRST-L-6] Proper agreement version check --- .../contracts/libraries/IndexingAgreement.sol | 3 +- .../indexing-agreement/accept.t.sol | 33 ++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 1935a6197..487db627e 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -390,8 +390,7 @@ library IndexingAgreement { signedRCAU.rcau.metadata ); - wrapper.agreement.version = metadata.version; - + require(wrapper.agreement.version == IndexingAgreementVersion.V1, "internal: invalid version"); require(metadata.version == IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version)); _setTermsV1(self, signedRCAU.rcau.agreementId, metadata.terms); 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 index db2a859dd..ac8981466 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -226,7 +226,38 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg subgraphService.acceptIndexingAgreement(ctx.indexers[0].allocationId, accepted); } - function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAllocated() public {} + function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenAgreementAlreadyAllocated( + Seed memory seed, + uint256 alternativeNonce + ) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + + // First, accept an indexing agreement on the allocation + (IRecurringCollector.SignedRCA memory accepted, ) = _withAcceptedIndexingAgreement(ctx, indexerState); + vm.assume(accepted.rca.nonce != alternativeNonce); + + // Now try to accept a different agreement on the same allocation + // Create a new agreement with different nonce to ensure different agreement ID + IRecurringCollector.RecurringCollectionAgreement + memory newRCA = _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr); + newRCA.nonce = alternativeNonce; // Different nonce to ensure different agreement ID + + // Sign the new agreement + IRecurringCollector.SignedRCA memory newSignedRCA = _recurringCollectorHelper.generateSignedRCA( + newRCA, + ctx.payer.signerPrivateKey + ); + + // Expect the error when trying to accept a second agreement on the same allocation + bytes memory expectedErr = abi.encodeWithSelector( + IndexingAgreement.AllocationAlreadyHasIndexingAgreement.selector, + indexerState.allocationId + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.acceptIndexingAgreement(indexerState.allocationId, newSignedRCA); + } function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenInvalidTermsData(Seed memory seed) public { Context storage ctx = _newCtx(seed); From 308d6e6d07e6dfcf499494f47d06f8f9580bf1a7 Mon Sep 17 00:00:00 2001 From: Matias Date: Thu, 24 Jul 2025 11:08:20 -0300 Subject: [PATCH 13/59] document: [TRST-L-7] update() --- .../contracts/payments/collectors/RecurringCollector.sol | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 79f1d1a12..465e806d4 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -184,6 +184,8 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC * @notice Update an indexing agreement. * See {IRecurringCollector.update}. * @dev Caller must be the data service for the agreement. + * @dev Note: Updated pricing terms apply immediately and will affect the next collection + * for the entire period since lastCollectionAt. */ function update(SignedRCAU calldata signedRCAU) external { require( From 17b794e49c8144558210a04e93230761cfd7161f Mon Sep 17 00:00:00 2001 From: Matias Date: Thu, 24 Jul 2025 14:10:28 -0300 Subject: [PATCH 14/59] fix: [TRST-L-9] Cancel agreement if over-allocated --- .../contracts/SubgraphService.sol | 21 ++++++++-- .../contracts/libraries/AllocationHandler.sol | 9 ++-- .../contracts/libraries/IndexingAgreement.sol | 6 +-- .../contracts/utilities/AllocationManager.sol | 5 ++- .../unit/shared/HorizonStakingShared.t.sol | 6 +++ .../indexing-agreement/integration.t.sol | 42 +++++++++++++++++-- 6 files changed, 74 insertions(+), 15 deletions(-) diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index d311c6c62..c5901eda7 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -574,10 +574,10 @@ contract SubgraphService is * @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 + * @param _forceClosed Whether the allocation was force closed */ - function _onCloseAllocation(address _allocationId, bool _stale) internal { - IndexingAgreement._getStorageManager().onCloseAllocation(_allocationId, _stale); + function _onCloseAllocation(address _allocationId, bool _forceClosed) internal { + IndexingAgreement._getStorageManager().onCloseAllocation(_allocationId, _forceClosed); } /** @@ -738,7 +738,20 @@ contract SubgraphService is _allocations.get(allocationId).indexer == _indexer, SubgraphServiceAllocationNotAuthorized(_indexer, allocationId) ); - return _presentPOI(allocationId, poi_, poiMetadata_, _delegationRatio, paymentsDestination[_indexer]); + + (uint256 paymentCollected, bool allocationForceClosed) = _presentPOI( + allocationId, + poi_, + poiMetadata_, + _delegationRatio, + paymentsDestination[_indexer] + ); + + if (allocationForceClosed) { + _onCloseAllocation(allocationId, true); + } + + return paymentCollected; } /** diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index 394430cad..62720951c 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -281,14 +281,15 @@ library AllocationHandler { * @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 + * @return tokensCollected The amount of tokens collected + * @return allocationForceClosed True if the allocation was automatically closed due to over-allocation, false otherwise */ 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) { + ) external returns (uint256, bool) { Allocation.State memory allocation = _allocations.get(params._allocationId); require(allocation.isOpen(), AllocationHandler.AllocationHandlerAllocationClosed(params._allocationId)); @@ -358,6 +359,7 @@ library AllocationHandler { ); // Check if the indexer is over-allocated and force close the allocation if necessary + bool allocationForceClosed; if ( _isOverAllocated( allocationProvisionTracker, @@ -366,6 +368,7 @@ library AllocationHandler { params._delegationRatio ) ) { + allocationForceClosed = true; _closeAllocation( _allocations, allocationProvisionTracker, @@ -376,7 +379,7 @@ library AllocationHandler { ); } - return tokensRewards; + return (tokensRewards, allocationForceClosed); } /** diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 487db627e..f5f04c602 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -450,10 +450,10 @@ library IndexingAgreement { * * @param self The indexing agreement storage manager * @param _allocationId The allocation ID - * @param stale Whether the allocation is stale or not + * @param forceClosed Whether the allocation was force closed * */ - function onCloseAllocation(StorageManager storage self, address _allocationId, bool stale) external { + function onCloseAllocation(StorageManager storage self, address _allocationId, bool forceClosed) external { bytes16 agreementId = self.allocationToActiveAgreementId[_allocationId]; if (agreementId == bytes16(0)) { return; @@ -469,7 +469,7 @@ library IndexingAgreement { agreementId, wrapper.agreement, wrapper.collectorAgreement, - stale + forceClosed ? IRecurringCollector.CancelAgreementBy.ThirdParty : IRecurringCollector.CancelAgreementBy.ServiceProvider ); diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index bc64d0eb6..a7d228744 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -131,7 +131,8 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca * @param _poiMetadata The metadata associated with the POI. The data and encoding format is for off-chain components to define, this function will only emit the value in an event as-is. * @param _delegationRatio The delegation ratio to consider when locking tokens * @param _paymentsDestination The address where indexing rewards should be sent - * @return The amount of tokens collected + * @return tokensCollected The amount of tokens collected + * @return allocationForceClosed True if the allocation was automatically closed due to over-allocation, false otherwise */ function _presentPOI( address _allocationId, @@ -139,7 +140,7 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca bytes memory _poiMetadata, uint32 _delegationRatio, address _paymentsDestination - ) internal returns (uint256) { + ) internal returns (uint256, bool) { return AllocationHandler.presentPOI( _allocations, diff --git a/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol b/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol index 55990a2b7..bd143a74f 100644 --- a/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol +++ b/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol @@ -38,6 +38,12 @@ abstract contract HorizonStakingSharedTest is SubgraphBaseTest { staking.addToProvision(_indexer, address(subgraphService), _tokens); } + function _removeFromProvision(address _indexer, uint256 _tokens) internal { + staking.thaw(_indexer, address(subgraphService), _tokens); + skip(staking.getProvision(_indexer, address(subgraphService)).thawingPeriod + 1); + staking.deprovision(_indexer, address(subgraphService), 0); + } + function _delegate(address _indexer, address _verifier, uint256 _tokens, uint256 _minSharesOut) internal { staking.delegate(_indexer, _verifier, _tokens, _minSharesOut); } 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 index 45b7db8d8..660658450 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -101,6 +101,35 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex _sharedAssert(beforeCollect, afterCollect, expectedTokens, tokensCollected); } + function test_SubgraphService_CollectIndexingRewards_CancelsAgreementWhenOverAllocated_Integration( + Seed memory seed + ) public { + // Setup context and indexer with active agreement + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + (, bytes16 agreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + + // Reduce indexer's provision to force over-allocation after collecting rewards + uint256 extraTokens = indexerState.tokens - minimumProvisionTokens; + vm.assume(extraTokens > 0); + _removeTokensFromProvision(indexerState, extraTokens); + + // Verify indexer will be over-allocated after presenting POI + assertTrue(subgraphService.isOverAllocated(indexerState.addr)); + + // Collect indexing rewards - this should trigger allocation closure and agreement cancellation + bytes memory collectData = abi.encode(indexerState.allocationId, bytes32("poi"), bytes("metadata")); + resetPrank(indexerState.addr); + subgraphService.collect(indexerState.addr, IGraphPayments.PaymentTypes.IndexingRewards, collectData); + + // Verify the indexing agreement was properly cancelled + IndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); + assertEq( + uint8(agreement.collectorAgreement.state), + uint8(IRecurringCollector.AgreementState.CanceledByServiceProvider) + ); + } + /* solhint-enable graph/func-name-mixedcase */ function _sharedSetup( @@ -195,10 +224,17 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex ); } - function _addTokensToProvision(IndexerState memory _indexerState, uint256 _tokensToAddToProvision) private { - deal({ token: address(token), to: _indexerState.addr, give: _tokensToAddToProvision }); + function _addTokensToProvision(IndexerState memory _indexerState, uint256 _tokens) private { + deal({ token: address(token), to: _indexerState.addr, give: _tokens }); + vm.startPrank(_indexerState.addr); + _addToProvision(_indexerState.addr, _tokens); + vm.stopPrank(); + } + + function _removeTokensFromProvision(IndexerState memory _indexerState, uint256 _tokens) private { + deal({ token: address(token), to: _indexerState.addr, give: _tokens }); vm.startPrank(_indexerState.addr); - _addToProvision(_indexerState.addr, _tokensToAddToProvision); + _removeFromProvision(_indexerState.addr, _tokens); vm.stopPrank(); } From 6d9a18c52e99430f92676c2f89379cdf5e64819e Mon Sep 17 00:00:00 2001 From: Matias Date: Thu, 24 Jul 2025 14:39:28 -0300 Subject: [PATCH 15/59] fix: [TRST-R-1] minor fixes --- .../contracts/payments/collectors/RecurringCollector.sol | 6 +++--- .../contracts/libraries/IndexingAgreementDecoder.sol | 2 +- .../unit/subgraphService/indexing-agreement/accept.t.sol | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 465e806d4..41a67aaf8 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -71,7 +71,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /** * @inheritdoc IRecurringCollector - * @notice Accept an indexing agreement. + * @notice Accept a Recurring Collection Agreement. * See {IRecurringCollector.accept}. * @dev Caller must be the data service the RCA was issued to. */ @@ -148,7 +148,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /** * @inheritdoc IRecurringCollector - * @notice Cancel an indexing agreement. + * @notice Cancel a Recurring Collection Agreement. * See {IRecurringCollector.cancel}. * @dev Caller must be the data service for the agreement. */ @@ -181,7 +181,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /** * @inheritdoc IRecurringCollector - * @notice Update an indexing agreement. + * @notice Update a Recurring Collection Agreement. * See {IRecurringCollector.update}. * @dev Caller must be the data service for the agreement. * @dev Note: Updated pricing terms apply immediately and will affect the next collection diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol index f8f5af811..a50e53f0d 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol @@ -95,7 +95,7 @@ library IndexingAgreementDecoder { ) { return decoded; } catch { - revert IndexingAgreementDecoderInvalidData("decodeCollectIndexingFeeData", data); + revert IndexingAgreementDecoderInvalidData("decodeIndexingAgreementTermsV1", data); } } } 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 index ac8981466..8e7cafdf6 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -275,7 +275,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg bytes memory expectedErr = abi.encodeWithSelector( IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, - "decodeCollectIndexingFeeData", + "decodeIndexingAgreementTermsV1", invalidTermsData ); vm.expectRevert(expectedErr); From 58b7a28e52f772e4572339d7702b10b80b65a031 Mon Sep 17 00:00:00 2001 From: Matias Date: Thu, 24 Jul 2025 14:55:24 -0300 Subject: [PATCH 16/59] fix: [TRST-R-4] CEI violation --- .../contracts/payments/collectors/RecurringCollector.sol | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 41a67aaf8..a24e2aac9 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -343,7 +343,10 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC slippage <= _params.maxSlippage, RecurringCollectorExcessiveSlippage(_params.tokens, tokensToCollect, _params.maxSlippage) ); + } + agreement.lastCollectionAt = uint64(block.timestamp); + if (tokensToCollect > 0) { _graphPaymentsEscrow().collect( _paymentType, agreement.payer, @@ -354,7 +357,6 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC _params.receiverDestination ); } - agreement.lastCollectionAt = uint64(block.timestamp); emit PaymentCollected( _paymentType, From 5f732aceb290efc4103f735903bd4cd953748201 Mon Sep 17 00:00:00 2001 From: Matias Date: Thu, 24 Jul 2025 15:19:37 -0300 Subject: [PATCH 17/59] fix: [TRST-R-5] Terms validation --- .../contracts/libraries/IndexingAgreement.sol | 40 +++++++++++++++++-- .../indexing-agreement/shared.t.sol | 2 +- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index f5f04c602..1035f2d62 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -271,6 +271,13 @@ library IndexingAgreement { */ error IndexingAgreementNotAuthorized(bytes16 agreementId, address unauthorizedIndexer); + /** + * @notice Thrown when indexing agreement terms are invalid + * @param tokensPerSecond The indexing agreement tokens per second + * @param maxOngoingTokensPerSecond The RCA maximum tokens per second + */ + error IndexingAgreementInvalidTerms(uint256 tokensPerSecond, uint256 maxOngoingTokensPerSecond); + /** * @notice Accept an indexing agreement. * @@ -343,7 +350,7 @@ library IndexingAgreement { agreement.allocationId = allocationId; require(metadata.version == IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version)); - _setTermsV1(self, agreementId, metadata.terms); + _setTermsV1(self, agreementId, metadata.terms, signedRCA.rca.maxOngoingTokensPerSecond); emit IndexingAgreementAccepted( signedRCA.rca.serviceProvider, @@ -392,7 +399,12 @@ library IndexingAgreement { require(wrapper.agreement.version == IndexingAgreementVersion.V1, "internal: invalid version"); require(metadata.version == IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version)); - _setTermsV1(self, signedRCAU.rcau.agreementId, metadata.terms); + _setTermsV1( + self, + signedRCAU.rcau.agreementId, + metadata.terms, + wrapper.collectorAgreement.maxOngoingTokensPerSecond + ); emit IndexingAgreementUpdated({ indexer: wrapper.collectorAgreement.serviceProvider, @@ -621,9 +633,16 @@ library IndexingAgreement { * @param _manager The indexing agreement storage manager * @param _agreementId The id of the agreement to update * @param _data The encoded terms data + * @param maxOngoingTokensPerSecond The RCA maximum tokens per second limit for validation */ - function _setTermsV1(StorageManager storage _manager, bytes16 _agreementId, bytes memory _data) private { + function _setTermsV1( + StorageManager storage _manager, + bytes16 _agreementId, + bytes memory _data, + uint256 maxOngoingTokensPerSecond + ) private { IndexingAgreementTermsV1 memory newTerms = IndexingAgreementDecoder.decodeIndexingAgreementTermsV1(_data); + _validateTermsAgainstRCA(newTerms, maxOngoingTokensPerSecond); _manager.termsV1[_agreementId].tokensPerSecond = newTerms.tokensPerSecond; _manager.termsV1[_agreementId].tokensPerEntityPerSecond = newTerms.tokensPerEntityPerSecond; } @@ -764,4 +783,19 @@ library IndexingAgreement { collectorAgreement: _directory().recurringCollector().getAgreement(agreementId) }); } + + /** + * @notice Validates indexing agreement terms against RCA limits + * @param terms The indexing agreement terms to validate + * @param maxOngoingTokensPerSecond The RCA maximum tokens per second limit + */ + function _validateTermsAgainstRCA( + IndexingAgreementTermsV1 memory terms, + uint256 maxOngoingTokensPerSecond + ) private pure { + require( + terms.tokensPerSecond <= maxOngoingTokensPerSecond, + IndexingAgreementInvalidTerms(terms.tokensPerSecond, maxOngoingTokensPerSecond) + ); + } } 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 index b51008c20..09660ff57 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -288,7 +288,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun ); rcau.metadata = _encodeUpdateIndexingAgreementMetadataV1( _newUpdateIndexingAgreementMetadataV1( - _ctx.ctxInternal.seed.termsV1.tokensPerSecond, + bound(_ctx.ctxInternal.seed.termsV1.tokensPerSecond, 0, _rca.maxOngoingTokensPerSecond), _ctx.ctxInternal.seed.termsV1.tokensPerEntityPerSecond ) ); From b492251395565ab97869b9e3e34b5840c1e6eb18 Mon Sep 17 00:00:00 2001 From: Matias Date: Fri, 25 Jul 2025 10:31:09 -0300 Subject: [PATCH 18/59] fix: [TRST-R-6] Configurable indexing fees cut --- .../contracts/SubgraphService.sol | 10 +++++++++- .../contracts/SubgraphServiceStorage.sol | 3 +++ .../contracts/interfaces/ISubgraphService.sol | 19 +++++++++++++++++++ .../contracts/libraries/IndexingAgreement.sol | 4 +++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index c5901eda7..653149267 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -398,6 +398,13 @@ contract SubgraphService is emit CurationCutSet(curationCut); } + /// @inheritdoc ISubgraphService + function setIndexingFeesCut(uint256 indexingFeesCut_) external override onlyOwner { + require(PPMMath.isValidPPM(indexingFeesCut_), SubgraphServiceInvalidIndexingFeesCut(indexingFeesCut_)); + indexingFeesCut = indexingFeesCut_; + emit IndexingFeesCutSet(indexingFeesCut_); + } + /** * @inheritdoc ISubgraphService * @notice Accept an indexing agreement. @@ -793,7 +800,8 @@ contract SubgraphService is agreementId: _agreementId, currentEpoch: _graphEpochManager().currentEpoch(), receiverDestination: _paymentsDestination, - data: _data + data: _data, + indexingFeesCut: indexingFeesCut }) ); diff --git a/packages/subgraph-service/contracts/SubgraphServiceStorage.sol b/packages/subgraph-service/contracts/SubgraphServiceStorage.sol index 06ada3a59..1e0b608d6 100644 --- a/packages/subgraph-service/contracts/SubgraphServiceStorage.sol +++ b/packages/subgraph-service/contracts/SubgraphServiceStorage.sol @@ -21,4 +21,7 @@ abstract contract SubgraphServiceV1Storage { /// @notice Destination of indexer payments mapping(address indexer => address destination) public paymentsDestination; + + /// @notice The cut data service takes from indexing fee payments. In PPM. + uint256 public indexingFeesCut; } diff --git a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol index 17ff4cbd0..54ebf4396 100644 --- a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol +++ b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol @@ -69,12 +69,24 @@ interface ISubgraphService is IDataServiceFees { */ event CurationCutSet(uint256 curationCut); + /** + * @notice Emitted when indexing fees cut is set + * @param indexingFeesCut The indexing fees cut + */ + event IndexingFeesCutSet(uint256 indexingFeesCut); + /** * @notice Thrown when trying to set a curation cut that is not a valid PPM value * @param curationCut The curation cut value */ error SubgraphServiceInvalidCurationCut(uint256 curationCut); + /** + * @notice Thrown when trying to set an indexing fees cut that is not a valid PPM value + * @param indexingFeesCut The indexing fees cut value + */ + error SubgraphServiceInvalidIndexingFeesCut(uint256 indexingFeesCut); + /** * @notice Thrown when an indexer tries to register with an empty URL */ @@ -252,6 +264,13 @@ interface ISubgraphService is IDataServiceFees { */ function setCurationCut(uint256 curationCut) external; + /** + * @notice Sets the data service payment cut for indexing fees + * @dev Emits a {IndexingFeesCutSet} event + * @param indexingFeesCut The indexing fees cut for the payment type + */ + function setIndexingFeesCut(uint256 indexingFeesCut) external; + /** * @notice Sets the payments destination for an indexer to receive payments * @dev Emits a {PaymentsDestinationSet} event diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 1035f2d62..f9648e4fb 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -80,6 +80,7 @@ library IndexingAgreement { * @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 + * @param indexingFeesCut The indexing fees cut in PPM */ struct CollectParams { address indexer; @@ -87,6 +88,7 @@ library IndexingAgreement { uint256 currentEpoch; address receiverDestination; bytes data; + uint256 indexingFeesCut; } /** @@ -577,7 +579,7 @@ library IndexingAgreement { agreementId: params.agreementId, collectionId: bytes32(uint256(uint160(wrapper.agreement.allocationId))), tokens: expectedTokens, - dataServiceCut: 0, + dataServiceCut: params.indexingFeesCut, receiverDestination: params.receiverDestination, maxSlippage: data.maxSlippage }) From 0e469beeba0ec433e313be8c9129bcf99acdaac6 Mon Sep 17 00:00:00 2001 From: Matias Date: Mon, 11 Aug 2025 15:20:20 -0300 Subject: [PATCH 19/59] fix: [TRST-M-2] improve _getCollectionInfo() --- .../interfaces/IRecurringCollector.sol | 18 ++++++++++- .../collectors/RecurringCollector.sol | 32 +++++++++---------- .../recurring-collector/collect.t.sol | 8 ++--- .../contracts/libraries/IndexingAgreement.sol | 2 +- .../indexing-agreement/collect.t.sol | 4 +++ 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol index 5bf597090..c4930a954 100644 --- a/packages/horizon/contracts/interfaces/IRecurringCollector.sol +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -28,6 +28,14 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { ThirdParty } + /// @notice Reasons why an agreement is not collectable + enum AgreementNotCollectableReason { + None, + InvalidAgreementState, + ZeroCollectionSeconds, + InvalidTemporalWindow + } + /** * @notice A representation of a signed Recurring Collection Agreement (RCA) * @param rca The Recurring Collection Agreement to be signed @@ -303,6 +311,13 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ error RecurringCollectorAgreementIncorrectState(bytes16 agreementId, AgreementState incorrectState); + /** + * @notice Thrown when an agreement is not collectable + * @param agreementId The agreement ID + * @param reason The reason why the agreement is not collectable + */ + error RecurringCollectorAgreementNotCollectable(bytes16 agreementId, AgreementNotCollectableReason reason); + /** * @notice Thrown when accepting an agreement with an address that is not set */ @@ -440,10 +455,11 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @return isCollectable Whether the agreement is in a valid state that allows collection attempts, * not that there are necessarily funds available to collect. * @return collectionSeconds The valid collection duration in seconds (0 if not collectable) + * @return reason The reason why the agreement is not collectable (None if collectable) */ function getCollectionInfo( AgreementData memory agreement - ) external view returns (bool isCollectable, uint256 collectionSeconds); + ) external view returns (bool isCollectable, uint256 collectionSeconds, AgreementNotCollectableReason reason); /** * @notice Generate a deterministic agreement ID from agreement parameters diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index a24e2aac9..99bf5d3fb 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -269,7 +269,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /// @inheritdoc IRecurringCollector function getCollectionInfo( AgreementData memory agreement - ) external view returns (bool isCollectable, uint256 collectionSeconds) { + ) external view returns (bool isCollectable, uint256 collectionSeconds, AgreementNotCollectableReason reason) { return _getCollectionInfo(agreement); } @@ -309,14 +309,11 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC ) private returns (uint256) { AgreementData storage agreement = _getAgreementStorage(_params.agreementId); - // Check if agreement exists first (for unknown agreements) - (bool isCollectable, uint256 collectionSeconds) = _getCollectionInfo(agreement); - require(isCollectable, RecurringCollectorAgreementIncorrectState(_params.agreementId, agreement.state)); - - require( - collectionSeconds > 0, - RecurringCollectorZeroCollectionSeconds(_params.agreementId, block.timestamp, agreement.lastCollectionAt) + // Check if agreement is collectable first + (bool isCollectable, uint256 collectionSeconds, AgreementNotCollectableReason reason) = _getCollectionInfo( + agreement ); + require(isCollectable, RecurringCollectorAgreementNotCollectable(_params.agreementId, reason)); require( msg.sender == agreement.dataService, @@ -583,17 +580,17 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC * @param _agreement The agreement data * @return isCollectable Whether the agreement can be collected from * @return collectionSeconds The valid collection duration in seconds (0 if not collectable) + * @return reason The reason why the agreement is not collectable (None if collectable) */ function _getCollectionInfo( AgreementData memory _agreement - ) private view returns (bool isCollectable, uint256 collectionSeconds) { + ) private view returns (bool, uint256, AgreementNotCollectableReason) { // Check if agreement is in collectable state - isCollectable = - _agreement.state == AgreementState.Accepted || + bool hasValidState = _agreement.state == AgreementState.Accepted || _agreement.state == AgreementState.CanceledByPayer; - if (!isCollectable) { - return (false, 0); + if (!hasValidState) { + return (false, 0, AgreementNotCollectableReason.InvalidAgreementState); } bool canceledOrElapsed = _agreement.state == AgreementState.CanceledByPayer || @@ -606,11 +603,14 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC uint256 collectionStart = _agreementCollectionStartAt(_agreement); if (collectionEnd < collectionStart) { - return (false, 0); + return (false, 0, AgreementNotCollectableReason.InvalidTemporalWindow); + } + + if (collectionStart == collectionEnd) { + return (false, 0, AgreementNotCollectableReason.ZeroCollectionSeconds); } - collectionSeconds = collectionEnd - collectionStart; - return (isCollectable, collectionSeconds); + return (true, collectionEnd - collectionStart, AgreementNotCollectableReason.None); } /** diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index d44284e9f..738a0415c 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -90,9 +90,9 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { bytes memory data = _generateCollectData(fuzzy.collectParams); bytes memory expectedErr = abi.encodeWithSelector( - IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + IRecurringCollector.RecurringCollectorAgreementNotCollectable.selector, fuzzy.collectParams.agreementId, - IRecurringCollector.AgreementState.NotAccepted + IRecurringCollector.AgreementNotCollectableReason.InvalidAgreementState ); vm.expectRevert(expectedErr); vm.prank(dataService); @@ -116,9 +116,9 @@ contract RecurringCollectorCollectTest is RecurringCollectorSharedTest { bytes memory data = _generateCollectData(collectParams); bytes memory expectedErr = abi.encodeWithSelector( - IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + IRecurringCollector.RecurringCollectorAgreementNotCollectable.selector, collectParams.agreementId, - IRecurringCollector.AgreementState.CanceledByServiceProvider + IRecurringCollector.AgreementNotCollectableReason.InvalidAgreementState ); vm.expectRevert(expectedErr); vm.prank(accepted.rca.dataService); diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index f9648e4fb..02f99be88 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -554,7 +554,7 @@ library IndexingAgreement { IndexingAgreementNotAuthorized(params.agreementId, params.indexer) ); // Get collection info from RecurringCollector (single source of truth for temporal logic) - (bool isCollectable, uint256 collectionSeconds) = _directory().recurringCollector().getCollectionInfo( + (bool isCollectable, uint256 collectionSeconds, ) = _directory().recurringCollector().getCollectionInfo( wrapper.collectorAgreement ); require(_isValid(wrapper) && isCollectable, IndexingAgreementNotCollectable(params.agreementId)); 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 index f41bdf976..3f7a5657c 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -58,6 +58,8 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ); _expectCollectCallAndEmit(data, indexerState, accepted, acceptedAgreementId, tokensCollected, entities, poi); + skip(1); // To make agreement collectable + subgraphService.collect( indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, @@ -215,6 +217,8 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA ); vm.expectRevert(expectedErr); + skip(1); // To make agreement collectable + subgraphService.collect( indexerState.addr, IGraphPayments.PaymentTypes.IndexingFee, From f65877e889c176457d08a9d3d88947535aa8ce63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 28 Nov 2025 17:35:40 -0300 Subject: [PATCH 20/59] feat(interfaces): remove staking extension from interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/horizon/IHorizonStaking.sol | 7 +- .../horizon/internal/IHorizonStakingBase.sol | 12 +- .../internal/IHorizonStakingExtension.sol | 215 ------------------ .../horizon/internal/IHorizonStakingMain.sol | 17 +- .../subgraph-service/IDisputeManager.sol | 54 +---- 5 files changed, 12 insertions(+), 293 deletions(-) delete mode 100644 packages/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol diff --git a/packages/interfaces/contracts/horizon/IHorizonStaking.sol b/packages/interfaces/contracts/horizon/IHorizonStaking.sol index 4e680a1e5..9b16ad368 100644 --- a/packages/interfaces/contracts/horizon/IHorizonStaking.sol +++ b/packages/interfaces/contracts/horizon/IHorizonStaking.sol @@ -5,15 +5,14 @@ pragma solidity ^0.8.22; import { IHorizonStakingTypes } from "./internal/IHorizonStakingTypes.sol"; import { IHorizonStakingMain } from "./internal/IHorizonStakingMain.sol"; import { IHorizonStakingBase } from "./internal/IHorizonStakingBase.sol"; -import { IHorizonStakingExtension } from "./internal/IHorizonStakingExtension.sol"; /** * @title Complete interface for the Horizon Staking contract * @author Edge & Node - * @notice This interface exposes all functions implemented by the {HorizonStaking} contract and its extension - * {HorizonStakingExtension} as well as the custom data types used by the contract. + * @notice This interface exposes all functions implemented by the {HorizonStaking} contract + * as well as the custom data types used by the contract. * @dev Use this interface to interact with the Horizon Staking contract. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ -interface IHorizonStaking is IHorizonStakingTypes, IHorizonStakingBase, IHorizonStakingMain, IHorizonStakingExtension {} +interface IHorizonStaking is IHorizonStakingTypes, IHorizonStakingBase, IHorizonStakingMain {} diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol index c48f20099..ccdcc1837 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol @@ -13,23 +13,13 @@ import { ILinkedList } from "./ILinkedList.sol"; /** * @title Interface for the {HorizonStakingBase} contract. * @author Edge & Node - * @notice Provides getters for {HorizonStaking} and {HorizonStakingExtension} storage variables. + * @notice Provides getters for {HorizonStaking} storage variables. * @dev Most functions operate over {HorizonStaking} provisions. To uniquely identify a provision * functions take `serviceProvider` and `verifier` addresses. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ interface IHorizonStakingBase { - /** - * @notice Emitted when a service provider stakes tokens. - * @dev TRANSITION PERIOD: After transition period move to IHorizonStakingMain. Temporarily it - * needs to be here since it's emitted by {_stake} which is used by both {HorizonStaking} - * and {HorizonStakingExtension}. - * @param serviceProvider The address of the service provider. - * @param tokens The amount of tokens staked. - */ - event HorizonStakeDeposited(address indexed serviceProvider, uint256 tokens); - /** * @notice Thrown when using an invalid thaw request type. */ diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol deleted file mode 100644 index d487b2eca..000000000 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol +++ /dev/null @@ -1,215 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -pragma solidity ^0.8.22; - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable gas-indexed-events - -import { IRewardsIssuer } from "../../contracts/rewards/IRewardsIssuer.sol"; - -/** - * @title Interface for {HorizonStakingExtension} contract. - * @author Edge & Node - * @notice Provides functions for managing legacy allocations. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -interface IHorizonStakingExtension is IRewardsIssuer { - /** - * @dev Allocate GRT tokens for the purpose of serving queries of a subgraph deployment - * An allocation is created in the allocate() function and closed in closeAllocation() - * @param indexer The indexer address - * @param subgraphDeploymentID The subgraph deployment ID - * @param tokens The amount of tokens allocated to the subgraph deployment - * @param createdAtEpoch The epoch when the allocation was created - * @param closedAtEpoch The epoch when the allocation was closed - * @param collectedFees The amount of collected fees for the allocation - * @param __DEPRECATED_effectiveAllocation Deprecated field. - * @param accRewardsPerAllocatedToken Snapshot used for reward calculation - * @param distributedRebates The amount of collected rebates that have been rebated - */ - struct Allocation { - address indexer; - bytes32 subgraphDeploymentID; - uint256 tokens; - uint256 createdAtEpoch; - uint256 closedAtEpoch; - uint256 collectedFees; - uint256 __DEPRECATED_effectiveAllocation; - uint256 accRewardsPerAllocatedToken; - uint256 distributedRebates; - } - - /** - * @dev Possible states an allocation can be. - * States: - * - Null = indexer == address(0) - * - Active = not Null && tokens > 0 - * - Closed = Active && closedAtEpoch != 0 - */ - enum AllocationState { - Null, - Active, - Closed - } - - /** - * @notice Emitted when `indexer` close an allocation in `epoch` for `allocationID`. - * An amount of `tokens` get unallocated from `subgraphDeploymentID`. - * This event also emits the POI (proof of indexing) submitted by the indexer. - * `isPublic` is true if the sender was someone other than the indexer. - * @param indexer The indexer address - * @param subgraphDeploymentID The subgraph deployment ID - * @param epoch The protocol epoch the allocation was closed on - * @param tokens The amount of tokens unallocated from the allocation - * @param allocationID The allocation identifier - * @param sender The address closing the allocation - * @param poi The proof of indexing submitted by the sender - * @param isPublic True if the allocation was force closed by someone other than the indexer/operator - */ - event AllocationClosed( - address indexed indexer, - bytes32 indexed subgraphDeploymentID, - uint256 epoch, - uint256 tokens, - address indexed allocationID, - address sender, - bytes32 poi, - bool isPublic - ); - - /** - * @notice Emitted when `indexer` collects a rebate on `subgraphDeploymentID` for `allocationID`. - * `epoch` is the protocol epoch the rebate was collected on - * The rebate is for `tokens` amount which are being provided by `assetHolder`; `queryFees` - * is the amount up for rebate after `curationFees` are distributed and `protocolTax` is burnt. - * `queryRebates` is the amount distributed to the `indexer` with `delegationFees` collected - * and sent to the delegation pool. - * @param assetHolder The address of the asset holder, the entity paying the query fees - * @param indexer The indexer address - * @param subgraphDeploymentID The subgraph deployment ID - * @param allocationID The allocation identifier - * @param epoch The protocol epoch the rebate was collected on - * @param tokens The amount of tokens collected - * @param protocolTax The amount of tokens burnt as protocol tax - * @param curationFees The amount of tokens distributed to the curation pool - * @param queryFees The amount of tokens collected as query fees - * @param queryRebates The amount of tokens distributed to the indexer - * @param delegationRewards The amount of tokens collected from the delegation pool - */ - event RebateCollected( - address assetHolder, - address indexed indexer, - bytes32 indexed subgraphDeploymentID, - address indexed allocationID, - uint256 epoch, - uint256 tokens, - uint256 protocolTax, - uint256 curationFees, - uint256 queryFees, - uint256 queryRebates, - uint256 delegationRewards - ); - - /** - * @notice Emitted when `indexer` was slashed for a total of `tokens` amount. - * Tracks `reward` amount of tokens given to `beneficiary`. - * @param indexer The indexer address - * @param tokens The amount of tokens slashed - * @param reward The amount of reward tokens to send to a beneficiary - * @param beneficiary The address of a beneficiary to receive a reward for the slashing - */ - event StakeSlashed(address indexed indexer, uint256 tokens, uint256 reward, address beneficiary); - - /** - * @notice Close an allocation and free the staked tokens. - * To be eligible for rewards a proof of indexing must be presented. - * Presenting a bad proof is subject to slashable condition. - * To opt out of rewards set _poi to 0x0 - * @param allocationID The allocation identifier - * @param poi Proof of indexing submitted for the allocated period - */ - function closeAllocation(address allocationID, bytes32 poi) external; - - /** - * @notice Collect and rebate query fees to the indexer - * This function will accept calls with zero tokens. - * We use an exponential rebate formula to calculate the amount of tokens to rebate to the indexer. - * This implementation allows collecting multiple times on the same allocation, keeping track of the - * total amount rebated, the total amount collected and compensating the indexer for the difference. - * @param tokens Amount of tokens to collect - * @param allocationID Allocation where the tokens will be assigned - */ - function collect(uint256 tokens, address allocationID) external; - - /** - * @notice Slash the indexer stake. Delegated tokens are not subject to slashing. - * Note that depending on the state of the indexer's stake, the slashed amount might be smaller than the - * requested slash amount. This can happen if the indexer has moved a significant part of their stake to - * a provision. Any outstanding slashing amount should be settled using Horizon's slash function - * {IHorizonStaking.slash}. - * @dev Can only be called by the slasher role. - * @param indexer Address of indexer to slash - * @param tokens Amount of tokens to slash from the indexer stake - * @param reward Amount of reward tokens to send to a beneficiary - * @param beneficiary Address of a beneficiary to receive a reward for the slashing - */ - function legacySlash(address indexer, uint256 tokens, uint256 reward, address beneficiary) external; - - /** - * @notice (Legacy) Return true if operator is allowed for the service provider on the subgraph data service. - * @param operator Address of the operator - * @param indexer Address of the service provider - * @return True if operator is allowed for indexer, false otherwise - */ - function isOperator(address operator, address indexer) external view returns (bool); - - /** - * @notice Getter that returns if an indexer has any stake. - * @param indexer Address of the indexer - * @return True if indexer has staked tokens - */ - function hasStake(address indexer) external view returns (bool); - - /** - * @notice Get the total amount of tokens staked by the indexer. - * @param indexer Address of the indexer - * @return Amount of tokens staked by the indexer - */ - function getIndexerStakedTokens(address indexer) external view returns (uint256); - - /** - * @notice Return the allocation by ID. - * @param allocationID Address used as allocation identifier - * @return Allocation data - */ - function getAllocation(address allocationID) external view returns (Allocation memory); - - /** - * @notice Return the current state of an allocation - * @param allocationID Allocation identifier - * @return AllocationState enum with the state of the allocation - */ - function getAllocationState(address allocationID) external view returns (AllocationState); - - /** - * @notice Return if allocationID is used. - * @param allocationID Address used as signer by the indexer for an allocation - * @return True if allocationID already used - */ - function isAllocation(address allocationID) external view returns (bool); - - /** - * @notice Return the time in blocks to unstake - * Deprecated, now enforced by each data service (verifier) - * @return Thawing period in blocks - */ - function __DEPRECATED_getThawingPeriod() external view returns (uint64); - - /** - * @notice Return the address of the subgraph data service. - * @dev TRANSITION PERIOD: After transition period move to main HorizonStaking contract - * @return Address of the subgraph data service - */ - function getSubgraphService() external view returns (address); -} diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index 19c1e1cf8..e0a5c082d 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -12,10 +12,6 @@ import { IHorizonStakingTypes } from "./IHorizonStakingTypes.sol"; * @title Inferface for the {HorizonStaking} contract. * @author Edge & Node * @notice Provides functions for managing stake, provisions, delegations, and slashing. - * @dev Note that this interface only includes the functions implemented by {HorizonStaking} contract, - * and not those implemented by {HorizonStakingExtension}. - * Do not use this interface to interface with the {HorizonStaking} contract, use {IHorizonStaking} for - * the complete interface. * @dev Most functions operate over {HorizonStaking} provisions. To uniquely identify a provision * functions take `serviceProvider` and `verifier` addresses. * @dev TRANSITION PERIOD: After transition period rename to IHorizonStaking. @@ -25,6 +21,13 @@ import { IHorizonStakingTypes } from "./IHorizonStakingTypes.sol"; interface IHorizonStakingMain { // -- Events: stake -- + /** + * @notice Emitted when a service provider stakes tokens. + * @param serviceProvider The address of the service provider. + * @param tokens The amount of tokens staked. + */ + event HorizonStakeDeposited(address indexed serviceProvider, uint256 tokens); + /** * @notice Emitted when a service provider unstakes tokens during the transition period. * @param serviceProvider The address of the service provider @@ -1002,10 +1005,4 @@ interface IHorizonStakingMain { * @return Whether the operator is authorized or not */ function isAuthorized(address serviceProvider, address verifier, address operator) external view returns (bool); - - /** - * @notice Get the address of the staking extension. - * @return The address of the staking extension - */ - function getStakingExtension() external view returns (address); } diff --git a/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol b/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol index da1324cc9..c7a7dc2dd 100644 --- a/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol +++ b/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol @@ -20,7 +20,7 @@ interface IDisputeManager { Null, IndexingDispute, QueryDispute, - LegacyDispute + __DEPRECATED_LegacyDispute } /// @notice Status of a dispute @@ -143,25 +143,6 @@ interface IDisputeManager { uint256 cancellableAt ); - /** - * @notice Emitted when a legacy dispute is created for `allocationId` and `fisherman`. - * The event emits the amount of `tokensSlash` to slash and `tokensRewards` to reward the fisherman. - * @param disputeId The dispute id - * @param indexer The indexer address - * @param fisherman The fisherman address to be credited with the rewards - * @param allocationId The allocation id - * @param tokensSlash The amount of tokens to slash - * @param tokensRewards The amount of tokens to reward the fisherman - */ - event LegacyDisputeCreated( - bytes32 indexed disputeId, - address indexed indexer, - address indexed fisherman, - address allocationId, - uint256 tokensSlash, - uint256 tokensRewards - ); - /** * @notice Emitted when arbitrator accepts a `disputeId` to `indexer` created by `fisherman`. * The event emits the amount `tokens` transferred to the fisherman, the deposit plus reward. @@ -469,39 +450,6 @@ interface IDisputeManager { */ function createIndexingDispute(address allocationId, bytes32 poi, uint256 blockNumber) external returns (bytes32); - /** - * @notice Creates and auto-accepts a legacy dispute. - * This disputes can be created to settle outstanding slashing amounts with an indexer that has been - * "legacy slashed" during or shortly after the transition period. See {HorizonStakingExtension.legacySlash} - * for more details. - * - * Note that this type of dispute: - * - can only be created by the arbitrator - * - does not require a bond - * - is automatically accepted when created - * - * Additionally, note that this type of disputes allow the arbitrator to directly set the slash and rewards - * amounts, bypassing the usual mechanisms that impose restrictions on those. This is done to give arbitrators - * maximum flexibility to ensure outstanding slashing amounts are settled fairly. This function needs to be removed - * after the transition period. - * - * Requirements: - * - Indexer must have been legacy slashed during or shortly after the transition period - * - Indexer must have provisioned funds to the Subgraph Service - * - * @param allocationId The allocation to dispute - * @param fisherman The fisherman address to be credited with the rewards - * @param tokensSlash The amount of tokens to slash - * @param tokensRewards The amount of tokens to reward the fisherman - * @return The dispute id - */ - function createAndAcceptLegacyDispute( - address allocationId, - address fisherman, - uint256 tokensSlash, - uint256 tokensRewards - ) external returns (bytes32); - // -- Arbitrator -- /** From 714b32cd25097ac3a3af46cd97b170342de5842c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 28 Nov 2025 17:41:18 -0300 Subject: [PATCH 21/59] chore: more interface removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../horizon/internal/IHorizonStakingMain.sol | 72 +------------------ 1 file changed, 2 insertions(+), 70 deletions(-) diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index e0a5c082d..4add15391 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -14,7 +14,6 @@ import { IHorizonStakingTypes } from "./IHorizonStakingTypes.sol"; * @notice Provides functions for managing stake, provisions, delegations, and slashing. * @dev Most functions operate over {HorizonStaking} provisions. To uniquely identify a provision * functions take `serviceProvider` and `verifier` addresses. - * @dev TRANSITION PERIOD: After transition period rename to IHorizonStaking. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ @@ -28,21 +27,6 @@ interface IHorizonStakingMain { */ event HorizonStakeDeposited(address indexed serviceProvider, uint256 tokens); - /** - * @notice Emitted when a service provider unstakes tokens during the transition period. - * @param serviceProvider The address of the service provider - * @param tokens The amount of tokens now locked (including previously locked tokens) - * @param until The block number until the stake is locked - */ - event HorizonStakeLocked(address indexed serviceProvider, uint256 tokens, uint256 until); - - /** - * @notice Emitted when a service provider withdraws tokens during the transition period. - * @param serviceProvider The address of the service provider - * @param tokens The amount of tokens withdrawn - */ - event HorizonStakeWithdrawn(address indexed serviceProvider, uint256 tokens); - // -- Events: provision -- /** @@ -327,12 +311,6 @@ interface IHorizonStakingMain { */ event AllowedLockedVerifierSet(address indexed verifier, bool allowed); - /** - * @notice Emitted when the legacy global thawing period is set to zero. - * @dev This marks the end of the transition period. - */ - event ThawingPeriodCleared(); - /** * @notice Emitted when the delegation slashing global flag is set. */ @@ -376,13 +354,6 @@ interface IHorizonStakingMain { */ error HorizonStakingNotAuthorized(address serviceProvider, address verifier, address caller); - /** - * @notice Thrown when attempting to create a provision with a verifier other than the - * subgraph data service. This restriction only applies during the transition period. - * @param verifier The verifier address - */ - error HorizonStakingInvalidVerifier(address verifier); - /** * @notice Thrown when attempting to create a provision with an invalid maximum verifier cut. * @param maxVerifierCut The maximum verifier cut @@ -410,14 +381,6 @@ interface IHorizonStakingMain { */ error HorizonStakingInsufficientIdleStake(uint256 tokens, uint256 minTokens); - /** - * @notice Thrown during the transition period when the service provider has insufficient stake to - * cover their existing legacy allocations. - * @param tokens The actual token amount - * @param minTokens The minimum required token amount - */ - error HorizonStakingInsufficientStakeForLegacyAllocations(uint256 tokens, uint256 minTokens); - // -- Errors: delegation -- /** @@ -488,13 +451,6 @@ interface IHorizonStakingMain { error HorizonStakingNothingToWithdraw(); // -- Errors: misc -- - /** - * @notice Thrown during the transition period when attempting to withdraw tokens that are still thawing. - * @dev Note this thawing refers to the global thawing period applied to legacy allocated tokens, - * it does not refer to thaw requests. - * @param until The block number until the stake is locked - */ - error HorizonStakingStillThawing(uint256 until); /** * @notice Thrown when a service provider attempts to operate on verifiers that are not allowed. @@ -574,40 +530,24 @@ interface IHorizonStakingMain { /** * @notice Move idle stake back to the owner's account. - * Stake is removed from the protocol: - * - During the transition period it's locked for a period of time before it can be withdrawn - * by calling {withdraw}. - * - After the transition period it's immediately withdrawn. - * Note that after the transition period if there are tokens still locked they will have to be - * withdrawn by calling {withdraw}. + * Stake is immediately removed from the protocol. * @dev Requirements: * - `_tokens` cannot be zero. * - `_serviceProvider` must have enough idle stake to cover the staking amount and any * legacy allocation. * - * Emits a {HorizonStakeLocked} event during the transition period. - * Emits a {HorizonStakeWithdrawn} event after the transition period. + * Emits a {HorizonStakeWithdrawn} event. * * @param tokens Amount of tokens to unstake */ function unstake(uint256 tokens) external; - /** - * @notice Withdraw service provider tokens once the thawing period (initiated by {unstake}) has passed. - * All thawed tokens are withdrawn. - * @dev This is only needed during the transition period while we still have - * a global lock. After that, unstake() will automatically withdraw. - */ - function withdraw() external; - /** * @notice Provision stake to a verifier. The tokens will be locked with a thawing period * and will be slashable by the verifier. This is the main mechanism to provision stake to a data * service, where the data service is the verifier. * This function can be called by the service provider or by an operator authorized by the provider * for this specific verifier. - * @dev During the transition period, only the subgraph data service can be used as a verifier. This - * prevents an escape hatch for legacy allocation stake. * @dev Requirements: * - `tokens` cannot be zero. * - The `serviceProvider` must have enough idle stake to cover the tokens to provision. @@ -974,14 +914,6 @@ interface IHorizonStakingMain { */ function setDelegationSlashingEnabled() external; - /** - * @notice Clear the legacy global thawing period. - * This signifies the end of the transition period, after which no legacy allocations should be left. - * @dev This function can only be called by the contract governor. - * @dev Emits a {ThawingPeriodCleared} event. - */ - function clearThawingPeriod() external; - /** * @notice Sets the global maximum thawing period allowed for provisions. * @param maxThawingPeriod The new maximum thawing period, in seconds From 392047cb3497d40151eac808202697bd150f8c13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 28 Nov 2025 18:23:46 -0300 Subject: [PATCH 22/59] feat: clean up post horizon on horizon contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/staking/HorizonStaking.sol | 100 +--- .../contracts/staking/HorizonStakingBase.sol | 25 +- .../staking/HorizonStakingExtension.sol | 484 ------------------ .../staking/HorizonStakingStorage.sol | 5 +- .../staking/libraries/ExponentialRebates.sol | 68 --- .../contracts/utilities/GraphDirectory.sol | 25 +- .../horizon/internal/IHorizonStakingMain.sol | 30 +- .../subgraph-service/ISubgraphService.sol | 10 - .../toolshed/internal/IAllocationManager.sol | 6 - 9 files changed, 50 insertions(+), 703 deletions(-) delete mode 100644 packages/horizon/contracts/staking/HorizonStakingExtension.sol delete mode 100644 packages/horizon/contracts/staking/libraries/ExponentialRebates.sol diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 73f48c354..5384e7698 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -9,7 +9,6 @@ pragma solidity 0.8.27; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; @@ -28,9 +27,6 @@ import { HorizonStakingBase } from "./HorizonStakingBase.sol"; * @dev Implements the {IHorizonStakingMain} interface. * @dev This is the main Staking contract in The Graph protocol after the Horizon upgrade. * It is designed to be deployed as an upgrade to the L2Staking contract from the legacy contracts package. - * @dev It uses a {HorizonStakingExtension} contract to implement the full {IHorizonStaking} interface through delegatecalls. - * This is due to the contract size limit on Arbitrum (24kB). The extension contract implements functionality to support - * the legacy staking functions. It can be eventually removed without affecting the main staking contract. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ @@ -407,21 +403,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { uint256 tokensVerifier, address verifierDestination ) external override notPaused { - // TRANSITION PERIOD: remove after the transition period - // Check if sender is authorized to slash on the deprecated list - if (__DEPRECATED_slashers[msg.sender]) { - // Forward call to staking extension - // solhint-disable-next-line avoid-low-level-calls - (bool success, ) = STAKING_EXTENSION_ADDRESS.delegatecall( - abi.encodeCall( - IHorizonStakingExtension.legacySlash, - (serviceProvider, tokens, tokensVerifier, verifierDestination) - ) - ); - require(success, HorizonStakingLegacySlashFailed()); - return; - } - address verifier = msg.sender; Provision storage prov = _provisions[serviceProvider][verifier]; DelegationPoolInternal storage pool = _getDelegationPool(serviceProvider, verifier); @@ -538,12 +519,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { emit DelegationSlashingEnabled(); } - /// @inheritdoc IHorizonStakingMain - function clearThawingPeriod() external override onlyGovernor { - __DEPRECATED_thawingPeriod = 0; - emit ThawingPeriodCleared(); - } - /// @inheritdoc IHorizonStakingMain function setMaxThawingPeriod(uint64 maxThawingPeriod) external override onlyGovernor { _maxThawingPeriod = maxThawingPeriod; @@ -569,17 +544,19 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { } /* - * GETTERS + * PRIVATE FUNCTIONS */ - /// @inheritdoc IHorizonStakingMain - function getStakingExtension() external view override returns (address) { - return STAKING_EXTENSION_ADDRESS; - } - - /* - * PRIVATE FUNCTIONS + /** + * @notice Deposit tokens into the service provider stake. + * Emits a {HorizonStakeDeposited} event. + * @param _serviceProvider The address of the service provider. + * @param _tokens The amount of tokens to deposit. */ + function _stake(address _serviceProvider, uint256 _tokens) internal { + _serviceProviders[_serviceProvider].tokensStaked = _serviceProviders[_serviceProvider].tokensStaked + _tokens; + emit HorizonStakeDeposited(_serviceProvider, _tokens); + } /** * @notice Deposit tokens on the service provider stake, on behalf of the service provider. @@ -599,12 +576,7 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { /** * @notice Move idle stake back to the owner's account. - * Stake is removed from the protocol: - * - During the transition period it's locked for a period of time before it can be withdrawn - * by calling {withdraw}. - * - After the transition period it's immediately withdrawn. - * Note that after the transition period if there are tokens still locked they will have to be - * withdrawn by calling {withdraw}. + * Stake is immediately removed from the protocol. * @param _tokens Amount of tokens to unstake */ function _unstake(uint256 _tokens) private { @@ -616,43 +588,18 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { ServiceProviderInternal storage sp = _serviceProviders[serviceProvider]; uint256 stakedTokens = sp.tokensStaked; - // This is also only during the transition period: we need - // to ensure tokens stay locked after closing legacy allocations. - // After sufficient time (56 days?) we should remove the closeAllocation function - // and set the thawing period to 0. - uint256 lockingPeriod = __DEPRECATED_thawingPeriod; - if (lockingPeriod == 0) { - sp.tokensStaked = stakedTokens - _tokens; - _graphToken().pushTokens(serviceProvider, _tokens); - emit HorizonStakeWithdrawn(serviceProvider, _tokens); - } else { - // Before locking more tokens, withdraw any unlocked ones if possible - if (sp.__DEPRECATED_tokensLocked != 0 && block.number >= sp.__DEPRECATED_tokensLockedUntil) { - _withdraw(serviceProvider); - } - // TRANSITION PERIOD: remove after the transition period - // Take into account period averaging for multiple unstake requests - if (sp.__DEPRECATED_tokensLocked > 0) { - lockingPeriod = MathUtils.weightedAverageRoundingUp( - MathUtils.diffOrZero(sp.__DEPRECATED_tokensLockedUntil, block.number), // Remaining thawing period - sp.__DEPRECATED_tokensLocked, // Weighted by remaining unstaked tokens - lockingPeriod, // Thawing period - _tokens // Weighted by new tokens to unstake - ); - } - - // Update balances - sp.__DEPRECATED_tokensLocked = sp.__DEPRECATED_tokensLocked + _tokens; - sp.__DEPRECATED_tokensLockedUntil = block.number + lockingPeriod; - emit HorizonStakeLocked(serviceProvider, sp.__DEPRECATED_tokensLocked, sp.__DEPRECATED_tokensLockedUntil); - } + sp.tokensStaked = stakedTokens - _tokens; + _graphToken().pushTokens(serviceProvider, _tokens); + emit HorizonStakeWithdrawn(serviceProvider, _tokens); } /** * @notice Withdraw service provider tokens once the thawing period (initiated by {unstake}) has passed. * All thawed tokens are withdrawn. - * @dev TRANSITION PERIOD: This is only needed during the transition period while we still have - * a global lock. After that, unstake() will automatically withdraw. + * This function is for backwards compatibility with the legacy staking contract. + * It only allows withdrawing tokens unstaked before horizon upgrade. + * @dev This function can't be removed in case there are still pre-horizon unstakes. + * Note that it's assumed unstakes have already passed their thawing period. * @param _serviceProvider Address of service provider to withdraw funds from */ function _withdraw(address _serviceProvider) private { @@ -660,10 +607,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { ServiceProviderInternal storage sp = _serviceProviders[_serviceProvider]; uint256 tokensToWithdraw = sp.__DEPRECATED_tokensLocked; require(tokensToWithdraw != 0, HorizonStakingInvalidZeroTokens()); - require( - block.number >= sp.__DEPRECATED_tokensLockedUntil, - HorizonStakingStillThawing(sp.__DEPRECATED_tokensLockedUntil) - ); // Reset locked tokens sp.__DEPRECATED_tokensLocked = 0; @@ -683,8 +626,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { * service, where the data service is the verifier. * This function can be called by the service provider or by an operator authorized by the provider * for this specific verifier. - * @dev TRANSITION PERIOD: During the transition period, only the subgraph data service can be used as a verifier. This - * prevents an escape hatch for legacy allocation stake. * @param _serviceProvider The service provider address * @param _tokens The amount of tokens that will be locked and slashable * @param _verifier The verifier address for which the tokens are provisioned (who will be able to slash the tokens) @@ -699,11 +640,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { uint64 _thawingPeriod ) private { require(_tokens > 0, HorizonStakingInvalidZeroTokens()); - // TRANSITION PERIOD: Remove this after the transition period - it prevents an early escape hatch for legacy allocations - require( - _verifier == SUBGRAPH_DATA_SERVICE_ADDRESS || __DEPRECATED_thawingPeriod == 0, - HorizonStakingInvalidVerifier(_verifier) - ); require(PPMMath.isValidPPM(_maxVerifierCut), HorizonStakingInvalidMaxVerifierCut(_maxVerifierCut)); require( _thawingPeriod <= _maxThawingPeriod, diff --git a/packages/horizon/contracts/staking/HorizonStakingBase.sol b/packages/horizon/contracts/staking/HorizonStakingBase.sol index 9c52a2171..520bd4dd6 100644 --- a/packages/horizon/contracts/staking/HorizonStakingBase.sol +++ b/packages/horizon/contracts/staking/HorizonStakingBase.sol @@ -23,9 +23,7 @@ import { HorizonStakingV1Storage } from "./HorizonStakingStorage.sol"; * @author Edge & Node * @notice This contract is the base staking contract implementing storage getters for both internal * and external use. - * @dev Implementation of the {IHorizonStakingBase} interface. - * @dev It's meant to be inherited by the {HorizonStaking} and {HorizonStakingExtension} - * contracts so some internal functions are also included here. + * @dev Implementation of the {IHorizonStakingBase} interface, meant to be inherited by {HorizonStaking}. * @custom:security-contact Please email security+contracts@thegraph.com if you find any * bugs. We may have an active bug bounty program. */ @@ -218,31 +216,18 @@ abstract contract HorizonStakingBase is return _delegationSlashingEnabled; } - /** - * @notice Deposit tokens into the service provider stake. - * @dev TRANSITION PERIOD: After transition period move to IHorizonStakingMain. Temporarily it - * needs to be here since it's used by both {HorizonStaking} and {HorizonStakingExtension}. - * - * Emits a {HorizonStakeDeposited} event. - * @param _serviceProvider The address of the service provider. - * @param _tokens The amount of tokens to deposit. - */ - function _stake(address _serviceProvider, uint256 _tokens) internal { - _serviceProviders[_serviceProvider].tokensStaked = _serviceProviders[_serviceProvider].tokensStaked + _tokens; - emit HorizonStakeDeposited(_serviceProvider, _tokens); - } - /** * @notice Gets the service provider's idle stake which is the stake that is not being * used for any provision. Note that this only includes service provider's self stake. - * @dev Note that the calculation considers tokens that were locked in the legacy staking contract. - * @dev TRANSITION PERIOD: update the calculation after the transition period. + * @dev Note that the calculation: + * - assumes tokens that were allocated to a subgraph deployment pre-horizon were all unallocated. + * - considers tokens that were locked in the legacy staking contract and never withdrawn. + * * @param _serviceProvider The address of the service provider. * @return The amount of tokens that are idle. */ function _getIdleStake(address _serviceProvider) internal view returns (uint256) { uint256 tokensUsed = _serviceProviders[_serviceProvider].tokensProvisioned + - _serviceProviders[_serviceProvider].__DEPRECATED_tokensAllocated + _serviceProviders[_serviceProvider].__DEPRECATED_tokensLocked; uint256 tokensStaked = _serviceProviders[_serviceProvider].tokensStaked; return tokensStaked > tokensUsed ? tokensStaked - tokensUsed : 0; diff --git a/packages/horizon/contracts/staking/HorizonStakingExtension.sol b/packages/horizon/contracts/staking/HorizonStakingExtension.sol deleted file mode 100644 index b1adcde0d..000000000 --- a/packages/horizon/contracts/staking/HorizonStakingExtension.sol +++ /dev/null @@ -1,484 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -pragma solidity 0.8.27; - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable function-max-lines, gas-strict-inequalities - -import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol"; -import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; -import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsIssuer.sol"; - -import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; -import { MathUtils } from "../libraries/MathUtils.sol"; -import { ExponentialRebates } from "./libraries/ExponentialRebates.sol"; -import { PPMMath } from "../libraries/PPMMath.sol"; - -import { HorizonStakingBase } from "./HorizonStakingBase.sol"; - -/** - * @title Horizon Staking extension contract - * @author Edge & Node - * @notice The {HorizonStakingExtension} contract implements the legacy functionality required to support the transition - * to the Horizon Staking contract. It allows indexers to close allocations and collect pending query fees, but it - * does not allow for the creation of new allocations. This should allow indexers to migrate to a subgraph data service - * without losing rewards or having service interruptions. - * @dev TRANSITION PERIOD: Once the transition period passes this contract can be removed (note that an upgrade to the - * RewardsManager will also be required). It's expected the transition period to last for at least a full allocation cycle - * (28 epochs). - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -contract HorizonStakingExtension is HorizonStakingBase, IHorizonStakingExtension { - using TokenUtils for IGraphToken; - using PPMMath for uint256; - - /** - * @dev Check if the caller is the slasher. - */ - modifier onlySlasher() { - require(__DEPRECATED_slashers[msg.sender], "!slasher"); - _; - } - - /** - * @notice The staking contract is upgradeable however we still use the constructor to set a few immutable variables - * @param controller The address of the Graph controller contract - * @param subgraphDataServiceAddress The address of the subgraph data service - */ - constructor( - address controller, - address subgraphDataServiceAddress - ) HorizonStakingBase(controller, subgraphDataServiceAddress) {} - - /// @inheritdoc IHorizonStakingExtension - function closeAllocation(address allocationID, bytes32 poi) external override notPaused { - _closeAllocation(allocationID, poi); - } - - /// @inheritdoc IHorizonStakingExtension - function collect(uint256 tokens, address allocationID) external override notPaused { - // Allocation identifier validation - require(allocationID != address(0), "!alloc"); - - // Allocation must exist - AllocationState allocState = _getAllocationState(allocationID); - require(allocState != AllocationState.Null, "!collect"); - - // If the query fees are zero, we don't want to revert - // but we also don't need to do anything, so just return - if (tokens == 0) { - return; - } - - Allocation storage alloc = __DEPRECATED_allocations[allocationID]; - bytes32 subgraphDeploymentID = alloc.subgraphDeploymentID; - - uint256 queryFees = tokens; // Tokens collected from the channel - uint256 protocolTax = 0; // Tokens burnt as protocol tax - uint256 curationFees = 0; // Tokens distributed to curators as curation fees - uint256 queryRebates = 0; // Tokens to distribute to indexer - uint256 delegationRewards = 0; // Tokens to distribute to delegators - - { - // -- Pull tokens from the sender -- - _graphToken().pullTokens(msg.sender, queryFees); - - // -- Collect protocol tax -- - protocolTax = _collectTax(queryFees, __DEPRECATED_protocolPercentage); - queryFees = queryFees - protocolTax; - - // -- Collect curation fees -- - // Only if the subgraph deployment is curated - curationFees = _collectCurationFees(subgraphDeploymentID, queryFees, __DEPRECATED_curationPercentage); - queryFees = queryFees - curationFees; - - // -- Process rebate reward -- - // Using accumulated fees and subtracting previously distributed rebates - // allows for multiple vouchers to be collected while following the rebate formula - alloc.collectedFees = alloc.collectedFees + queryFees; - - // No rebates if indexer has no stake or if lambda is zero - uint256 newRebates = (alloc.tokens == 0 || __DEPRECATED_lambdaNumerator == 0) - ? 0 - : ExponentialRebates.exponentialRebates( - alloc.collectedFees, - alloc.tokens, - __DEPRECATED_alphaNumerator, - __DEPRECATED_alphaDenominator, - __DEPRECATED_lambdaNumerator, - __DEPRECATED_lambdaDenominator - ); - - // -- Ensure rebates to distribute are within bounds -- - // Indexers can become under or over rebated if rebate parameters (alpha, lambda) - // change between successive collect calls for the same allocation - - // Ensure rebates to distribute are not negative (indexer is over-rebated) - queryRebates = MathUtils.diffOrZero(newRebates, alloc.distributedRebates); - - // Ensure rebates to distribute are not greater than available (indexer is under-rebated) - queryRebates = MathUtils.min(queryRebates, queryFees); - - // -- Burn rebates remanent -- - _graphToken().burnTokens(queryFees - queryRebates); - - // -- Distribute rebates -- - if (queryRebates > 0) { - alloc.distributedRebates = alloc.distributedRebates + queryRebates; - - // -- Collect delegation rewards into the delegation pool -- - delegationRewards = _collectDelegationQueryRewards(alloc.indexer, queryRebates); - queryRebates = queryRebates - delegationRewards; - - // -- Transfer or restake rebates -- - _sendRewards(queryRebates, alloc.indexer, __DEPRECATED_rewardsDestination[alloc.indexer] == address(0)); - } - } - - emit RebateCollected( - msg.sender, - alloc.indexer, - subgraphDeploymentID, - allocationID, - _graphEpochManager().currentEpoch(), - tokens, - protocolTax, - curationFees, - queryFees, - queryRebates, - delegationRewards - ); - } - - /// @inheritdoc IHorizonStakingExtension - function legacySlash( - address indexer, - uint256 tokens, - uint256 reward, - address beneficiary - ) external override onlySlasher notPaused { - ServiceProviderInternal storage indexerStake = _serviceProviders[indexer]; - - // Only able to slash a non-zero number of tokens - require(tokens > 0, "!tokens"); - - // Rewards comes from tokens slashed balance - require(tokens >= reward, "rewards>slash"); - - // Cannot slash stake of an indexer without any or enough stake - require(indexerStake.tokensStaked > 0, "!stake"); - require(tokens <= indexerStake.tokensStaked, "slash>stake"); - - // Validate beneficiary of slashed tokens - require(beneficiary != address(0), "!beneficiary"); - - // Slashing tokens that are already provisioned would break provision accounting, we need to limit - // the slash amount. This can be compensated for, by slashing with the main slash function if needed. - uint256 slashableStake = indexerStake.tokensStaked - indexerStake.tokensProvisioned; - if (slashableStake == 0) { - emit StakeSlashed(indexer, 0, 0, beneficiary); - return; - } - if (tokens > slashableStake) { - reward = (reward * slashableStake) / tokens; - tokens = slashableStake; - } - - // Slashing more tokens than freely available (over allocation condition) - // Unlock locked tokens to avoid the indexer to withdraw them - uint256 tokensUsed = indexerStake.__DEPRECATED_tokensAllocated + indexerStake.__DEPRECATED_tokensLocked; - uint256 tokensAvailable = tokensUsed > indexerStake.tokensStaked ? 0 : indexerStake.tokensStaked - tokensUsed; - if (tokens > tokensAvailable && indexerStake.__DEPRECATED_tokensLocked > 0) { - uint256 tokensOverAllocated = tokens - tokensAvailable; - uint256 tokensToUnlock = MathUtils.min(tokensOverAllocated, indexerStake.__DEPRECATED_tokensLocked); - indexerStake.__DEPRECATED_tokensLocked = indexerStake.__DEPRECATED_tokensLocked - tokensToUnlock; - if (indexerStake.__DEPRECATED_tokensLocked == 0) { - indexerStake.__DEPRECATED_tokensLockedUntil = 0; - } - } - - // Remove tokens to slash from the stake - indexerStake.tokensStaked = indexerStake.tokensStaked - tokens; - - // -- Interactions -- - - // Set apart the reward for the beneficiary and burn remaining slashed stake - _graphToken().burnTokens(tokens - reward); - - // Give the beneficiary a reward for slashing - _graphToken().pushTokens(beneficiary, reward); - - emit StakeSlashed(indexer, tokens, reward, beneficiary); - } - - /// @inheritdoc IHorizonStakingExtension - function isAllocation(address allocationID) external view override returns (bool) { - return _getAllocationState(allocationID) != AllocationState.Null; - } - - /// @inheritdoc IHorizonStakingExtension - function getAllocation(address allocationID) external view override returns (Allocation memory) { - return __DEPRECATED_allocations[allocationID]; - } - - /// @inheritdoc IRewardsIssuer - function getAllocationData( - address allocationID - ) external view override returns (bool, address, bytes32, uint256, uint256, uint256) { - Allocation memory allo = __DEPRECATED_allocations[allocationID]; - bool isActive = _getAllocationState(allocationID) == AllocationState.Active; - return (isActive, allo.indexer, allo.subgraphDeploymentID, allo.tokens, allo.accRewardsPerAllocatedToken, 0); - } - - /// @inheritdoc IHorizonStakingExtension - function getAllocationState(address allocationID) external view override returns (AllocationState) { - return _getAllocationState(allocationID); - } - - /// @inheritdoc IRewardsIssuer - function getSubgraphAllocatedTokens(bytes32 subgraphDeploymentID) external view override returns (uint256) { - return __DEPRECATED_subgraphAllocations[subgraphDeploymentID]; - } - - /// @inheritdoc IHorizonStakingExtension - function getIndexerStakedTokens(address indexer) external view override returns (uint256) { - return _serviceProviders[indexer].tokensStaked; - } - - /// @inheritdoc IHorizonStakingExtension - function getSubgraphService() external view override returns (address) { - return SUBGRAPH_DATA_SERVICE_ADDRESS; - } - - /// @inheritdoc IHorizonStakingExtension - function hasStake(address indexer) external view override returns (bool) { - return _serviceProviders[indexer].tokensStaked > 0; - } - - /// @inheritdoc IHorizonStakingExtension - function __DEPRECATED_getThawingPeriod() external view returns (uint64) { - return __DEPRECATED_thawingPeriod; - } - - /// @inheritdoc IHorizonStakingExtension - function isOperator(address operator, address serviceProvider) public view override returns (bool) { - return _legacyOperatorAuth[serviceProvider][operator]; - } - - /** - * @notice Collect tax to burn for an amount of tokens - * @param _tokens Total tokens received used to calculate the amount of tax to collect - * @param _percentage Percentage of tokens to burn as tax - * @return Amount of tax charged - */ - function _collectTax(uint256 _tokens, uint256 _percentage) private returns (uint256) { - uint256 tax = _tokens.mulPPMRoundUp(_percentage); - _graphToken().burnTokens(tax); // Burn tax if any - return tax; - } - - /** - * @notice Triggers an update of rewards due to a change in allocations - * @param _subgraphDeploymentID Subgraph deployment updated - */ - function _updateRewards(bytes32 _subgraphDeploymentID) private { - _graphRewardsManager().onSubgraphAllocationUpdate(_subgraphDeploymentID); - } - - /** - * @notice Assign rewards for the closed allocation to indexer and delegators - * @param _allocationID Allocation - * @param _indexer Address of the indexer that did the allocation - */ - function _distributeRewards(address _allocationID, address _indexer) private { - // Automatically triggers update of rewards snapshot as allocation will change - // after this call. Take rewards mint tokens for the Staking contract to distribute - // between indexer and delegators - uint256 totalRewards = _graphRewardsManager().takeRewards(_allocationID); - if (totalRewards == 0) { - return; - } - - // Calculate delegation rewards and add them to the delegation pool - uint256 delegationRewards = _collectDelegationIndexingRewards(_indexer, totalRewards); - uint256 indexerRewards = totalRewards - delegationRewards; - - // Send the indexer rewards - _sendRewards(indexerRewards, _indexer, __DEPRECATED_rewardsDestination[_indexer] == address(0)); - } - - /** - * @notice Send rewards to the appropriate destination - * @param _tokens Number of rewards tokens - * @param _beneficiary Address of the beneficiary of rewards - * @param _restake Whether to restake or not - */ - function _sendRewards(uint256 _tokens, address _beneficiary, bool _restake) private { - if (_tokens == 0) return; - - if (_restake) { - // Restake to place fees into the indexer stake - _stake(_beneficiary, _tokens); - } else { - // Transfer funds to the beneficiary's designated rewards destination if set - address destination = __DEPRECATED_rewardsDestination[_beneficiary]; - _graphToken().pushTokens(destination == address(0) ? _beneficiary : destination, _tokens); - } - } - - /** - * @notice Close an allocation and free the staked tokens - * @param _allocationID The allocation identifier - * @param _poi Proof of indexing submitted for the allocated period - */ - function _closeAllocation(address _allocationID, bytes32 _poi) private { - // Allocation must exist and be active - AllocationState allocState = _getAllocationState(_allocationID); - require(allocState == AllocationState.Active, "!active"); - - // Get allocation - Allocation memory alloc = __DEPRECATED_allocations[_allocationID]; - - // Validate that an allocation cannot be closed before one epoch - alloc.closedAtEpoch = _graphEpochManager().currentEpoch(); - uint256 epochs = MathUtils.diffOrZero(alloc.closedAtEpoch, alloc.createdAtEpoch); - - // Indexer or operator can close an allocation - // Anyone is allowed to close ONLY under two concurrent conditions - // - After maxAllocationEpochs passed - // - When the allocation is for non-zero amount of tokens - bool isIndexerOrOperator = msg.sender == alloc.indexer || isOperator(msg.sender, alloc.indexer); - if (epochs <= __DEPRECATED_maxAllocationEpochs || alloc.tokens == 0) { - require(isIndexerOrOperator, "!auth"); - } - - // -- Rewards Distribution -- - - // Process non-zero-allocation rewards tracking - if (alloc.tokens > 0) { - // Distribute rewards if proof of indexing was presented by the indexer or operator - if (isIndexerOrOperator && _poi != 0 && epochs > 0) { - _distributeRewards(_allocationID, alloc.indexer); - } else { - _updateRewards(alloc.subgraphDeploymentID); - } - - // Free allocated tokens from use - _serviceProviders[alloc.indexer].__DEPRECATED_tokensAllocated = - _serviceProviders[alloc.indexer].__DEPRECATED_tokensAllocated - alloc.tokens; - - // Track total allocations per subgraph - // Used for rewards calculations - __DEPRECATED_subgraphAllocations[alloc.subgraphDeploymentID] = - __DEPRECATED_subgraphAllocations[alloc.subgraphDeploymentID] - alloc.tokens; - } - - // Close the allocation - // Note that this breaks CEI pattern. We update after the rewards distribution logic as it expects the allocation - // to still be active. There shouldn't be reentrancy risk here as all internal calls are to trusted contracts. - __DEPRECATED_allocations[_allocationID].closedAtEpoch = alloc.closedAtEpoch; - - emit AllocationClosed( - alloc.indexer, - alloc.subgraphDeploymentID, - alloc.closedAtEpoch, - alloc.tokens, - _allocationID, - msg.sender, - _poi, - !isIndexerOrOperator - ); - } - - /** - * @notice Collect the delegation rewards for query fees - * @dev This function will assign the collected fees to the delegation pool - * @param _indexer Indexer to which the tokens to distribute are related - * @param _tokens Total tokens received used to calculate the amount of fees to collect - * @return Amount of delegation rewards - */ - function _collectDelegationQueryRewards(address _indexer, uint256 _tokens) private returns (uint256) { - uint256 delegationRewards = 0; - DelegationPoolInternal storage pool = _legacyDelegationPools[_indexer]; - if (pool.tokens > 0 && uint256(pool.__DEPRECATED_queryFeeCut).isValidPPM()) { - uint256 indexerCut = uint256(pool.__DEPRECATED_queryFeeCut).mulPPM(_tokens); - delegationRewards = _tokens - indexerCut; - pool.tokens = pool.tokens + delegationRewards; - } - return delegationRewards; - } - - /** - * @notice Collect the delegation rewards for indexing - * @dev This function will assign the collected fees to the delegation pool - * @param _indexer Indexer to which the tokens to distribute are related - * @param _tokens Total tokens received used to calculate the amount of fees to collect - * @return Amount of delegation rewards - */ - function _collectDelegationIndexingRewards(address _indexer, uint256 _tokens) private returns (uint256) { - uint256 delegationRewards = 0; - DelegationPoolInternal storage pool = _legacyDelegationPools[_indexer]; - if (pool.tokens > 0 && uint256(pool.__DEPRECATED_indexingRewardCut).isValidPPM()) { - uint256 indexerCut = uint256(pool.__DEPRECATED_indexingRewardCut).mulPPM(_tokens); - delegationRewards = _tokens - indexerCut; - pool.tokens = pool.tokens + delegationRewards; - } - return delegationRewards; - } - - /** - * @notice Collect the curation fees for a subgraph deployment from an amount of tokens - * @dev This function transfer curation fees to the Curation contract by calling Curation.collect - * @param _subgraphDeploymentID Subgraph deployment to which the curation fees are related - * @param _tokens Total tokens received used to calculate the amount of fees to collect - * @param _curationCut Percentage of tokens to collect as fees - * @return Amount of curation fees - */ - function _collectCurationFees( - bytes32 _subgraphDeploymentID, - uint256 _tokens, - uint256 _curationCut - ) private returns (uint256) { - if (_tokens == 0) { - return 0; - } - - ICuration curation = _graphCuration(); - bool isCurationEnabled = _curationCut > 0 && address(curation) != address(0); - - if (isCurationEnabled && curation.isCurated(_subgraphDeploymentID)) { - uint256 curationFees = _tokens.mulPPMRoundUp(_curationCut); - if (curationFees > 0) { - // Transfer and call collect() - // This function transfer tokens to a trusted protocol contracts - // Then we call collect() to do the transfer Bookkeeping - _graphRewardsManager().onSubgraphSignalUpdate(_subgraphDeploymentID); - _graphToken().pushTokens(address(curation), curationFees); - curation.collect(_subgraphDeploymentID, curationFees); - } - return curationFees; - } - return 0; - } - - /** - * @notice Return the current state of an allocation - * @param _allocationID Allocation identifier - * @return AllocationState enum with the state of the allocation - */ - function _getAllocationState(address _allocationID) private view returns (AllocationState) { - Allocation storage alloc = __DEPRECATED_allocations[_allocationID]; - - if (alloc.indexer == address(0)) { - return AllocationState.Null; - } - - if (alloc.createdAtEpoch != 0 && alloc.closedAtEpoch == 0) { - return AllocationState.Active; - } - - return AllocationState.Closed; - } -} diff --git a/packages/horizon/contracts/staking/HorizonStakingStorage.sol b/packages/horizon/contracts/staking/HorizonStakingStorage.sol index 5f63af9df..a10b853a8 100644 --- a/packages/horizon/contracts/staking/HorizonStakingStorage.sol +++ b/packages/horizon/contracts/staking/HorizonStakingStorage.sol @@ -2,7 +2,6 @@ pragma solidity 0.8.27; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; @@ -62,7 +61,7 @@ abstract contract HorizonStakingV1Storage { /// @dev Allocation details. /// Deprecated, now applied on the subgraph data service - mapping(address allocationId => IHorizonStakingExtension.Allocation allocation) internal __DEPRECATED_allocations; + mapping(address allocationId => bytes32 __DEPRECATED_allocation) internal __DEPRECATED_allocations; /// @dev Subgraph allocations, tracks the tokens allocated to a subgraph deployment /// Deprecated, now applied on the SubgraphService @@ -89,7 +88,7 @@ abstract contract HorizonStakingV1Storage { uint32 internal __DEPRECATED_delegationParametersCooldown; /// @dev Time in epochs a delegator needs to wait to withdraw delegated stake - /// Deprecated, now only enforced during a transition period + /// Deprecated, enforced by each data service as needed. uint32 internal __DEPRECATED_delegationUnbondingPeriod; /// @dev Percentage of tokens to tax a delegation deposit diff --git a/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol b/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol deleted file mode 100644 index 974e7197b..000000000 --- a/packages/horizon/contracts/staking/libraries/ExponentialRebates.sol +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -pragma solidity 0.8.27; - -import { LibFixedMath } from "../../libraries/LibFixedMath.sol"; - -/** - * @title ExponentialRebates library - * @author Edge & Node - * @notice A library to compute query fee rebates using an exponential formula - * @dev This is only used for backwards compatibility in HorizonStaking, and should - * be removed after the transition period. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -library ExponentialRebates { - /// @dev Maximum value of the exponent for which to compute the exponential before clamping to zero. - uint32 private constant MAX_EXPONENT = 15; - - /** - * @notice The exponential formula used to compute fee-based rewards for staking pools in a given epoch - * @dev This function does not perform bounds checking on the inputs, but the following conditions - * need to be true: - * 0 <= alphaNumerator / alphaDenominator <= 1 - * 0 < lambdaNumerator / lambdaDenominator - * The exponential rebates function has the form: - * `(1 - alpha * exp ^ (-lambda * stake / fees)) * fees` - * @param fees Fees generated by indexer in the staking pool - * @param stake Stake attributed to the indexer in the staking pool - * @param alphaNumerator Numerator of `alpha` in the rebates function - * @param alphaDenominator Denominator of `alpha` in the rebates function - * @param lambdaNumerator Numerator of `lambda` in the rebates function - * @param lambdaDenominator Denominator of `lambda` in the rebates function - * @return rewards Rewards owed to the staking pool - */ - function exponentialRebates( - uint256 fees, - uint256 stake, - uint32 alphaNumerator, - uint32 alphaDenominator, - uint32 lambdaNumerator, - uint32 lambdaDenominator - ) external pure returns (uint256) { - // If alpha is zero indexer gets 100% fees rebate - int256 alpha = LibFixedMath.toFixed(int32(alphaNumerator), int32(alphaDenominator)); - if (alpha == 0) { - return fees; - } - - // No rebates if no fees... - if (fees == 0) { - return 0; - } - - // Award all fees as rebate if the exponent is too large - int256 lambda = LibFixedMath.toFixed(int32(lambdaNumerator), int32(lambdaDenominator)); - int256 exponent = LibFixedMath.mulDiv(lambda, int256(stake), int256(fees)); - if (LibFixedMath.toInteger(exponent) > int256(uint256(MAX_EXPONENT))) { - return fees; - } - - // Compute `1 - alpha * exp ^(-exponent)` - int256 factor = LibFixedMath.sub(LibFixedMath.one(), LibFixedMath.mul(alpha, LibFixedMath.exp(-exponent))); - - // Weight the fees by the factor - return LibFixedMath.uintMul(factor, fees); - } -} diff --git a/packages/horizon/contracts/utilities/GraphDirectory.sol b/packages/horizon/contracts/utilities/GraphDirectory.sol index 6e657c6d7..f5f86841e 100644 --- a/packages/horizon/contracts/utilities/GraphDirectory.sol +++ b/packages/horizon/contracts/utilities/GraphDirectory.sol @@ -13,8 +13,6 @@ import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/r import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol"; import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; -import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol"; - /** * @title GraphDirectory contract * @author Edge & Node @@ -55,13 +53,6 @@ abstract contract GraphDirectory { /// @notice The Graph Proxy Admin contract address IGraphProxyAdmin private immutable GRAPH_PROXY_ADMIN; - // -- Legacy Graph contracts -- - // These are required for backwards compatibility on HorizonStakingExtension - // TRANSITION PERIOD: remove these once HorizonStakingExtension is removed - - /// @notice The Curation contract address - ICuration private immutable GRAPH_CURATION; - /** * @notice Emitted when the GraphDirectory is initialized * @param graphToken The Graph Token contract address @@ -73,7 +64,6 @@ abstract contract GraphDirectory { * @param graphRewardsManager The Rewards Manager contract address * @param graphTokenGateway The Token Gateway contract address * @param graphProxyAdmin The Graph Proxy Admin contract address - * @param graphCuration The Curation contract address */ event GraphDirectoryInitialized( address indexed graphToken, @@ -84,8 +74,7 @@ abstract contract GraphDirectory { address graphEpochManager, address graphRewardsManager, address graphTokenGateway, - address graphProxyAdmin, - address graphCuration + address graphProxyAdmin ); /** @@ -116,7 +105,6 @@ abstract contract GraphDirectory { GRAPH_REWARDS_MANAGER = IRewardsManager(_getContractFromController("RewardsManager")); GRAPH_TOKEN_GATEWAY = ITokenGateway(_getContractFromController("GraphTokenGateway")); GRAPH_PROXY_ADMIN = IGraphProxyAdmin(_getContractFromController("GraphProxyAdmin")); - GRAPH_CURATION = ICuration(_getContractFromController("Curation")); emit GraphDirectoryInitialized( address(GRAPH_TOKEN), @@ -127,8 +115,7 @@ abstract contract GraphDirectory { address(GRAPH_EPOCH_MANAGER), address(GRAPH_REWARDS_MANAGER), address(GRAPH_TOKEN_GATEWAY), - address(GRAPH_PROXY_ADMIN), - address(GRAPH_CURATION) + address(GRAPH_PROXY_ADMIN) ); } @@ -204,14 +191,6 @@ abstract contract GraphDirectory { return GRAPH_PROXY_ADMIN; } - /** - * @notice Get the Curation contract - * @return The Curation contract - */ - function _graphCuration() internal view returns (ICuration) { - return GRAPH_CURATION; - } - /** * @notice Get a contract address from the controller * @dev Requirements: diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index 4add15391..f4f9499ae 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -27,6 +27,13 @@ interface IHorizonStakingMain { */ event HorizonStakeDeposited(address indexed serviceProvider, uint256 tokens); + /** + * @notice Emitted when a service provider unstakes tokens. + * @param serviceProvider The address of the service provider + * @param tokens The amount of tokens withdrawn + */ + event HorizonStakeWithdrawn(address indexed serviceProvider, uint256 tokens); + // -- Events: provision -- /** @@ -206,7 +213,7 @@ interface IHorizonStakingMain { /** * @notice Emitted when `delegator` withdrew delegated `tokens` from `indexer` using `withdrawDelegated`. - * @dev This event is for the legacy `withdrawDelegated` function. + * @dev This event is for the legacy `withdrawDelegated` function, only emitted for pre-horizon undelegations. * @param indexer The address of the indexer * @param delegator The address of the delegator * @param tokens The amount of tokens withdrawn @@ -446,7 +453,8 @@ interface IHorizonStakingMain { error HorizonStakingTooManyThawRequests(); /** - * @notice Thrown when attempting to withdraw tokens that have not thawed (legacy undelegate). + * @notice Thrown when attempting to withdraw tokens that have not thawed. + * @dev This error is only thrown for pre-horizon undelegations. */ error HorizonStakingNothingToWithdraw(); @@ -470,11 +478,6 @@ interface IHorizonStakingMain { */ error HorizonStakingInvalidDelegationFeeCut(uint256 feeCut); - /** - * @notice Thrown when a legacy slash fails. - */ - error HorizonStakingLegacySlashFailed(); - /** * @notice Thrown when there attempting to slash a provision with no tokens to slash. */ @@ -542,6 +545,18 @@ interface IHorizonStakingMain { */ function unstake(uint256 tokens) external; + /** + * @notice Withdraw service provider tokens once the thawing period (initiated by {unstake}) has passed. + * All thawed tokens are withdrawn. + * This function is for backwards compatibility with the legacy staking contract. + * It only allows withdrawing tokens unstaked before horizon upgrade. + * @dev This function can't be removed in case there are still pre-horizon unstakes. + * + * Emits a {HorizonStakeWithdrawn} event. + * + */ + function withdraw() external; + /** * @notice Provision stake to a verifier. The tokens will be locked with a thawing period * and will be slashable by the verifier. This is the main mechanism to provision stake to a data @@ -826,6 +841,7 @@ interface IHorizonStakingMain { * @notice Withdraw undelegated tokens from the subgraph data service provision after thawing. * This function is for backwards compatibility with the legacy staking contract. * It only allows withdrawing tokens undelegated before horizon upgrade. + * @dev This function can't be removed in case there are still pre-horizon undelegations. * @dev See {delegate}. * @param serviceProvider The service provider address * @param deprecated Deprecated parameter kept for backwards compatibility diff --git a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol index 5b084c7a7..18829a024 100644 --- a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol +++ b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol @@ -198,16 +198,6 @@ interface ISubgraphService is IDataServiceFees { */ function resizeAllocation(address indexer, address allocationId, uint256 tokens) external; - /** - * @notice Imports a legacy allocation id into the subgraph service - * This is a governor only action that is required to prevent indexers from re-using allocation ids from the - * legacy staking contract. - * @param indexer The address of the indexer - * @param allocationId The id of the allocation - * @param subgraphDeploymentId The id of the subgraph deployment - */ - function migrateLegacyAllocation(address indexer, address allocationId, bytes32 subgraphDeploymentId) external; - /** * @notice Sets a pause guardian * @param pauseGuardian The address of the pause guardian diff --git a/packages/interfaces/contracts/toolshed/internal/IAllocationManager.sol b/packages/interfaces/contracts/toolshed/internal/IAllocationManager.sol index 9e6e8b704..3b6809a63 100644 --- a/packages/interfaces/contracts/toolshed/internal/IAllocationManager.sol +++ b/packages/interfaces/contracts/toolshed/internal/IAllocationManager.sol @@ -44,12 +44,6 @@ interface IAllocationManager { bool forceClosed ); - event LegacyAllocationMigrated( - address indexed indexer, - address indexed allocationId, - bytes32 indexed subgraphDeploymentId - ); - event MaxPOIStalenessSet(uint256 maxPOIStaleness); // Errors From 28f26618e5e069d1627e4762e14bee925079743b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 28 Nov 2025 18:44:25 -0300 Subject: [PATCH 23/59] test: update horizon tests after cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/horizon/test/unit/GraphBase.t.sol | 10 +- .../HorizonStakingShared.t.sol | 703 +----------------- .../unit/staking/allocation/allocation.t.sol | 33 - .../test/unit/staking/allocation/close.t.sol | 116 --- .../unit/staking/allocation/collect.t.sol | 81 -- .../staking/delegation/legacyWithdraw.t.sol | 1 - .../unit/staking/governance/governance.t.sol | 13 - .../unit/staking/provision/provision.t.sol | 16 - .../serviceProvider/serviceProvider.t.sol | 31 - .../test/unit/staking/slash/legacySlash.t.sol | 253 ------- .../test/unit/staking/stake/unstake.t.sol | 73 -- .../test/unit/staking/stake/withdraw.t.sol | 15 - .../test/unit/utilities/GraphDirectory.t.sol | 4 +- .../GraphDirectoryImplementation.sol | 5 - 14 files changed, 19 insertions(+), 1335 deletions(-) delete mode 100644 packages/horizon/test/unit/staking/allocation/allocation.t.sol delete mode 100644 packages/horizon/test/unit/staking/allocation/close.t.sol delete mode 100644 packages/horizon/test/unit/staking/allocation/collect.t.sol delete mode 100644 packages/horizon/test/unit/staking/slash/legacySlash.t.sol diff --git a/packages/horizon/test/unit/GraphBase.t.sol b/packages/horizon/test/unit/GraphBase.t.sol index f3f55b96a..1433cc802 100644 --- a/packages/horizon/test/unit/GraphBase.t.sol +++ b/packages/horizon/test/unit/GraphBase.t.sol @@ -14,7 +14,6 @@ import { GraphPayments } from "contracts/payments/GraphPayments.sol"; import { GraphTallyCollector } from "contracts/payments/collectors/GraphTallyCollector.sol"; import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { HorizonStaking } from "contracts/staking/HorizonStaking.sol"; -import { HorizonStakingExtension } from "contracts/staking/HorizonStakingExtension.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { MockGRTToken } from "../../contracts/mocks/MockGRTToken.sol"; import { EpochManagerMock } from "contracts/mocks/EpochManagerMock.sol"; @@ -43,7 +42,6 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { GraphTallyCollector graphTallyCollector; HorizonStaking private stakingBase; - HorizonStakingExtension private stakingExtension; address subgraphDataServiceLegacyAddress = makeAddr("subgraphDataServiceLegacyAddress"); address subgraphDataServiceAddress = makeAddr("subgraphDataServiceAddress"); @@ -86,7 +84,6 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { vm.label({ account: address(payments), newLabel: "GraphPayments" }); vm.label({ account: address(escrow), newLabel: "PaymentsEscrow" }); vm.label({ account: address(staking), newLabel: "HorizonStaking" }); - vm.label({ account: address(stakingExtension), newLabel: "HorizonStakingExtension" }); vm.label({ account: address(graphTallyCollector), newLabel: "GraphTallyCollector" }); // Ensure caller is back to the original msg.sender @@ -194,12 +191,7 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { escrow = PaymentsEscrow(escrowProxyAddress); } - stakingExtension = new HorizonStakingExtension(address(controller), subgraphDataServiceLegacyAddress); - stakingBase = new HorizonStaking( - address(controller), - address(stakingExtension), - subgraphDataServiceLegacyAddress - ); + stakingBase = new HorizonStaking(address(controller), address(0), subgraphDataServiceLegacyAddress); graphTallyCollector = new GraphTallyCollector( "GraphTallyCollector", diff --git a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol index f89a7fafa..85f0bb755 100644 --- a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol +++ b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol @@ -7,14 +7,12 @@ import { GraphBaseTest } from "../../GraphBase.t.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IHorizonStakingBase } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol"; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { LinkedList } from "../../../../contracts/libraries/LinkedList.sol"; import { MathUtils } from "../../../../contracts/libraries/MathUtils.sol"; import { PPMMath } from "../../../../contracts/libraries/PPMMath.sol"; -import { ExponentialRebates } from "../../../../contracts/staking/libraries/ExponentialRebates.sol"; abstract contract HorizonStakingSharedTest is GraphBaseTest { using LinkedList for ILinkedList.List; @@ -23,13 +21,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { event Transfer(address indexed from, address indexed to, uint tokens); address internal _allocationId = makeAddr("allocationId"); - bytes32 internal constant _subgraphDeploymentID = keccak256("subgraphDeploymentID"); - uint256 internal constant MAX_ALLOCATION_EPOCHS = 28; - - uint32 internal alphaNumerator = 100; - uint32 internal alphaDenominator = 100; - uint32 internal lambdaNumerator = 60; - uint32 internal lambdaDenominator = 100; /* * MODIFIERS @@ -80,17 +71,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { _createProvision(users.indexer, dataService, tokens, maxVerifierCut, thawingPeriod); } - modifier useAllocation(uint256 tokens) { - vm.assume(tokens <= MAX_STAKING_TOKENS); - _createAllocation(users.indexer, _allocationId, _subgraphDeploymentID, tokens); - _; - } - - modifier useRebateParameters() { - _setStorage_RebateParameters(alphaNumerator, alphaDenominator, lambdaNumerator, lambdaDenominator); - _; - } - /* * HELPERS: these are shortcuts to perform common actions that often involve multiple contract calls */ @@ -105,34 +85,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { _provision(serviceProvider, verifier, tokens, maxVerifierCut, thawingPeriod); } - // This allows setting up contract state with legacy allocations - function _createAllocation( - address serviceProvider, - address allocationId, - bytes32 subgraphDeploymentID, - uint256 tokens - ) internal { - _setStorage_MaxAllocationEpochs(MAX_ALLOCATION_EPOCHS); - - IHorizonStakingExtension.Allocation memory _allocation = IHorizonStakingExtension.Allocation({ - indexer: serviceProvider, - subgraphDeploymentID: subgraphDeploymentID, - tokens: tokens, - createdAtEpoch: block.timestamp, - closedAtEpoch: 0, - collectedFees: 0, - __DEPRECATED_effectiveAllocation: 0, - accRewardsPerAllocatedToken: 0, - distributedRebates: 0 - }); - _setStorage_allocation(_allocation, allocationId, tokens); - - // delegation pool initialized - _setStorage_DelegationPool(serviceProvider, 0, uint32(PPMMath.MAX_PPM), uint32(PPMMath.MAX_PPM)); - - token.transfer(address(staking), tokens); - } - /* * ACTIONS: these are individual contract calls wrapped in assertion blocks to ensure they work as expected */ @@ -152,7 +104,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // stakeTo token.approve(address(staking), tokens); vm.expectEmit(); - emit IHorizonStakingBase.HorizonStakeDeposited(serviceProvider, tokens); + emit IHorizonStakingMain.HorizonStakeDeposited(serviceProvider, tokens); staking.stakeTo(serviceProvider, tokens); // after @@ -185,7 +137,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // stakeTo token.approve(address(staking), tokens); vm.expectEmit(); - emit IHorizonStakingBase.HorizonStakeDeposited(serviceProvider, tokens); + emit IHorizonStakingMain.HorizonStakeDeposited(serviceProvider, tokens); vm.expectEmit(); emit IHorizonStakingMain.ProvisionIncreased(serviceProvider, verifier, tokens); staking.stakeToProvision(serviceProvider, verifier, tokens); @@ -232,48 +184,15 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { function _unstake(uint256 _tokens) internal { (, address msgSender, ) = vm.readCallers(); - uint256 deprecatedThawingPeriod = staking.__DEPRECATED_getThawingPeriod(); - // before uint256 beforeSenderBalance = token.balanceOf(msgSender); uint256 beforeStakingBalance = token.balanceOf(address(staking)); ServiceProviderInternal memory beforeServiceProvider = _getStorage_ServiceProviderInternal(msgSender); - bool withdrawCalled = beforeServiceProvider.__DEPRECATED_tokensLocked != 0 && - block.number >= beforeServiceProvider.__DEPRECATED_tokensLockedUntil; - - if (deprecatedThawingPeriod != 0 && beforeServiceProvider.__DEPRECATED_tokensLocked > 0) { - deprecatedThawingPeriod = MathUtils.weightedAverageRoundingUp( - MathUtils.diffOrZero( - withdrawCalled ? 0 : beforeServiceProvider.__DEPRECATED_tokensLockedUntil, - block.number - ), - withdrawCalled ? 0 : beforeServiceProvider.__DEPRECATED_tokensLocked, - deprecatedThawingPeriod, - _tokens - ); - } - // unstake - if (deprecatedThawingPeriod == 0) { - vm.expectEmit(address(staking)); - emit IHorizonStakingMain.HorizonStakeWithdrawn(msgSender, _tokens); - } else { - if (withdrawCalled) { - vm.expectEmit(address(staking)); - emit IHorizonStakingMain.HorizonStakeWithdrawn( - msgSender, - beforeServiceProvider.__DEPRECATED_tokensLocked - ); - } + vm.expectEmit(address(staking)); + emit IHorizonStakingMain.HorizonStakeWithdrawn(msgSender, _tokens); - vm.expectEmit(address(staking)); - emit IHorizonStakingMain.HorizonStakeLocked( - msgSender, - withdrawCalled ? _tokens : beforeServiceProvider.__DEPRECATED_tokensLocked + _tokens, - block.number + deprecatedThawingPeriod - ); - } staking.unstake(_tokens); // after @@ -282,41 +201,19 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { ServiceProviderInternal memory afterServiceProvider = _getStorage_ServiceProviderInternal(msgSender); // assert - if (deprecatedThawingPeriod == 0) { - assertEq(afterSenderBalance, _tokens + beforeSenderBalance); - assertEq(afterStakingBalance, beforeStakingBalance - _tokens); - assertEq(afterServiceProvider.tokensStaked, beforeServiceProvider.tokensStaked - _tokens); - assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); - assertEq( - afterServiceProvider.__DEPRECATED_tokensAllocated, - beforeServiceProvider.__DEPRECATED_tokensAllocated - ); - assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, beforeServiceProvider.__DEPRECATED_tokensLocked); - assertEq( - afterServiceProvider.__DEPRECATED_tokensLockedUntil, - beforeServiceProvider.__DEPRECATED_tokensLockedUntil - ); - } else { - assertEq( - afterServiceProvider.tokensStaked, - withdrawCalled - ? beforeServiceProvider.tokensStaked - beforeServiceProvider.__DEPRECATED_tokensLocked - : beforeServiceProvider.tokensStaked - ); - assertEq( - afterServiceProvider.__DEPRECATED_tokensLocked, - _tokens + (withdrawCalled ? 0 : beforeServiceProvider.__DEPRECATED_tokensLocked) - ); - assertEq(afterServiceProvider.__DEPRECATED_tokensLockedUntil, block.number + deprecatedThawingPeriod); - assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); - assertEq( - afterServiceProvider.__DEPRECATED_tokensAllocated, - beforeServiceProvider.__DEPRECATED_tokensAllocated - ); - uint256 tokensTransferred = (withdrawCalled ? beforeServiceProvider.__DEPRECATED_tokensLocked : 0); - assertEq(afterSenderBalance, beforeSenderBalance + tokensTransferred); - assertEq(afterStakingBalance, beforeStakingBalance - tokensTransferred); - } + assertEq(afterSenderBalance, _tokens + beforeSenderBalance); + assertEq(afterStakingBalance, beforeStakingBalance - _tokens); + assertEq(afterServiceProvider.tokensStaked, beforeServiceProvider.tokensStaked - _tokens); + assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); + assertEq( + afterServiceProvider.__DEPRECATED_tokensAllocated, + beforeServiceProvider.__DEPRECATED_tokensAllocated + ); + assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, beforeServiceProvider.__DEPRECATED_tokensLocked); + assertEq( + afterServiceProvider.__DEPRECATED_tokensLockedUntil, + beforeServiceProvider.__DEPRECATED_tokensLockedUntil + ); } function _withdraw() internal { @@ -1460,19 +1357,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterEnabled, true); } - function _clearThawingPeriod() internal { - // clearThawingPeriod - vm.expectEmit(address(staking)); - emit IHorizonStakingMain.ThawingPeriodCleared(); - staking.clearThawingPeriod(); - - // after - uint64 afterThawingPeriod = staking.__DEPRECATED_getThawingPeriod(); - - // assert - assertEq(afterThawingPeriod, 0); - } - function _setMaxThawingPeriod(uint64 maxThawingPeriod) internal { // setMaxThawingPeriod vm.expectEmit(address(staking)); @@ -1619,318 +1503,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { } } - // use struct to avoid 'stack too deep' error - struct CalcValues_CloseAllocation { - uint256 rewards; - uint256 delegatorRewards; - uint256 indexerRewards; - } - struct BeforeValues_CloseAllocation { - IHorizonStakingExtension.Allocation allocation; - DelegationPoolInternalTest pool; - ServiceProviderInternal serviceProvider; - uint256 subgraphAllocations; - uint256 stakingBalance; - uint256 indexerBalance; - uint256 beneficiaryBalance; - } - - // Current rewards manager is mocked and assumed to mint fixed rewards - function _closeAllocation(address allocationId, bytes32 poi) internal { - (, address msgSender, ) = vm.readCallers(); - - // before - BeforeValues_CloseAllocation memory beforeValues; - beforeValues.allocation = staking.getAllocation(allocationId); - beforeValues.pool = _getStorage_DelegationPoolInternal( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - true - ); - beforeValues.serviceProvider = _getStorage_ServiceProviderInternal(beforeValues.allocation.indexer); - beforeValues.subgraphAllocations = _getStorage_SubgraphAllocations( - beforeValues.allocation.subgraphDeploymentID - ); - beforeValues.stakingBalance = token.balanceOf(address(staking)); - beforeValues.indexerBalance = token.balanceOf(beforeValues.allocation.indexer); - beforeValues.beneficiaryBalance = token.balanceOf( - _getStorage_RewardsDestination(beforeValues.allocation.indexer) - ); - - bool isAuth = staking.isAuthorized( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - msgSender - ); - address rewardsDestination = _getStorage_RewardsDestination(beforeValues.allocation.indexer); - - CalcValues_CloseAllocation memory calcValues = CalcValues_CloseAllocation({ - rewards: ALLOCATIONS_REWARD_CUT, - delegatorRewards: ALLOCATIONS_REWARD_CUT - - uint256(beforeValues.pool.__DEPRECATED_indexingRewardCut).mulPPM(ALLOCATIONS_REWARD_CUT), - indexerRewards: 0 - }); - calcValues.indexerRewards = - ALLOCATIONS_REWARD_CUT - (beforeValues.pool.tokens > 0 ? calcValues.delegatorRewards : 0); - - // closeAllocation - vm.expectEmit(address(staking)); - emit IHorizonStakingExtension.AllocationClosed( - beforeValues.allocation.indexer, - beforeValues.allocation.subgraphDeploymentID, - epochManager.currentEpoch(), - beforeValues.allocation.tokens, - allocationId, - msgSender, - poi, - !isAuth - ); - staking.closeAllocation(allocationId, poi); - - // after - IHorizonStakingExtension.Allocation memory afterAllocation = staking.getAllocation(allocationId); - DelegationPoolInternalTest memory afterPool = _getStorage_DelegationPoolInternal( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - true - ); - ServiceProviderInternal memory afterServiceProvider = _getStorage_ServiceProviderInternal( - beforeValues.allocation.indexer - ); - uint256 afterSubgraphAllocations = _getStorage_SubgraphAllocations( - beforeValues.allocation.subgraphDeploymentID - ); - uint256 afterStakingBalance = token.balanceOf(address(staking)); - uint256 afterIndexerBalance = token.balanceOf(beforeValues.allocation.indexer); - uint256 afterBeneficiaryBalance = token.balanceOf(rewardsDestination); - - if (beforeValues.allocation.tokens > 0) { - if (isAuth && poi != 0) { - if (rewardsDestination != address(0)) { - assertEq( - beforeValues.stakingBalance + calcValues.rewards - calcValues.indexerRewards, - afterStakingBalance - ); - assertEq(beforeValues.indexerBalance, afterIndexerBalance); - assertEq(beforeValues.beneficiaryBalance + calcValues.indexerRewards, afterBeneficiaryBalance); - } else { - assertEq(beforeValues.stakingBalance + calcValues.rewards, afterStakingBalance); - assertEq(beforeValues.indexerBalance, afterIndexerBalance); - assertEq(beforeValues.beneficiaryBalance, afterBeneficiaryBalance); - } - } else { - assertEq(beforeValues.stakingBalance, afterStakingBalance); - assertEq(beforeValues.indexerBalance, afterIndexerBalance); - assertEq(beforeValues.beneficiaryBalance, afterBeneficiaryBalance); - } - } else { - assertEq(beforeValues.stakingBalance, afterStakingBalance); - assertEq(beforeValues.indexerBalance, afterIndexerBalance); - assertEq(beforeValues.beneficiaryBalance, afterBeneficiaryBalance); - } - - assertEq(afterAllocation.indexer, beforeValues.allocation.indexer); - assertEq(afterAllocation.subgraphDeploymentID, beforeValues.allocation.subgraphDeploymentID); - assertEq(afterAllocation.tokens, beforeValues.allocation.tokens); - assertEq(afterAllocation.createdAtEpoch, beforeValues.allocation.createdAtEpoch); - assertEq(afterAllocation.closedAtEpoch, epochManager.currentEpoch()); - assertEq(afterAllocation.collectedFees, beforeValues.allocation.collectedFees); - assertEq( - afterAllocation.__DEPRECATED_effectiveAllocation, - beforeValues.allocation.__DEPRECATED_effectiveAllocation - ); - assertEq(afterAllocation.accRewardsPerAllocatedToken, beforeValues.allocation.accRewardsPerAllocatedToken); - assertEq(afterAllocation.distributedRebates, beforeValues.allocation.distributedRebates); - - if (beforeValues.allocation.tokens > 0 && isAuth && poi != 0 && rewardsDestination == address(0)) { - assertEq( - afterServiceProvider.tokensStaked, - beforeValues.serviceProvider.tokensStaked + calcValues.indexerRewards - ); - } else { - assertEq(afterServiceProvider.tokensStaked, beforeValues.serviceProvider.tokensStaked); - } - assertEq(afterServiceProvider.tokensProvisioned, beforeValues.serviceProvider.tokensProvisioned); - assertEq( - afterServiceProvider.__DEPRECATED_tokensAllocated + beforeValues.allocation.tokens, - beforeValues.serviceProvider.__DEPRECATED_tokensAllocated - ); - assertEq( - afterServiceProvider.__DEPRECATED_tokensLocked, - beforeValues.serviceProvider.__DEPRECATED_tokensLocked - ); - assertEq( - afterServiceProvider.__DEPRECATED_tokensLockedUntil, - beforeValues.serviceProvider.__DEPRECATED_tokensLockedUntil - ); - - assertEq(afterSubgraphAllocations + beforeValues.allocation.tokens, beforeValues.subgraphAllocations); - - if (beforeValues.allocation.tokens > 0 && isAuth && poi != 0 && beforeValues.pool.tokens > 0) { - assertEq(afterPool.tokens, beforeValues.pool.tokens + calcValues.delegatorRewards); - } else { - assertEq(afterPool.tokens, beforeValues.pool.tokens); - } - } - - // use struct to avoid 'stack too deep' error - struct BeforeValues_Collect { - IHorizonStakingExtension.Allocation allocation; - DelegationPoolInternalTest pool; - ServiceProviderInternal serviceProvider; - uint256 stakingBalance; - uint256 senderBalance; - uint256 curationBalance; - uint256 beneficiaryBalance; - } - struct CalcValues_Collect { - uint256 protocolTaxTokens; - uint256 queryFees; - uint256 curationCutTokens; - uint256 newRebates; - uint256 payment; - uint256 delegationFeeCut; - } - struct AfterValues_Collect { - IHorizonStakingExtension.Allocation allocation; - DelegationPoolInternalTest pool; - ServiceProviderInternal serviceProvider; - uint256 stakingBalance; - uint256 senderBalance; - uint256 curationBalance; - uint256 beneficiaryBalance; - } - - function _collect(uint256 tokens, address allocationId) internal { - (, address msgSender, ) = vm.readCallers(); - - // before - BeforeValues_Collect memory beforeValues; - beforeValues.allocation = staking.getAllocation(allocationId); - beforeValues.pool = _getStorage_DelegationPoolInternal( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - true - ); - beforeValues.serviceProvider = _getStorage_ServiceProviderInternal(beforeValues.allocation.indexer); - - (uint32 curationPercentage, uint32 protocolPercentage) = _getStorage_ProtocolTaxAndCuration(); - address rewardsDestination = _getStorage_RewardsDestination(beforeValues.allocation.indexer); - - beforeValues.stakingBalance = token.balanceOf(address(staking)); - beforeValues.senderBalance = token.balanceOf(msgSender); - beforeValues.curationBalance = token.balanceOf(address(curation)); - beforeValues.beneficiaryBalance = token.balanceOf(rewardsDestination); - - // calc some stuff - CalcValues_Collect memory calcValues; - calcValues.protocolTaxTokens = tokens.mulPPMRoundUp(protocolPercentage); - calcValues.queryFees = tokens - calcValues.protocolTaxTokens; - calcValues.curationCutTokens = 0; - if (curation.isCurated(beforeValues.allocation.subgraphDeploymentID)) { - calcValues.curationCutTokens = calcValues.queryFees.mulPPMRoundUp(curationPercentage); - calcValues.queryFees -= calcValues.curationCutTokens; - } - calcValues.newRebates = ExponentialRebates.exponentialRebates( - calcValues.queryFees + beforeValues.allocation.collectedFees, - beforeValues.allocation.tokens, - alphaNumerator, - alphaDenominator, - lambdaNumerator, - lambdaDenominator - ); - calcValues.payment = calcValues.newRebates > calcValues.queryFees - ? calcValues.queryFees - : calcValues.newRebates; - calcValues.delegationFeeCut = 0; - if (beforeValues.pool.tokens > 0) { - calcValues.delegationFeeCut = - calcValues.payment - calcValues.payment.mulPPM(beforeValues.pool.__DEPRECATED_queryFeeCut); - calcValues.payment -= calcValues.delegationFeeCut; - } - - // staking.collect() - if (tokens > 0) { - vm.expectEmit(address(staking)); - emit IHorizonStakingExtension.RebateCollected( - msgSender, - beforeValues.allocation.indexer, - beforeValues.allocation.subgraphDeploymentID, - allocationId, - epochManager.currentEpoch(), - tokens, - calcValues.protocolTaxTokens, - calcValues.curationCutTokens, - calcValues.queryFees, - calcValues.payment, - calcValues.delegationFeeCut - ); - } - staking.collect(tokens, allocationId); - - // after - AfterValues_Collect memory afterValues; - afterValues.allocation = staking.getAllocation(allocationId); - afterValues.pool = _getStorage_DelegationPoolInternal( - beforeValues.allocation.indexer, - subgraphDataServiceLegacyAddress, - true - ); - afterValues.serviceProvider = _getStorage_ServiceProviderInternal(beforeValues.allocation.indexer); - afterValues.stakingBalance = token.balanceOf(address(staking)); - afterValues.senderBalance = token.balanceOf(msgSender); - afterValues.curationBalance = token.balanceOf(address(curation)); - afterValues.beneficiaryBalance = token.balanceOf(rewardsDestination); - - // assert - assertEq(afterValues.senderBalance + tokens, beforeValues.senderBalance); - assertEq(afterValues.curationBalance, beforeValues.curationBalance + calcValues.curationCutTokens); - if (rewardsDestination != address(0)) { - assertEq(afterValues.beneficiaryBalance, beforeValues.beneficiaryBalance + calcValues.payment); - assertEq(afterValues.stakingBalance, beforeValues.stakingBalance + calcValues.delegationFeeCut); - } else { - assertEq(afterValues.beneficiaryBalance, beforeValues.beneficiaryBalance); - assertEq( - afterValues.stakingBalance, - beforeValues.stakingBalance + calcValues.delegationFeeCut + calcValues.payment - ); - } - - assertEq( - afterValues.allocation.collectedFees, - beforeValues.allocation.collectedFees + tokens - calcValues.protocolTaxTokens - calcValues.curationCutTokens - ); - assertEq(afterValues.allocation.indexer, beforeValues.allocation.indexer); - assertEq(afterValues.allocation.subgraphDeploymentID, beforeValues.allocation.subgraphDeploymentID); - assertEq(afterValues.allocation.tokens, beforeValues.allocation.tokens); - assertEq(afterValues.allocation.createdAtEpoch, beforeValues.allocation.createdAtEpoch); - assertEq(afterValues.allocation.closedAtEpoch, beforeValues.allocation.closedAtEpoch); - assertEq( - afterValues.allocation.accRewardsPerAllocatedToken, - beforeValues.allocation.accRewardsPerAllocatedToken - ); - assertEq( - afterValues.allocation.distributedRebates, - beforeValues.allocation.distributedRebates + calcValues.newRebates - ); - - assertEq(afterValues.pool.tokens, beforeValues.pool.tokens + calcValues.delegationFeeCut); - assertEq(afterValues.pool.shares, beforeValues.pool.shares); - assertEq(afterValues.pool.tokensThawing, beforeValues.pool.tokensThawing); - assertEq(afterValues.pool.sharesThawing, beforeValues.pool.sharesThawing); - assertEq(afterValues.pool.thawingNonce, beforeValues.pool.thawingNonce); - - assertEq(afterValues.serviceProvider.tokensProvisioned, beforeValues.serviceProvider.tokensProvisioned); - if (rewardsDestination != address(0)) { - assertEq(afterValues.serviceProvider.tokensStaked, beforeValues.serviceProvider.tokensStaked); - } else { - assertEq( - afterValues.serviceProvider.tokensStaked, - beforeValues.serviceProvider.tokensStaked + calcValues.payment - ); - } - } - /* * STORAGE HELPERS */ @@ -1975,22 +1547,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { return vm.load(address(staking), bytes32(slot)) == bytes32(uint256(1)); } - function _setStorage_DeprecatedThawingPeriod(uint32 _thawingPeriod) internal { - uint256 slot = 13; - - // Read the current value of the slot - uint256 currentSlotValue = uint256(vm.load(address(staking), bytes32(slot))); - - // Create a mask to clear the bits for __DEPRECATED_thawingPeriod (bits 0-31) - uint256 mask = ~(uint256(0xFFFFFFFF)); // Mask to clear the first 32 bits - - // Clear the bits for __DEPRECATED_thawingPeriod and set the new value - uint256 newSlotValue = (currentSlotValue & mask) | uint256(_thawingPeriod); - - // Store the updated value back into the slot - vm.store(address(staking), bytes32(slot), bytes32(newSlotValue)); - } - function _setStorage_ServiceProvider( address _indexer, uint256 _tokensStaked, @@ -2096,59 +1652,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { return delegation; } - function _setStorage_allocation( - IHorizonStakingExtension.Allocation memory allocation, - address allocationId, - uint256 tokens - ) internal { - // __DEPRECATED_allocations - uint256 allocationsSlot = 15; - bytes32 allocationBaseSlot = keccak256(abi.encode(allocationId, allocationsSlot)); - vm.store(address(staking), allocationBaseSlot, bytes32(uint256(uint160(allocation.indexer)))); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 1), allocation.subgraphDeploymentID); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 2), bytes32(tokens)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 3), bytes32(allocation.createdAtEpoch)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 4), bytes32(allocation.closedAtEpoch)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 5), bytes32(allocation.collectedFees)); - vm.store( - address(staking), - bytes32(uint256(allocationBaseSlot) + 6), - bytes32(allocation.__DEPRECATED_effectiveAllocation) - ); - vm.store( - address(staking), - bytes32(uint256(allocationBaseSlot) + 7), - bytes32(allocation.accRewardsPerAllocatedToken) - ); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 8), bytes32(allocation.distributedRebates)); - - // _serviceProviders - uint256 serviceProviderSlot = 14; - bytes32 serviceProviderBaseSlot = keccak256(abi.encode(allocation.indexer, serviceProviderSlot)); - uint256 currentTokensStaked = uint256(vm.load(address(staking), serviceProviderBaseSlot)); - uint256 currentTokensProvisioned = uint256( - vm.load(address(staking), bytes32(uint256(serviceProviderBaseSlot) + 1)) - ); - vm.store( - address(staking), - bytes32(uint256(serviceProviderBaseSlot) + 0), - bytes32(currentTokensStaked + tokens) - ); - vm.store( - address(staking), - bytes32(uint256(serviceProviderBaseSlot) + 1), - bytes32(currentTokensProvisioned + tokens) - ); - - // __DEPRECATED_subgraphAllocations - uint256 subgraphsAllocationsSlot = 16; - bytes32 subgraphAllocationsBaseSlot = keccak256( - abi.encode(allocation.subgraphDeploymentID, subgraphsAllocationsSlot) - ); - uint256 currentAllocatedTokens = uint256(vm.load(address(staking), subgraphAllocationsBaseSlot)); - vm.store(address(staking), subgraphAllocationsBaseSlot, bytes32(currentAllocatedTokens + tokens)); - } - function _getStorage_SubgraphAllocations(bytes32 subgraphDeploymentID) internal view returns (uint256) { uint256 subgraphsAllocationsSlot = 16; bytes32 subgraphAllocationsBaseSlot = keccak256(abi.encode(subgraphDeploymentID, subgraphsAllocationsSlot)); @@ -2167,40 +1670,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { return address(uint160(uint256(vm.load(address(staking), rewardsDestinationSlotBaseSlot)))); } - function _setStorage_MaxAllocationEpochs(uint256 maxAllocationEpochs) internal { - uint256 slot = 13; - - // Read the current value of the storage slot - uint256 currentSlotValue = uint256(vm.load(address(staking), bytes32(slot))); - - // Mask to clear the specific bits for __DEPRECATED_maxAllocationEpochs (bits 128-159) - uint256 mask = ~(uint256(0xFFFFFFFF) << 128); - - // Clear the bits and set the new maxAllocationEpochs value - uint256 newSlotValue = (currentSlotValue & mask) | (uint256(maxAllocationEpochs) << 128); - - // Store the updated value back into the slot - vm.store(address(staking), bytes32(slot), bytes32(newSlotValue)); - - uint256 readMaxAllocationEpochs = _getStorage_MaxAllocationEpochs(); - assertEq(readMaxAllocationEpochs, maxAllocationEpochs); - } - - function _getStorage_MaxAllocationEpochs() internal view returns (uint256) { - uint256 slot = 13; - - // Read the current value of the storage slot - uint256 currentSlotValue = uint256(vm.load(address(staking), bytes32(slot))); - - // Mask to isolate bits 128-159 - uint256 mask = uint256(0xFFFFFFFF) << 128; - - // Extract the maxAllocationEpochs by masking and shifting - uint256 maxAllocationEpochs = (currentSlotValue & mask) >> 128; - - return maxAllocationEpochs; - } - function _setStorage_DelegationPool( address serviceProvider, uint256 tokens, @@ -2216,144 +1685,6 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { vm.store(address(staking), tokensSlot, bytes32(tokens)); } - function _setStorage_RebateParameters( - uint32 alphaNumerator_, - uint32 alphaDenominator_, - uint32 lambdaNumerator_, - uint32 lambdaDenominator_ - ) internal { - // Store alpha numerator and denominator in slot 13 - uint256 alphaSlot = 13; - - uint256 newAlphaSlotValue; - { - uint256 alphaNumeratorOffset = 160; // Offset for __DEPRECATED_alphaNumerator (20th byte) - uint256 alphaDenominatorOffset = 192; // Offset for __DEPRECATED_alphaDenominator (24th byte) - - // Read current value of the slot - uint256 currentAlphaSlotValue = uint256(vm.load(address(staking), bytes32(alphaSlot))); - - // Create a mask to clear the bits for alphaNumerator and alphaDenominator - uint256 alphaMask = ~(uint256(0xFFFFFFFF) << alphaNumeratorOffset) & - ~(uint256(0xFFFFFFFF) << alphaDenominatorOffset); - - // Clear and set new values - newAlphaSlotValue = - (currentAlphaSlotValue & alphaMask) | - (uint256(alphaNumerator_) << alphaNumeratorOffset) | - (uint256(alphaDenominator_) << alphaDenominatorOffset); - } - - // Store the updated value back into the slot - vm.store(address(staking), bytes32(alphaSlot), bytes32(newAlphaSlotValue)); - - // Store lambda numerator and denominator in slot 25 - uint256 lambdaSlot = 25; - - uint256 newLambdaSlotValue; - { - uint256 lambdaNumeratorOffset = 160; // Offset for lambdaNumerator (20th byte) - uint256 lambdaDenominatorOffset = 192; // Offset for lambdaDenominator (24th byte) - - // Read current value of the slot - uint256 currentLambdaSlotValue = uint256(vm.load(address(staking), bytes32(lambdaSlot))); - - // Create a mask to clear the bits for lambdaNumerator and lambdaDenominator - uint256 lambdaMask = ~(uint256(0xFFFFFFFF) << lambdaNumeratorOffset) & - ~(uint256(0xFFFFFFFF) << lambdaDenominatorOffset); - - // Clear and set new values - newLambdaSlotValue = - (currentLambdaSlotValue & lambdaMask) | - (uint256(lambdaNumerator_) << lambdaNumeratorOffset) | - (uint256(lambdaDenominator_) << lambdaDenominatorOffset); - } - - // Store the updated value back into the slot - vm.store(address(staking), bytes32(lambdaSlot), bytes32(newLambdaSlotValue)); - - // Verify the storage - ( - uint32 readAlphaNumerator, - uint32 readAlphaDenominator, - uint32 readLambdaNumerator, - uint32 readLambdaDenominator - ) = _getStorage_RebateParameters(); - assertEq(readAlphaNumerator, alphaNumerator_); - assertEq(readAlphaDenominator, alphaDenominator_); - assertEq(readLambdaNumerator, lambdaNumerator_); - assertEq(readLambdaDenominator, lambdaDenominator_); - } - - function _getStorage_RebateParameters() internal view returns (uint32, uint32, uint32, uint32) { - // Read alpha numerator and denominator - uint256 alphaSlot = 13; - uint256 alphaValues = uint256(vm.load(address(staking), bytes32(alphaSlot))); - uint32 alphaNumerator_ = uint32(alphaValues >> 160); - uint32 alphaDenominator_ = uint32(alphaValues >> 192); - - // Read lambda numerator and denominator - uint256 lambdaSlot = 25; - uint256 lambdaValues = uint256(vm.load(address(staking), bytes32(lambdaSlot))); - uint32 lambdaNumerator_ = uint32(lambdaValues >> 160); - uint32 lambdaDenominator_ = uint32(lambdaValues >> 192); - - return (alphaNumerator_, alphaDenominator_, lambdaNumerator_, lambdaDenominator_); - } - - // function _setStorage_ProtocolTaxAndCuration(uint32 curationPercentage, uint32 taxPercentage) private { - // bytes32 slot = bytes32(uint256(13)); - // uint256 curationOffset = 4; - // uint256 protocolTaxOffset = 8; - // bytes32 originalValue = vm.load(address(staking), slot); - - // bytes32 newProtocolTaxValue = bytes32( - // ((uint256(originalValue) & - // ~((0xFFFFFFFF << (8 * curationOffset)) | (0xFFFFFFFF << (8 * protocolTaxOffset)))) | - // (uint256(curationPercentage) << (8 * curationOffset))) | - // (uint256(taxPercentage) << (8 * protocolTaxOffset)) - // ); - // vm.store(address(staking), slot, newProtocolTaxValue); - - // (uint32 readCurationPercentage, uint32 readTaxPercentage) = _getStorage_ProtocolTaxAndCuration(); - // assertEq(readCurationPercentage, curationPercentage); - // } - - function _setStorage_ProtocolTaxAndCuration(uint32 curationPercentage, uint32 taxPercentage) internal { - bytes32 slot = bytes32(uint256(13)); - - // Offsets for the percentages - uint256 curationOffset = 32; // __DEPRECATED_curationPercentage (2nd uint32, bits 32-63) - uint256 protocolTaxOffset = 64; // __DEPRECATED_protocolPercentage (3rd uint32, bits 64-95) - - // Read the current slot value - uint256 originalValue = uint256(vm.load(address(staking), slot)); - - // Create masks to clear the specific bits for the two percentages - uint256 mask = ~(uint256(0xFFFFFFFF) << curationOffset) & ~(uint256(0xFFFFFFFF) << protocolTaxOffset); // Mask for curationPercentage // Mask for protocolTax - - // Clear the existing bits and set the new values - uint256 newSlotValue = (originalValue & mask) | - (uint256(curationPercentage) << curationOffset) | - (uint256(taxPercentage) << protocolTaxOffset); - - // Store the updated slot value - vm.store(address(staking), slot, bytes32(newSlotValue)); - - // Verify the values were set correctly - (uint32 readCurationPercentage, uint32 readTaxPercentage) = _getStorage_ProtocolTaxAndCuration(); - assertEq(readCurationPercentage, curationPercentage); - assertEq(readTaxPercentage, taxPercentage); - } - - function _getStorage_ProtocolTaxAndCuration() internal view returns (uint32, uint32) { - bytes32 slot = bytes32(uint256(13)); - bytes32 value = vm.load(address(staking), slot); - uint32 curationPercentage = uint32(uint256(value) >> 32); - uint32 taxPercentage = uint32(uint256(value) >> 64); - return (curationPercentage, taxPercentage); - } - /* * MISC: private functions to help with testing */ diff --git a/packages/horizon/test/unit/staking/allocation/allocation.t.sol b/packages/horizon/test/unit/staking/allocation/allocation.t.sol deleted file mode 100644 index 5c9bb179d..000000000 --- a/packages/horizon/test/unit/staking/allocation/allocation.t.sol +++ /dev/null @@ -1,33 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.27; - -import "forge-std/Test.sol"; - -import { HorizonStakingTest } from "../HorizonStaking.t.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; - -contract HorizonStakingAllocationTest is HorizonStakingTest { - /* - * TESTS - */ - - function testAllocation_GetAllocationState_Active(uint256 tokens) public useIndexer useAllocation(tokens) { - IHorizonStakingExtension.AllocationState state = staking.getAllocationState(_allocationId); - assertEq(uint16(state), uint16(IHorizonStakingExtension.AllocationState.Active)); - } - - function testAllocation_GetAllocationState_Null() public view { - IHorizonStakingExtension.AllocationState state = staking.getAllocationState(_allocationId); - assertEq(uint16(state), uint16(IHorizonStakingExtension.AllocationState.Null)); - } - - function testAllocation_IsAllocation(uint256 tokens) public useIndexer useAllocation(tokens) { - bool isAllocation = staking.isAllocation(_allocationId); - assertTrue(isAllocation); - } - - function testAllocation_IsNotAllocation() public view { - bool isAllocation = staking.isAllocation(_allocationId); - assertFalse(isAllocation); - } -} diff --git a/packages/horizon/test/unit/staking/allocation/close.t.sol b/packages/horizon/test/unit/staking/allocation/close.t.sol deleted file mode 100644 index cac390099..000000000 --- a/packages/horizon/test/unit/staking/allocation/close.t.sol +++ /dev/null @@ -1,116 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.27; - -import "forge-std/Test.sol"; - -import { HorizonStakingTest } from "../HorizonStaking.t.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; -import { PPMMath } from "../../../../contracts/libraries/PPMMath.sol"; - -contract HorizonStakingCloseAllocationTest is HorizonStakingTest { - using PPMMath for uint256; - - bytes32 internal constant _poi = keccak256("poi"); - - /* - * MODIFIERS - */ - - modifier useLegacyOperator() { - resetPrank(users.indexer); - _setOperator(subgraphDataServiceLegacyAddress, users.operator, true); - vm.startPrank(users.operator); - _; - vm.stopPrank(); - } - - /* - * TESTS - */ - - function testCloseAllocation(uint256 tokens) public useIndexer useAllocation(1 ether) { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - // Skip 15 epochs - vm.roll(15); - - _closeAllocation(_allocationId, _poi); - } - - function testCloseAllocation_Operator(uint256 tokens) public useLegacyOperator useAllocation(1 ether) { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - // Skip 15 epochs - vm.roll(15); - - _closeAllocation(_allocationId, _poi); - } - - function testCloseAllocation_WithBeneficiaryAddress(uint256 tokens) public useIndexer useAllocation(1 ether) { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - address beneficiary = makeAddr("beneficiary"); - _setStorage_RewardsDestination(users.indexer, beneficiary); - - // Skip 15 epochs - vm.roll(15); - - _closeAllocation(_allocationId, _poi); - } - - function testCloseAllocation_RevertWhen_NotActive() public { - vm.expectRevert("!active"); - staking.closeAllocation(_allocationId, _poi); - } - - function testCloseAllocation_RevertWhen_NotIndexer() public useIndexer useAllocation(1 ether) { - resetPrank(users.delegator); - vm.expectRevert("!auth"); - staking.closeAllocation(_allocationId, _poi); - } - - function testCloseAllocation_AfterMaxEpochs_AnyoneCanClose( - uint256 tokens - ) public useIndexer useAllocation(1 ether) { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - // Skip to over the max allocation epochs - vm.roll((MAX_ALLOCATION_EPOCHS + 1) * EPOCH_LENGTH + 1); - - resetPrank(users.delegator); - _closeAllocation(_allocationId, 0x0); - } - - function testCloseAllocation_RevertWhen_ZeroTokensNotAuthorized() public useIndexer useAllocation(1 ether) { - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, 100 ether, 0, 0); - - resetPrank(users.delegator); - vm.expectRevert("!auth"); - staking.closeAllocation(_allocationId, 0x0); - } - - function testCloseAllocation_WithDelegation( - uint256 tokens, - uint256 delegationTokens, - uint32 indexingRewardCut - ) public useIndexer useAllocation(1 ether) { - tokens = bound(tokens, 2, MAX_STAKING_TOKENS); - delegationTokens = bound(delegationTokens, 0, MAX_STAKING_TOKENS); - vm.assume(indexingRewardCut <= MAX_PPM); - - uint256 legacyAllocationTokens = tokens / 2; - uint256 provisionTokens = tokens - legacyAllocationTokens; - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, provisionTokens, 0, 0); - _setStorage_DelegationPool(users.indexer, delegationTokens, indexingRewardCut, 0); - - // Skip 15 epochs - vm.roll(15); - - _closeAllocation(_allocationId, _poi); - } -} diff --git a/packages/horizon/test/unit/staking/allocation/collect.t.sol b/packages/horizon/test/unit/staking/allocation/collect.t.sol deleted file mode 100644 index 31a5138b2..000000000 --- a/packages/horizon/test/unit/staking/allocation/collect.t.sol +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.27; - -import "forge-std/Test.sol"; - -import { HorizonStakingTest } from "../HorizonStaking.t.sol"; -import { ExponentialRebates } from "../../../../contracts/staking/libraries/ExponentialRebates.sol"; -import { PPMMath } from "../../../../contracts/libraries/PPMMath.sol"; - -contract HorizonStakingCollectAllocationTest is HorizonStakingTest { - using PPMMath for uint256; - - /* - * TESTS - */ - - function testCollectAllocation_RevertWhen_InvalidAllocationId( - uint256 tokens - ) public useIndexer useAllocation(1 ether) { - vm.expectRevert("!alloc"); - staking.collect(tokens, address(0)); - } - - function testCollectAllocation_RevertWhen_Null(uint256 tokens) public { - vm.expectRevert("!collect"); - staking.collect(tokens, _allocationId); - } - - function testCollect_Tokens( - uint256 allocationTokens, - uint256 collectTokens, - uint256 curationTokens, - uint32 curationPercentage, - uint32 protocolTaxPercentage, - uint256 delegationTokens, - uint32 queryFeeCut - ) public useIndexer useRebateParameters useAllocation(allocationTokens) { - collectTokens = bound(collectTokens, 0, MAX_STAKING_TOKENS); - curationTokens = bound(curationTokens, 0, MAX_STAKING_TOKENS); - delegationTokens = bound(delegationTokens, 0, MAX_STAKING_TOKENS); - vm.assume(curationPercentage <= MAX_PPM); - vm.assume(protocolTaxPercentage <= MAX_PPM); - vm.assume(queryFeeCut <= MAX_PPM); - - resetPrank(users.indexer); - _setStorage_ProtocolTaxAndCuration(curationPercentage, protocolTaxPercentage); - console.log("queryFeeCut", queryFeeCut); - _setStorage_DelegationPool(users.indexer, delegationTokens, 0, queryFeeCut); - curation.signal(_subgraphDeploymentID, curationTokens); - - resetPrank(users.gateway); - approve(address(staking), collectTokens); - _collect(collectTokens, _allocationId); - } - - function testCollect_WithBeneficiaryAddress( - uint256 allocationTokens, - uint256 collectTokens - ) public useIndexer useRebateParameters useAllocation(allocationTokens) { - collectTokens = bound(collectTokens, 0, MAX_STAKING_TOKENS); - - address beneficiary = makeAddr("beneficiary"); - _setStorage_RewardsDestination(users.indexer, beneficiary); - - resetPrank(users.gateway); - approve(address(staking), collectTokens); - _collect(collectTokens, _allocationId); - - uint256 newRebates = ExponentialRebates.exponentialRebates( - collectTokens, - allocationTokens, - alphaNumerator, - alphaDenominator, - lambdaNumerator, - lambdaDenominator - ); - uint256 payment = newRebates > collectTokens ? collectTokens : newRebates; - - assertEq(token.balanceOf(beneficiary), payment); - } -} diff --git a/packages/horizon/test/unit/staking/delegation/legacyWithdraw.t.sol b/packages/horizon/test/unit/staking/delegation/legacyWithdraw.t.sol index e5ba447e4..400d5a4f1 100644 --- a/packages/horizon/test/unit/staking/delegation/legacyWithdraw.t.sol +++ b/packages/horizon/test/unit/staking/delegation/legacyWithdraw.t.sol @@ -5,7 +5,6 @@ import "forge-std/Test.sol"; import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { LinkedList } from "../../../../contracts/libraries/LinkedList.sol"; import { HorizonStakingTest } from "../HorizonStaking.t.sol"; diff --git a/packages/horizon/test/unit/staking/governance/governance.t.sol b/packages/horizon/test/unit/staking/governance/governance.t.sol index 2fe4a46da..040629b05 100644 --- a/packages/horizon/test/unit/staking/governance/governance.t.sol +++ b/packages/horizon/test/unit/staking/governance/governance.t.sol @@ -39,19 +39,6 @@ contract HorizonStakingGovernanceTest is HorizonStakingTest { staking.setDelegationSlashingEnabled(); } - function testGovernance_ClearThawingPeriod(uint32 thawingPeriod) public useGovernor { - // simulate previous thawing period - _setStorage_DeprecatedThawingPeriod(thawingPeriod); - - _clearThawingPeriod(); - } - - function testGovernance_ClearThawingPeriod_NotGovernor() public useIndexer { - bytes memory expectedError = abi.encodeWithSignature("ManagedOnlyGovernor()"); - vm.expectRevert(expectedError); - staking.clearThawingPeriod(); - } - function testGovernance__SetMaxThawingPeriod(uint64 maxThawingPeriod) public useGovernor { _setMaxThawingPeriod(maxThawingPeriod); } diff --git a/packages/horizon/test/unit/staking/provision/provision.t.sol b/packages/horizon/test/unit/staking/provision/provision.t.sol index c87e13a45..8cfafff5b 100644 --- a/packages/horizon/test/unit/staking/provision/provision.t.sol +++ b/packages/horizon/test/unit/staking/provision/provision.t.sol @@ -96,22 +96,6 @@ contract HorizonStakingProvisionTest is HorizonStakingTest { staking.provision(users.indexer, subgraphDataServiceAddress, amount, maxVerifierCut, thawingPeriod); } - function testProvision_RevertWhen_VerifierIsNotSubgraphDataServiceDuringTransitionPeriod( - uint256 amount - ) public useIndexer useStake(amount) { - // simulate the transition period - _setStorage_DeprecatedThawingPeriod(THAWING_PERIOD_IN_BLOCKS); - - // oddly we use subgraphDataServiceLegacyAddress as the subgraph service address - // so subgraphDataServiceAddress is not the subgraph service ¯\_(ツ)_/¯ - bytes memory expectedError = abi.encodeWithSignature( - "HorizonStakingInvalidVerifier(address)", - subgraphDataServiceAddress - ); - vm.expectRevert(expectedError); - staking.provision(users.indexer, subgraphDataServiceAddress, amount, 0, 0); - } - function testProvision_AddTokensToProvision( uint256 amount, uint32 maxVerifierCut, diff --git a/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol b/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol index 9d6a87fc0..af4449211 100644 --- a/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol +++ b/packages/horizon/test/unit/staking/serviceProvider/serviceProvider.t.sol @@ -101,37 +101,6 @@ contract HorizonStakingServiceProviderTest is HorizonStakingTest { assertEq(providerTokensAvailable, amount); } - function testServiceProvider_HasStake( - uint256 amount - ) public useIndexer useProvision(amount, MAX_PPM, MAX_THAWING_PERIOD) { - assertTrue(staking.hasStake(users.indexer)); - - _thaw(users.indexer, subgraphDataServiceAddress, amount); - skip(MAX_THAWING_PERIOD + 1); - _deprovision(users.indexer, subgraphDataServiceAddress, 0); - staking.unstake(amount); - - assertFalse(staking.hasStake(users.indexer)); - } - - function testServiceProvider_GetIndexerStakedTokens( - uint256 amount - ) public useIndexer useProvision(amount, MAX_PPM, MAX_THAWING_PERIOD) { - assertEq(staking.getIndexerStakedTokens(users.indexer), amount); - - _thaw(users.indexer, subgraphDataServiceAddress, amount); - // Does not discount thawing tokens - assertEq(staking.getIndexerStakedTokens(users.indexer), amount); - - skip(MAX_THAWING_PERIOD + 1); - _deprovision(users.indexer, subgraphDataServiceAddress, 0); - // Does not discount thawing tokens - assertEq(staking.getIndexerStakedTokens(users.indexer), amount); - - staking.unstake(amount); - assertEq(staking.getIndexerStakedTokens(users.indexer), 0); - } - function testServiceProvider_RevertIf_InvalidDelegationFeeCut( uint256 cut, uint8 paymentTypeInput diff --git a/packages/horizon/test/unit/staking/slash/legacySlash.t.sol b/packages/horizon/test/unit/staking/slash/legacySlash.t.sol deleted file mode 100644 index 1af4670db..000000000 --- a/packages/horizon/test/unit/staking/slash/legacySlash.t.sol +++ /dev/null @@ -1,253 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.27; - -import "forge-std/Test.sol"; - -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; - -import { HorizonStakingTest } from "../HorizonStaking.t.sol"; - -contract HorizonStakingLegacySlashTest is HorizonStakingTest { - /* - * MODIFIERS - */ - - modifier useLegacySlasher(address slasher) { - bytes32 storageKey = keccak256(abi.encode(slasher, 18)); - vm.store(address(staking), storageKey, bytes32(uint256(1))); - _; - } - - /* - * HELPERS - */ - - function _setIndexer( - address _indexer, - uint256 _tokensStaked, - uint256 _tokensAllocated, - uint256 _tokensLocked, - uint256 _tokensLockedUntil - ) public { - bytes32 baseSlot = keccak256(abi.encode(_indexer, 14)); - - vm.store(address(staking), bytes32(uint256(baseSlot)), bytes32(_tokensStaked)); - vm.store(address(staking), bytes32(uint256(baseSlot) + 1), bytes32(_tokensAllocated)); - vm.store(address(staking), bytes32(uint256(baseSlot) + 2), bytes32(_tokensLocked)); - vm.store(address(staking), bytes32(uint256(baseSlot) + 3), bytes32(_tokensLockedUntil)); - } - - /* - * ACTIONS - */ - - function _legacySlash(address _indexer, uint256 _tokens, uint256 _rewards, address _beneficiary) internal { - // before - uint256 beforeStakingBalance = token.balanceOf(address(staking)); - uint256 beforeRewardsDestinationBalance = token.balanceOf(_beneficiary); - ServiceProviderInternal memory beforeIndexer = _getStorage_ServiceProviderInternal(_indexer); - - // calculate slashable stake - uint256 slashableStake = beforeIndexer.tokensStaked - beforeIndexer.tokensProvisioned; - uint256 actualTokens = _tokens; - uint256 actualRewards = _rewards; - if (slashableStake == 0) { - actualTokens = 0; - actualRewards = 0; - } else if (_tokens > slashableStake) { - actualRewards = (_rewards * slashableStake) / _tokens; - actualTokens = slashableStake; - } - - // slash - vm.expectEmit(address(staking)); - emit IHorizonStakingExtension.StakeSlashed(_indexer, actualTokens, actualRewards, _beneficiary); - staking.slash(_indexer, _tokens, _rewards, _beneficiary); - - // after - uint256 afterStakingBalance = token.balanceOf(address(staking)); - uint256 afterRewardsDestinationBalance = token.balanceOf(_beneficiary); - ServiceProviderInternal memory afterIndexer = _getStorage_ServiceProviderInternal(_indexer); - - assertEq(beforeStakingBalance - actualTokens, afterStakingBalance); - assertEq(beforeRewardsDestinationBalance, afterRewardsDestinationBalance - actualRewards); - assertEq(afterIndexer.tokensStaked, beforeIndexer.tokensStaked - actualTokens); - } - - /* - * TESTS - */ - function testSlash_Legacy( - uint256 tokensStaked, - uint256 tokensProvisioned, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokensStaked > 0); - vm.assume(tokensStaked <= MAX_STAKING_TOKENS); - vm.assume(tokensProvisioned > 0); - vm.assume(tokensProvisioned <= tokensStaked); - slashTokens = bound(slashTokens, 1, tokensStaked); - reward = bound(reward, 0, slashTokens); - - _stake(tokensStaked); - _provision(users.indexer, subgraphDataServiceLegacyAddress, tokensProvisioned, 0, 0); - - resetPrank(users.legacySlasher); - _legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_UsingLockedTokens( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 1); - slashTokens = bound(slashTokens, 1, tokens); - reward = bound(reward, 0, slashTokens); - - _setIndexer(users.indexer, tokens, 0, tokens, block.timestamp + 1); - // Send tokens manually to staking - token.transfer(address(staking), tokens); - - resetPrank(users.legacySlasher); - _legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_UsingAllocatedTokens( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 1); - slashTokens = bound(slashTokens, 1, tokens); - reward = bound(reward, 0, slashTokens); - - _setIndexer(users.indexer, tokens, 0, tokens, 0); - // Send tokens manually to staking - token.transfer(address(staking), tokens); - - resetPrank(users.legacySlasher); - staking.legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_CallerNotSlasher( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer { - vm.assume(tokens > 0); - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - vm.expectRevert("!slasher"); - staking.legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_RewardsOverSlashTokens( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 0); - vm.assume(slashTokens > 0); - vm.assume(reward > slashTokens); - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - resetPrank(users.legacySlasher); - vm.expectRevert("rewards>slash"); - staking.legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_NoStake( - uint256 slashTokens, - uint256 reward - ) public useLegacySlasher(users.legacySlasher) { - vm.assume(slashTokens > 0); - reward = bound(reward, 0, slashTokens); - - resetPrank(users.legacySlasher); - vm.expectRevert("!stake"); - staking.legacySlash(users.indexer, slashTokens, reward, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_ZeroTokens( - uint256 tokens - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 0); - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - resetPrank(users.legacySlasher); - vm.expectRevert("!tokens"); - staking.legacySlash(users.indexer, 0, 0, makeAddr("fisherman")); - } - - function testSlash_Legacy_RevertWhen_NoBeneficiary( - uint256 tokens, - uint256 slashTokens, - uint256 reward - ) public useIndexer useLegacySlasher(users.legacySlasher) { - vm.assume(tokens > 0); - slashTokens = bound(slashTokens, 1, tokens); - reward = bound(reward, 0, slashTokens); - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, 0); - - resetPrank(users.legacySlasher); - vm.expectRevert("!beneficiary"); - staking.legacySlash(users.indexer, slashTokens, reward, address(0)); - } - - function test_LegacySlash_WhenTokensAllocatedGreaterThanStake() - public - useIndexer - useLegacySlasher(users.legacySlasher) - { - // Setup indexer with: - // - tokensStaked = 1000 GRT - // - tokensAllocated = 800 GRT - // - tokensLocked = 300 GRT - // This means tokensUsed (1100 GRT) > tokensStaked (1000 GRT) - _setIndexer( - users.indexer, - 1000 ether, // tokensStaked - 800 ether, // tokensAllocated - 300 ether, // tokensLocked - 0 // tokensLockedUntil - ); - - // Send tokens manually to staking - token.transfer(address(staking), 1100 ether); - - resetPrank(users.legacySlasher); - _legacySlash(users.indexer, 1000 ether, 500 ether, makeAddr("fisherman")); - } - - function test_LegacySlash_WhenDelegateCallFails() public useIndexer useLegacySlasher(users.legacySlasher) { - // Setup indexer with: - // - tokensStaked = 1000 GRT - // - tokensAllocated = 800 GRT - // - tokensLocked = 300 GRT - - _setIndexer( - users.indexer, - 1000 ether, // tokensStaked - 800 ether, // tokensAllocated - 300 ether, // tokensLocked - 0 // tokensLockedUntil - ); - - // Send tokens manually to staking - token.transfer(address(staking), 1100 ether); - - // Change staking extension code to an invalid opcode so the delegatecall reverts - address stakingExtension = staking.getStakingExtension(); - vm.etch(stakingExtension, hex"fe"); - - resetPrank(users.legacySlasher); - bytes memory expectedError = abi.encodeWithSignature("HorizonStakingLegacySlashFailed()"); - vm.expectRevert(expectedError); - staking.slash(users.indexer, 1000 ether, 500 ether, makeAddr("fisherman")); - } -} diff --git a/packages/horizon/test/unit/staking/stake/unstake.t.sol b/packages/horizon/test/unit/staking/stake/unstake.t.sol index 83c6a0a81..147edf233 100644 --- a/packages/horizon/test/unit/staking/stake/unstake.t.sol +++ b/packages/horizon/test/unit/staking/stake/unstake.t.sol @@ -26,79 +26,6 @@ contract HorizonStakingUnstakeTest is HorizonStakingTest { _unstake(tokensToUnstake); } - function testUnstake_LockingPeriodGreaterThanZero_NoThawing( - uint256 tokens, - uint256 tokensToUnstake, - uint32 maxVerifierCut, - uint64 thawingPeriod - ) public useIndexer useProvision(tokens, maxVerifierCut, thawingPeriod) { - tokensToUnstake = bound(tokensToUnstake, 1, tokens); - - // simulate transition period - _setStorage_DeprecatedThawingPeriod(THAWING_PERIOD_IN_BLOCKS); - - // thaw, wait and deprovision - _thaw(users.indexer, subgraphDataServiceAddress, tokens); - skip(thawingPeriod + 1); - _deprovision(users.indexer, subgraphDataServiceAddress, 0); - - // unstake - _unstake(tokensToUnstake); - } - - function testUnstake_LockingPeriodGreaterThanZero_TokensDoneThawing( - uint256 tokens, - uint256 tokensToUnstake, - uint256 tokensLocked - ) public useIndexer { - // bounds - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - tokensToUnstake = bound(tokensToUnstake, 1, tokens); - tokensLocked = bound(tokensLocked, 1, MAX_STAKING_TOKENS); - - // simulate locked tokens with past locking period - _setStorage_DeprecatedThawingPeriod(THAWING_PERIOD_IN_BLOCKS); - token.transfer(address(staking), tokensLocked); - _setStorage_ServiceProvider(users.indexer, tokensLocked, 0, tokensLocked, block.number, 0); - - // create provision, thaw and deprovision - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, MAX_THAWING_PERIOD); - _thaw(users.indexer, subgraphDataServiceLegacyAddress, tokens); - skip(MAX_THAWING_PERIOD + 1); - _deprovision(users.indexer, subgraphDataServiceLegacyAddress, 0); - - // unstake - _unstake(tokensToUnstake); - } - - function testUnstake_LockingPeriodGreaterThanZero_TokensStillThawing( - uint256 tokens, - uint256 tokensToUnstake, - uint256 tokensThawing, - uint32 tokensThawingUntilBlock - ) public useIndexer { - // bounds - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - tokensToUnstake = bound(tokensToUnstake, 1, tokens); - tokensThawing = bound(tokensThawing, 1, MAX_STAKING_TOKENS); - vm.assume(tokensThawingUntilBlock > block.number); - vm.assume(tokensThawingUntilBlock < block.number + THAWING_PERIOD_IN_BLOCKS); - - // simulate locked tokens still thawing - _setStorage_DeprecatedThawingPeriod(THAWING_PERIOD_IN_BLOCKS); - token.transfer(address(staking), tokensThawing); - _setStorage_ServiceProvider(users.indexer, tokensThawing, 0, tokensThawing, tokensThawingUntilBlock, 0); - - // create provision, thaw and deprovision - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, MAX_THAWING_PERIOD); - _thaw(users.indexer, subgraphDataServiceLegacyAddress, tokens); - skip(MAX_THAWING_PERIOD + 1); - _deprovision(users.indexer, subgraphDataServiceLegacyAddress, 0); - - // unstake - _unstake(tokensToUnstake); - } - function testUnstake_RevertWhen_ZeroTokens( uint256 amount, uint32 maxVerifierCut, diff --git a/packages/horizon/test/unit/staking/stake/withdraw.t.sol b/packages/horizon/test/unit/staking/stake/withdraw.t.sol index eac19e416..188a3dcee 100644 --- a/packages/horizon/test/unit/staking/stake/withdraw.t.sol +++ b/packages/horizon/test/unit/staking/stake/withdraw.t.sol @@ -37,19 +37,4 @@ contract HorizonStakingWithdrawTest is HorizonStakingTest { vm.expectRevert(abi.encodeWithSelector(IHorizonStakingMain.HorizonStakingInvalidZeroTokens.selector)); staking.withdraw(); } - - function testWithdraw_RevertWhen_StillThawing(uint256 tokens, uint256 tokensLocked) public useIndexer { - tokens = bound(tokens, 1, MAX_STAKING_TOKENS); - tokensLocked = bound(tokensLocked, 1, tokens); - - // simulate locked tokens still thawing - uint256 thawUntil = block.timestamp + 1; - token.transfer(address(staking), tokens); - _setStorage_ServiceProvider(users.indexer, tokens, 0, tokensLocked, thawUntil, 0); - - _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, MAX_THAWING_PERIOD); - - vm.expectRevert(abi.encodeWithSelector(IHorizonStakingMain.HorizonStakingStillThawing.selector, thawUntil)); - staking.withdraw(); - } } diff --git a/packages/horizon/test/unit/utilities/GraphDirectory.t.sol b/packages/horizon/test/unit/utilities/GraphDirectory.t.sol index 180590a1e..6399be255 100644 --- a/packages/horizon/test/unit/utilities/GraphDirectory.t.sol +++ b/packages/horizon/test/unit/utilities/GraphDirectory.t.sol @@ -19,8 +19,7 @@ contract GraphDirectoryTest is GraphBaseTest { _getContractFromController("EpochManager"), _getContractFromController("RewardsManager"), _getContractFromController("GraphTokenGateway"), - _getContractFromController("GraphProxyAdmin"), - _getContractFromController("Curation") + _getContractFromController("GraphProxyAdmin") ); _deployImplementation(address(controller)); } @@ -49,7 +48,6 @@ contract GraphDirectoryTest is GraphBaseTest { assertEq(_getContractFromController("RewardsManager"), address(directory.graphRewardsManager())); assertEq(_getContractFromController("GraphTokenGateway"), address(directory.graphTokenGateway())); assertEq(_getContractFromController("GraphProxyAdmin"), address(directory.graphProxyAdmin())); - assertEq(_getContractFromController("Curation"), address(directory.graphCuration())); } function test_RevertWhen_AnInvalidContractGetterIsCalled() external { diff --git a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol index 4a88bf0cd..f10741296 100644 --- a/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol +++ b/packages/horizon/test/unit/utilities/GraphDirectoryImplementation.sol @@ -12,7 +12,6 @@ import { IEpochManager } from "@graphprotocol/interfaces/contracts/contracts/epo import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; import { ITokenGateway } from "@graphprotocol/interfaces/contracts/contracts/arbitrum/ITokenGateway.sol"; import { IGraphProxyAdmin } from "@graphprotocol/interfaces/contracts/contracts/upgrades/IGraphProxyAdmin.sol"; -import { ICuration } from "@graphprotocol/interfaces/contracts/contracts/curation/ICuration.sol"; import { GraphDirectory } from "./../../../contracts/utilities/GraphDirectory.sol"; @@ -57,8 +56,4 @@ contract GraphDirectoryImplementation is GraphDirectory { function graphProxyAdmin() external view returns (IGraphProxyAdmin) { return _graphProxyAdmin(); } - - function graphCuration() external view returns (ICuration) { - return _graphCuration(); - } } From 1d2b76d1f217bb306d0f5be3b048b46b4d97ebe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 1 Dec 2025 09:25:14 -0300 Subject: [PATCH 24/59] fix: more cleanup on horizon, interfaces and toolshed packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/staking/HorizonStaking.sol | 43 +- .../contracts/staking/HorizonStakingBase.sol | 5 + .../ignition/modules/core/HorizonStaking.ts | 33 +- .../test/deployment/HorizonStaking.test.ts | 12 +- .../delegator.test.ts | 143 ----- .../multicall.test.ts | 114 ---- .../during-transition-period/operator.test.ts | 99 ---- .../permissionless.test.ts | 66 --- .../service-provider.test.ts | 521 ------------------ .../during-transition-period/slasher.test.ts | 88 --- packages/horizon/test/unit/GraphBase.t.sol | 2 +- .../horizon/internal/IHorizonStakingBase.sol | 6 + .../src/deployments/horizon/actions.ts | 21 - 13 files changed, 16 insertions(+), 1137 deletions(-) delete mode 100644 packages/horizon/test/integration/during-transition-period/delegator.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/multicall.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/operator.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/permissionless.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/service-provider.test.ts delete mode 100644 packages/horizon/test/integration/during-transition-period/slasher.test.ts diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 5384e7698..66aa35b5d 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -38,9 +38,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { /// @dev Maximum number of simultaneous stake thaw requests (per provision) or undelegations (per delegation) uint256 private constant MAX_THAW_REQUESTS = 1_000; - /// @dev Address of the staking extension contract - address private immutable STAKING_EXTENSION_ADDRESS; - /// @dev Minimum amount of delegation. uint256 private constant MIN_DELEGATION = 1e18; @@ -73,50 +70,12 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { /** * @notice The staking contract is upgradeable however we still use the constructor to set a few immutable variables * @param controller The address of the Graph controller contract - * @param stakingExtensionAddress The address of the staking extension contract * @param subgraphDataServiceAddress The address of the subgraph data service */ constructor( address controller, - address stakingExtensionAddress, address subgraphDataServiceAddress - ) HorizonStakingBase(controller, subgraphDataServiceAddress) { - STAKING_EXTENSION_ADDRESS = stakingExtensionAddress; - } - - /** - * @notice Delegates the current call to the StakingExtension implementation. - * @dev This function does not return to its internal call site, it will return directly to the - * external caller. - */ - fallback() external { - // solhint-disable-previous-line payable-fallback, no-complex-fallback - address extensionImpl = STAKING_EXTENSION_ADDRESS; - // solhint-disable-next-line no-inline-assembly - assembly { - // (a) get free memory pointer - let ptr := mload(0x40) - - // (1) copy incoming call data - calldatacopy(ptr, 0, calldatasize()) - - // (2) forward call to logic contract - let result := delegatecall(gas(), extensionImpl, ptr, calldatasize(), 0, 0) - let size := returndatasize() - - // (3) retrieve return data - returndatacopy(ptr, 0, size) - - // (4) forward return data back to caller - switch result - case 0 { - revert(ptr, size) - } - default { - return(ptr, size) - } - } - } + ) HorizonStakingBase(controller, subgraphDataServiceAddress) {} /* * STAKING diff --git a/packages/horizon/contracts/staking/HorizonStakingBase.sol b/packages/horizon/contracts/staking/HorizonStakingBase.sol index 520bd4dd6..75e2cd37c 100644 --- a/packages/horizon/contracts/staking/HorizonStakingBase.sol +++ b/packages/horizon/contracts/staking/HorizonStakingBase.sol @@ -52,6 +52,11 @@ abstract contract HorizonStakingBase is SUBGRAPH_DATA_SERVICE_ADDRESS = subgraphDataServiceAddress; } + /// @inheritdoc IHorizonStakingBase + function getSubgraphService() external view override returns (address) { + return SUBGRAPH_DATA_SERVICE_ADDRESS; + } + /// @inheritdoc IHorizonStakingBase /// @dev Removes deprecated fields from the return value. function getServiceProvider(address serviceProvider) external view override returns (ServiceProvider memory) { diff --git a/packages/horizon/ignition/modules/core/HorizonStaking.ts b/packages/horizon/ignition/modules/core/HorizonStaking.ts index c4044b0af..a7bec9076 100644 --- a/packages/horizon/ignition/modules/core/HorizonStaking.ts +++ b/packages/horizon/ignition/modules/core/HorizonStaking.ts @@ -3,8 +3,6 @@ import GraphProxyAdminArtifact from '@graphprotocol/contracts/artifacts/contract import { buildModule } from '@nomicfoundation/hardhat-ignition/modules' import HorizonStakingArtifact from '../../../build/contracts/contracts/staking/HorizonStaking.sol/HorizonStaking.json' -import HorizonStakingExtensionArtifact from '../../../build/contracts/contracts/staking/HorizonStakingExtension.sol/HorizonStakingExtension.json' -import ExponentialRebatesArtifact from '../../../build/contracts/contracts/staking/libraries/ExponentialRebates.sol/ExponentialRebates.json' import GraphPeripheryModule, { MigratePeripheryModule } from '../periphery/periphery' import { upgradeGraphProxy } from '../proxy/GraphProxy' import { deployImplementation } from '../proxy/implementation' @@ -17,25 +15,11 @@ export default buildModule('HorizonStaking', (m) => { const subgraphServiceAddress = m.getParameter('subgraphServiceAddress') const maxThawingPeriod = m.getParameter('maxThawingPeriod') - // Deploy HorizonStakingExtension - requires periphery and proxies to be registered in the controller - const ExponentialRebates = m.library('ExponentialRebates', ExponentialRebatesArtifact) - const HorizonStakingExtension = m.contract( - 'HorizonStakingExtension', - HorizonStakingExtensionArtifact, - [Controller, subgraphServiceAddress], - { - libraries: { - ExponentialRebates: ExponentialRebates, - }, - after: [GraphPeripheryModule, HorizonProxiesModule], - }, - ) - // Deploy HorizonStaking implementation const HorizonStakingImplementation = deployImplementation(m, { name: 'HorizonStaking', artifact: HorizonStakingArtifact, - constructorArgs: [Controller, HorizonStakingExtension, subgraphServiceAddress], + constructorArgs: [Controller, subgraphServiceAddress], }) // Upgrade proxy to implementation contract @@ -61,24 +45,11 @@ export const MigrateHorizonStakingDeployerModule = buildModule('HorizonStakingDe const HorizonStakingProxy = m.contractAt('HorizonStakingProxy', GraphProxyArtifact, horizonStakingAddress) - // Deploy HorizonStakingExtension - requires periphery and proxies to be registered in the controller - const ExponentialRebates = m.library('ExponentialRebates', ExponentialRebatesArtifact) - const HorizonStakingExtension = m.contract( - 'HorizonStakingExtension', - HorizonStakingExtensionArtifact, - [Controller, subgraphServiceAddress], - { - libraries: { - ExponentialRebates: ExponentialRebates, - }, - }, - ) - // Deploy HorizonStaking implementation const HorizonStakingImplementation = deployImplementation(m, { name: 'HorizonStaking', artifact: HorizonStakingArtifact, - constructorArgs: [Controller, HorizonStakingExtension, subgraphServiceAddress], + constructorArgs: [Controller, subgraphServiceAddress], }) return { HorizonStakingProxy, HorizonStakingImplementation } diff --git a/packages/horizon/test/deployment/HorizonStaking.test.ts b/packages/horizon/test/deployment/HorizonStaking.test.ts index fed2af75f..f60d92b52 100644 --- a/packages/horizon/test/deployment/HorizonStaking.test.ts +++ b/packages/horizon/test/deployment/HorizonStaking.test.ts @@ -1,5 +1,5 @@ import { loadConfig } from '@graphprotocol/toolshed/hardhat' -import { assert, expect } from 'chai' +import { expect } from 'chai' import hre from 'hardhat' import { graphProxyTests } from './lib/GraphProxy.test' @@ -27,16 +27,6 @@ describe('HorizonStaking', function () { expect(delegationSlashingEnabled).to.equal(false) }) - testIf(4)('should set a non zero thawing period', async function () { - if (process.env.IGNITION_DEPLOYMENT_TYPE === 'protocol') { - assert.fail('Deployment type "protocol": no historical state available') - } - const thawingPeriod = await HorizonStaking.__DEPRECATED_getThawingPeriod() - expect(thawingPeriod).to.not.equal(0) - }) - - it.skip('should set the right staking extension address') - testIf(4)('should set the right subgraph data service address', async function () { const subgraphDataServiceAddress = await HorizonStaking.getSubgraphService() expect(subgraphDataServiceAddress).to.equal(config.$global.subgraphServiceAddress) diff --git a/packages/horizon/test/integration/during-transition-period/delegator.test.ts b/packages/horizon/test/integration/during-transition-period/delegator.test.ts deleted file mode 100644 index 352599f18..000000000 --- a/packages/horizon/test/integration/during-transition-period/delegator.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { ZERO_ADDRESS } from '@graphprotocol/toolshed' -import { delegators } from '@graphprotocol/toolshed/fixtures' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Delegator', () => { - let snapshotId: string - - const thawingPeriod = 2419200n // 28 days - - // Subgraph service address is not set for integration tests - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - const graphToken = graph.horizon.contracts.L2GraphToken - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Existing Protocol Users', () => { - describe('User undelegated before horizon was deployed', () => { - let indexer: HardhatEthersSigner - let delegator: HardhatEthersSigner - let tokens: bigint - - before(async () => { - const delegatorFixture = delegators[2] - const delegationFixture = delegatorFixture.delegations[0] - - // Verify delegator is undelegated - expect(delegatorFixture.undelegate).to.be.true - - // Get signers - indexer = await ethers.getSigner(delegationFixture.indexerAddress) - delegator = await ethers.getSigner(delegatorFixture.address) - - // Get tokens - tokens = delegationFixture.tokens - }) - - it('should be able to withdraw their tokens after the thawing period', async () => { - // Get the thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Mine remaining blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get delegator balance before withdrawing - const balanceBefore = await graphToken.balanceOf(delegator.address) - - // Withdraw tokens - await horizonStaking.connect(delegator)['withdrawDelegated(address,address)'](indexer.address, ZERO_ADDRESS) - - // Get delegator balance after withdrawing - const balanceAfter = await graphToken.balanceOf(delegator.address) - - // Expected balance after is the balance before plus the tokens minus the 0.5% delegation tax - const expectedBalanceAfter = balanceBefore + tokens - (tokens * 5000n) / 1000000n - - // Verify tokens are withdrawn - expect(balanceAfter).to.equal(expectedBalanceAfter) - }) - - it('should revert if the thawing period has not passed', async () => { - // Withdraw tokens - await expect( - horizonStaking.connect(delegator)['withdrawDelegated(address,address)'](indexer.address, ZERO_ADDRESS), - ).to.be.revertedWithCustomError(horizonStaking, 'HorizonStakingNothingToWithdraw') - }) - }) - - describe('Transition period is over', () => { - let governor: HardhatEthersSigner - let indexer: HardhatEthersSigner - let delegator: HardhatEthersSigner - let tokens: bigint - - before(async () => { - const delegatorFixture = delegators[0] - const delegationFixture = delegatorFixture.delegations[0] - - // Get signers - governor = await graph.accounts.getGovernor() - indexer = await ethers.getSigner(delegationFixture.indexerAddress) - delegator = await ethers.getSigner(delegatorFixture.address) - - // Get tokens - tokens = delegationFixture.tokens - }) - - it('should be able to undelegate during transition period and withdraw after transition period', async () => { - // Get delegator's delegation - const delegation = await horizonStaking.getDelegation( - indexer.address, - subgraphServiceAddress, - delegator.address, - ) - - // Undelegate tokens - await horizonStaking - .connect(delegator) - ['undelegate(address,address,uint256)'](indexer.address, subgraphServiceAddress, delegation.shares) - - // Wait for thawing period - await ethers.provider.send('evm_increaseTime', [Number(thawingPeriod) + 1]) - await ethers.provider.send('evm_mine', []) - - // Clear thawing period - await horizonStaking.connect(governor).clearThawingPeriod() - - // Get delegator balance before withdrawing - const balanceBefore = await graphToken.balanceOf(delegator.address) - - // Withdraw tokens - await horizonStaking - .connect(delegator) - ['withdrawDelegated(address,address,uint256)'](indexer.address, ZERO_ADDRESS, BigInt(1)) - - // Get delegator balance after withdrawing - const balanceAfter = await graphToken.balanceOf(delegator.address) - - // Expected balance after is the balance before plus the tokens minus the 0.5% delegation tax - // because the delegation was before the horizon upgrade, after the upgrade there is no tax - const expectedBalanceAfter = balanceBefore + tokens - (tokens * 5000n) / 1000000n - - // Verify tokens are withdrawn - expect(balanceAfter).to.equal(expectedBalanceAfter) - }) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/multicall.test.ts b/packages/horizon/test/integration/during-transition-period/multicall.test.ts deleted file mode 100644 index 948cd8f5f..000000000 --- a/packages/horizon/test/integration/during-transition-period/multicall.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { ONE_MILLION, PaymentTypes } from '@graphprotocol/toolshed' -import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Service Provider', () => { - let snapshotId: string - - const maxVerifierCut = 50_000n - const thawingPeriod = 2419200n - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - const graphToken = graph.horizon.contracts.L2GraphToken - - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('New Protocol Users', () => { - let serviceProvider: HardhatEthersSigner - - before(async () => { - ;[, , serviceProvider] = await graph.accounts.getTestAccounts() - await setGRTBalance(graph.provider, graphToken.target, serviceProvider.address, ONE_MILLION) - }) - - it('should allow multicalling stake+provision calls', async () => { - const tokensToStake = ethers.parseEther('1000') - const tokensToProvision = ethers.parseEther('100') - - // check state before - const beforeProvision = await horizonStaking.getProvision(serviceProvider.address, subgraphServiceAddress) - expect(beforeProvision.tokens).to.equal(0) - expect(beforeProvision.maxVerifierCut).to.equal(0) - expect(beforeProvision.thawingPeriod).to.equal(0) - expect(beforeProvision.createdAt).to.equal(0) - - // multicall - await graphToken.connect(serviceProvider).approve(horizonStaking.target, tokensToStake) - const stakeCalldata = horizonStaking.interface.encodeFunctionData('stake', [tokensToStake]) - const provisionCalldata = horizonStaking.interface.encodeFunctionData('provision', [ - serviceProvider.address, - subgraphServiceAddress, - tokensToProvision, - maxVerifierCut, - thawingPeriod, - ]) - await horizonStaking.connect(serviceProvider).multicall([stakeCalldata, provisionCalldata]) - - // check state after - const block = await graph.provider.getBlock('latest') - const afterProvision = await horizonStaking.getProvision(serviceProvider.address, subgraphServiceAddress) - expect(afterProvision.tokens).to.equal(tokensToProvision) - expect(afterProvision.maxVerifierCut).to.equal(maxVerifierCut) - expect(afterProvision.thawingPeriod).to.equal(thawingPeriod) - expect(afterProvision.createdAt).to.equal(block?.timestamp) - }) - - it('should allow multicalling delegation parameter set calls', async () => { - // check state before - const beforeIndexingRewards = await horizonStaking.getDelegationFeeCut( - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.IndexingRewards, - ) - const beforeQueryFee = await horizonStaking.getDelegationFeeCut( - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.QueryFee, - ) - expect(beforeIndexingRewards).to.equal(0) - expect(beforeQueryFee).to.equal(0) - - // multicall - const indexingRewardsCalldata = horizonStaking.interface.encodeFunctionData('setDelegationFeeCut', [ - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.IndexingRewards, - 10_000n, - ]) - const queryFeeCalldata = horizonStaking.interface.encodeFunctionData('setDelegationFeeCut', [ - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.QueryFee, - 12_345n, - ]) - await horizonStaking.connect(serviceProvider).multicall([indexingRewardsCalldata, queryFeeCalldata]) - - // check state after - const afterIndexingRewards = await horizonStaking.getDelegationFeeCut( - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.IndexingRewards, - ) - const afterQueryFee = await horizonStaking.getDelegationFeeCut( - serviceProvider.address, - subgraphServiceAddress, - PaymentTypes.QueryFee, - ) - expect(afterIndexingRewards).to.equal(10_000n) - expect(afterQueryFee).to.equal(12_345n) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/operator.test.ts b/packages/horizon/test/integration/during-transition-period/operator.test.ts deleted file mode 100644 index ab5b26ebf..000000000 --- a/packages/horizon/test/integration/during-transition-period/operator.test.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { generatePOI } from '@graphprotocol/toolshed' -import { indexers } from '@graphprotocol/toolshed/fixtures' -import { getEventData } from '@graphprotocol/toolshed/hardhat' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Operator', () => { - let snapshotId: string - - // Subgraph service address is not set for integration tests - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Existing Protocol Users', () => { - let indexer: HardhatEthersSigner - let operator: HardhatEthersSigner - let allocationID: string - let allocationTokens: bigint - let delegationIndexingCut: number - - before(async () => { - const indexerFixture = indexers[0] - const allocationFixture = indexerFixture.allocations[0] - - // Get signers - indexer = await ethers.getSigner(indexerFixture.address) - ;[operator] = await graph.accounts.getTestAccounts() - - // Get allocation details - allocationID = allocationFixture.allocationID - allocationTokens = allocationFixture.tokens - delegationIndexingCut = indexerFixture.indexingRewardCut - - // Set the operator - await horizonStaking.connect(indexer).setOperator(subgraphServiceAddress, operator.address, true) - }) - - it('should allow the operator to close an open legacy allocation and collect rewards', async () => { - // Use a non-zero POI - const poi = generatePOI('poi') - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Get delegation pool before closing allocation - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Mine blocks to simulate time passing - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get idle stake before closing allocation - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Close allocation - const tx = await horizonStaking.connect(operator).closeAllocation(allocationID, poi) - const eventData = await getEventData( - tx, - 'event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount)', - ) - const rewards = eventData[2] - - // Verify rewards are not zero - expect(rewards).to.not.equal(0, 'Rewards were not transferred to service provider') - - // Verify rewards minus delegation cut are restaked - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - const idleStakeRewardsTokens = (rewards * BigInt(delegationIndexingCut)) / 1000000n - expect(idleStakeAfter).to.equal( - idleStakeBefore + allocationTokens + idleStakeRewardsTokens, - 'Rewards were not restaked', - ) - - // Verify delegators cut is added to delegation pool - const delegationPool = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPool.tokens - const delegationRewardsTokens = rewards - idleStakeRewardsTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationRewardsTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/permissionless.test.ts b/packages/horizon/test/integration/during-transition-period/permissionless.test.ts deleted file mode 100644 index a7d13e302..000000000 --- a/packages/horizon/test/integration/during-transition-period/permissionless.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { generatePOI } from '@graphprotocol/toolshed' -import { indexers } from '@graphprotocol/toolshed/fixtures' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Permissionless', () => { - let snapshotId: string - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - const epochManager = graph.horizon.contracts.EpochManager - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('After max allocation epochs', () => { - let indexer: HardhatEthersSigner - let anySigner: HardhatEthersSigner - let allocationID: string - let allocationTokens: bigint - - before(async () => { - // Get signers - indexer = await ethers.getSigner(indexers[0].address) - ;[anySigner] = await graph.accounts.getTestAccounts() - - // ensure anySigner is not operator for the indexer - await horizonStaking.connect(indexer).setOperator(subgraphServiceAddress, anySigner.address, false) - - // Get allocation details - allocationID = indexers[0].allocations[0].allocationID - allocationTokens = indexers[0].allocations[0].tokens - }) - - it('should allow any user to close an allocation after 28 epochs', async () => { - // Get indexer's idle stake before closing allocation - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Mine blocks to simulate 28 epochs passing - const startingEpoch = await epochManager.currentEpoch() - while ((await epochManager.currentEpoch()) - startingEpoch < 28) { - await ethers.provider.send('evm_mine', []) - } - - // Close allocation - const poi = generatePOI('poi') - await horizonStaking.connect(anySigner).closeAllocation(allocationID, poi) - - // Get indexer's idle stake after closing allocation - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - - // Verify allocation tokens were added to indexer's idle stake but no rewards were collected - expect(idleStakeAfter).to.be.equal(idleStakeBefore + allocationTokens) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/service-provider.test.ts b/packages/horizon/test/integration/during-transition-period/service-provider.test.ts deleted file mode 100644 index 0be3c6112..000000000 --- a/packages/horizon/test/integration/during-transition-period/service-provider.test.ts +++ /dev/null @@ -1,521 +0,0 @@ -import { generatePOI, ONE_MILLION } from '@graphprotocol/toolshed' -import { indexers } from '@graphprotocol/toolshed/fixtures' -import { getEventData, setGRTBalance } from '@graphprotocol/toolshed/hardhat' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Service Provider', () => { - let snapshotId: string - - const graph = hre.graph() - const { stake, collect } = graph.horizon.actions - const horizonStaking = graph.horizon.contracts.HorizonStaking - const graphToken = graph.horizon.contracts.L2GraphToken - - // Subgraph service address is not set for integration tests - const subgraphServiceAddress = '0x0000000000000000000000000000000000000000' - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('New Protocol Users', () => { - let serviceProvider: HardhatEthersSigner - let tokensToStake = ethers.parseEther('1000') - - before(async () => { - ;[, , serviceProvider] = await graph.accounts.getTestAccounts() - await setGRTBalance(graph.provider, graphToken.target, serviceProvider.address, ONE_MILLION) - - // Stake tokens to service provider - await stake(serviceProvider, [tokensToStake]) - }) - - it('should allow service provider to unstake and withdraw after thawing period', async () => { - const tokensToUnstake = ethers.parseEther('100') - const balanceBefore = await graphToken.balanceOf(serviceProvider.address) - - // First unstake request - await horizonStaking.connect(serviceProvider).unstake(tokensToUnstake) - - // During transition period, tokens are locked by thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Mine remaining blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Now we can withdraw - await horizonStaking.connect(serviceProvider).withdraw() - const balanceAfter = await graphToken.balanceOf(serviceProvider.address) - - expect(balanceAfter).to.equal( - balanceBefore + tokensToUnstake, - 'Tokens were not transferred back to service provider', - ) - }) - - it('should handle multiple unstake requests correctly', async () => { - // Make multiple unstake requests - const request1 = ethers.parseEther('50') - const request2 = ethers.parseEther('75') - - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // First unstake request - await horizonStaking.connect(serviceProvider).unstake(request1) - - // Mine half of thawing period blocks - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Second unstake request - await horizonStaking.connect(serviceProvider).unstake(request2) - - // Mine remaining blocks to complete first unstake thawing period - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Check that withdraw reverts since thawing period is not complete - await expect(horizonStaking.connect(serviceProvider).withdraw()).to.be.revertedWithCustomError( - horizonStaking, - 'HorizonStakingStillThawing', - ) - - // Mine remaining blocks to complete thawing period - for (let i = 0; i < halfThawingPeriod + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(serviceProvider.address) - - // Withdraw all thawed tokens - await horizonStaking.connect(serviceProvider).withdraw() - - // Verify all tokens are withdrawn and transferred back to service provider - const balanceAfter = await graphToken.balanceOf(serviceProvider.address) - expect(balanceAfter).to.equal( - balanceBefore + request1 + request2, - 'Tokens were not transferred back to service provider', - ) - }) - - describe('Transition period is over', () => { - let governor: HardhatEthersSigner - let tokensToUnstake: bigint - - before(async () => { - // Get governor - governor = await graph.accounts.getGovernor() - - // Set tokens - tokensToStake = ethers.parseEther('100000') - tokensToUnstake = ethers.parseEther('10000') - }) - - it('should be able to withdraw tokens that were unstaked during transition period', async () => { - // Stake tokens - await stake(serviceProvider, [tokensToStake]) - - // Unstake tokens - await horizonStaking.connect(serviceProvider).unstake(tokensToUnstake) - - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(serviceProvider.address) - - // Get thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Clear thawing period - await horizonStaking.connect(governor).clearThawingPeriod() - - // Mine blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Withdraw tokens - await horizonStaking.connect(serviceProvider).withdraw() - - // Get balance after withdrawing - const balanceAfter = await graphToken.balanceOf(serviceProvider.address) - expect(balanceAfter).to.equal( - balanceBefore + tokensToUnstake, - 'Tokens were not transferred back to service provider', - ) - }) - - it('should be able to unstake tokens without a thawing period', async () => { - // Stake tokens - await stake(serviceProvider, [tokensToStake]) - - // Clear thawing period - await horizonStaking.connect(governor).clearThawingPeriod() - - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(serviceProvider.address) - - // Unstake tokens - await horizonStaking.connect(serviceProvider).unstake(tokensToUnstake) - - // Get balance after withdrawing - const balanceAfter = await graphToken.balanceOf(serviceProvider.address) - expect(balanceAfter).to.equal( - balanceBefore + tokensToUnstake, - 'Tokens were not transferred back to service provider', - ) - }) - }) - }) - - describe('Existing Protocol Users', () => { - let indexer: HardhatEthersSigner - let tokensUnstaked: bigint - - before(async () => { - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - tokensUnstaked = indexerFixture.tokensToUnstake || 0n - - await setGRTBalance(graph.provider, graphToken.target, indexer.address, ONE_MILLION) - }) - - it('should allow service provider to withdraw their locked tokens after thawing period passes', async () => { - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(indexer.address) - - // Get thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Mine blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Withdraw tokens - await horizonStaking.connect(indexer).withdraw() - - // Verify tokens are transferred back to service provider - const balanceAfter = await graphToken.balanceOf(indexer.address) - expect(balanceAfter).to.equal( - balanceBefore + tokensUnstaked, - 'Tokens were not transferred back to service provider', - ) - }) - - describe('Legacy allocations', () => { - describe('Restaking', () => { - let delegationIndexingCut: number - let delegationQueryFeeCut: number - let allocationID: string - let allocationTokens: bigint - let gateway: HardhatEthersSigner - - beforeEach(async () => { - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - delegationIndexingCut = indexerFixture.indexingRewardCut - delegationQueryFeeCut = indexerFixture.queryFeeCut - allocationID = indexerFixture.allocations[0].allocationID - allocationTokens = indexerFixture.allocations[0].tokens - gateway = await graph.accounts.getGateway() - await setGRTBalance(graph.provider, graphToken.target, gateway.address, ONE_MILLION) - }) - - it('should be able to close an open legacy allocation and collect rewards', async () => { - // Use a non-zero POI - const poi = generatePOI('poi') - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Get delegation pool before closing allocation - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Mine blocks to simulate time passing - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get idle stake before closing allocation - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Close allocation - const tx = await horizonStaking.connect(indexer).closeAllocation(allocationID, poi) - const eventData = await getEventData( - tx, - 'event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount)', - ) - const rewards = eventData[2] - - // Verify rewards are not zero - expect(rewards).to.not.equal(0, 'Rewards were not transferred to service provider') - - // Verify rewards minus delegation cut are restaked - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - const idleStakeRewardsTokens = (rewards * BigInt(delegationIndexingCut)) / 1000000n - expect(idleStakeAfter).to.equal( - idleStakeBefore + allocationTokens + idleStakeRewardsTokens, - 'Rewards were not restaked', - ) - - // Verify delegators cut is added to delegation pool - const delegationPool = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPool.tokens - const delegationRewardsTokens = rewards - idleStakeRewardsTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationRewardsTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - - it('should be able to collect query fees', async () => { - const tokensToCollect = ethers.parseEther('1000') - - // Get idle stake before collecting - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Get delegation pool before collecting - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Collect query fees - await collect(gateway, [tokensToCollect, allocationID]) - - // Get idle stake after collecting - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - - // Subtract protocol tax (1%) and curation fees (10% after the protocol tax deduction) - const protocolTax = (tokensToCollect * 1n) / 100n - const curationFees = (tokensToCollect * 99n) / 1000n - const remainingTokens = tokensToCollect - protocolTax - curationFees - - // Verify tokens minus delegators cut are restaked - const indexerCutTokens = (remainingTokens * BigInt(delegationQueryFeeCut)) / 1000000n - expect(idleStakeAfter).to.equal(idleStakeBefore + indexerCutTokens, 'Indexer cut was not restaked') - - // Verify delegators cut is added to delegation pool - const delegationPool = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPool.tokens - const delegationCutTokens = remainingTokens - indexerCutTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationCutTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - - it('should be able to close an allocation and collect query fees for the closed allocation', async () => { - // Use a non-zero POI - const poi = generatePOI('poi') - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Mine blocks to simulate time passing - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Close allocation - await horizonStaking.connect(indexer).closeAllocation(allocationID, poi) - - // Tokens to collect - const tokensToCollect = ethers.parseEther('1000') - - // Get idle stake before collecting - const idleStakeBefore = await horizonStaking.getIdleStake(indexer.address) - - // Get delegation pool before collecting - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Collect query fees - await collect(gateway, [tokensToCollect, allocationID]) - - // Get idle stake after collecting - const idleStakeAfter = await horizonStaking.getIdleStake(indexer.address) - - // Subtract protocol tax (1%) and curation fees (10% after the protocol tax deduction) - const protocolTax = (tokensToCollect * 1n) / 100n - const curationFees = (tokensToCollect * 99n) / 1000n - const remainingTokens = tokensToCollect - protocolTax - curationFees - - // Verify tokens minus delegators cut are restaked - const indexerCutTokens = (remainingTokens * BigInt(delegationQueryFeeCut)) / 1000000n - expect(idleStakeAfter).to.equal(idleStakeBefore + indexerCutTokens, 'Indexer cut was not restaked') - - // Verify delegators cut is added to delegation pool - const delegationPool = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPool.tokens - const delegationCutTokens = remainingTokens - indexerCutTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationCutTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - }) - - describe('With rewardsDestination set', () => { - let delegationIndexingCut: number - let delegationQueryFeeCut: number - let rewardsDestination: string - let allocationID: string - let gateway: HardhatEthersSigner - - beforeEach(async () => { - const indexerFixture = indexers[1] - indexer = await ethers.getSigner(indexerFixture.address) - delegationIndexingCut = indexerFixture.indexingRewardCut - delegationQueryFeeCut = indexerFixture.queryFeeCut - rewardsDestination = indexerFixture.rewardsDestination! - allocationID = indexerFixture.allocations[0].allocationID - gateway = await graph.accounts.getGateway() - await setGRTBalance(graph.provider, graphToken.target, gateway.address, ONE_MILLION) - }) - - it('should be able to close an open allocation and collect rewards', async () => { - // Use a non-zero POI - const poi = generatePOI('poi') - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Get delegation tokens before - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Mine blocks to simulate time passing - const halfThawingPeriod = Number(thawingPeriod) / 2 - for (let i = 0; i < halfThawingPeriod; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get rewards destination balance before closing allocation - const balanceBefore = await graphToken.balanceOf(rewardsDestination) - - // Close allocation - const tx = await horizonStaking.connect(indexer).closeAllocation(allocationID, poi) - const eventData = await getEventData( - tx, - 'event HorizonRewardsAssigned(address indexed indexer, address indexed allocationID, uint256 amount)', - ) - const rewards = eventData[2] - - // Verify rewards are not zero - expect(rewards).to.not.equal(0, 'Rewards were not transferred to rewards destination') - - // Verify indexer rewards cut is transferred to rewards destination - const balanceAfter = await graphToken.balanceOf(rewardsDestination) - const indexerCutTokens = (rewards * BigInt(delegationIndexingCut)) / 1000000n - expect(balanceAfter).to.equal( - balanceBefore + indexerCutTokens, - 'Indexer cut was not transferred to rewards destination', - ) - - // Verify delegators cut is added to delegation pool - const delegationPoolAfter = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPoolAfter.tokens - const delegationCutTokens = rewards - indexerCutTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationCutTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - - it('should be able to collect query fees', async () => { - const tokensToCollect = ethers.parseEther('1000') - - // Get rewards destination balance before collecting - const balanceBefore = await graphToken.balanceOf(rewardsDestination) - - // Get delegation tokens before - const delegationPoolBefore = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensBefore = delegationPoolBefore.tokens - - // Collect query fees - await collect(gateway, [tokensToCollect, allocationID]) - - // Get rewards destination balance after collecting - const balanceAfter = await graphToken.balanceOf(rewardsDestination) - - // Subtract protocol tax (1%) and curation fees (10% after the protocol tax deduction) - const protocolTax = (tokensToCollect * 1n) / 100n - const curationFees = (tokensToCollect * 99n) / 1000n - const remainingTokens = tokensToCollect - protocolTax - curationFees - - // Verify indexer cut is transferred to rewards destination - const indexerCutTokens = (remainingTokens * BigInt(delegationQueryFeeCut)) / 1000000n - expect(balanceAfter).to.equal( - balanceBefore + indexerCutTokens, - 'Indexer cut was not transferred to rewards destination', - ) - - // Verify delegators cut is added to delegation pool - const delegationPoolAfter = await horizonStaking.getDelegationPool(indexer.address, subgraphServiceAddress) - const delegationPoolTokensAfter = delegationPoolAfter.tokens - const delegationCutTokens = remainingTokens - indexerCutTokens - expect(delegationPoolTokensAfter).to.equal( - delegationPoolTokensBefore + delegationCutTokens, - 'Delegators cut was not added to delegation pool', - ) - }) - }) - }) - - describe('Transition period is over', () => { - let governor: HardhatEthersSigner - let tokensToUnstake: bigint - - before(async () => { - // Get governor - governor = await graph.accounts.getGovernor() - - // Get indexer - const indexerFixture = indexers[2] - indexer = await ethers.getSigner(indexerFixture.address) - - // Set tokens - tokensToUnstake = ethers.parseEther('10000') - }) - - it('should be able to withdraw tokens that were unstaked during transition period', async () => { - // Unstake tokens during transition period - await horizonStaking.connect(indexer).unstake(tokensToUnstake) - - // Get thawing period - const thawingPeriod = await horizonStaking.__DEPRECATED_getThawingPeriod() - - // Clear thawing period - await horizonStaking.connect(governor).clearThawingPeriod() - - // Mine blocks to complete thawing period - for (let i = 0; i < Number(thawingPeriod) + 1; i++) { - await ethers.provider.send('evm_mine', []) - } - - // Get balance before withdrawing - const balanceBefore = await graphToken.balanceOf(indexer.address) - - // Withdraw tokens - await horizonStaking.connect(indexer).withdraw() - - // Get balance after withdrawing - const balanceAfter = await graphToken.balanceOf(indexer.address) - expect(balanceAfter).to.equal( - balanceBefore + tokensToUnstake, - 'Tokens were not transferred back to service provider', - ) - }) - }) - }) -}) diff --git a/packages/horizon/test/integration/during-transition-period/slasher.test.ts b/packages/horizon/test/integration/during-transition-period/slasher.test.ts deleted file mode 100644 index 47ced0883..000000000 --- a/packages/horizon/test/integration/during-transition-period/slasher.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { indexers } from '@graphprotocol/toolshed/fixtures' -import type { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import hre from 'hardhat' -import { ethers } from 'hardhat' - -describe('Slasher', () => { - let snapshotId: string - - let indexer: string - let slasher: HardhatEthersSigner - let tokensToSlash: bigint - - const graph = hre.graph() - const horizonStaking = graph.horizon.contracts.HorizonStaking - const graphToken = graph.horizon.contracts.L2GraphToken - - before(async () => { - slasher = await graph.accounts.getArbitrator() - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Available tokens', () => { - before(() => { - const indexerFixture = indexers[0] - indexer = indexerFixture.address - tokensToSlash = ethers.parseEther('10000') - }) - - it('should be able to slash indexer stake', async () => { - // Before slash state - const idleStakeBeforeSlash = await horizonStaking.getIdleStake(indexer) - const tokensVerifier = tokensToSlash / 2n - const slasherBeforeBalance = await graphToken.balanceOf(slasher.address) - - // Slash tokens - await horizonStaking.connect(slasher).slash(indexer, tokensToSlash, tokensVerifier, slasher.address) - - // Indexer's stake should have decreased - const idleStakeAfterSlash = await horizonStaking.getIdleStake(indexer) - expect(idleStakeAfterSlash).to.equal(idleStakeBeforeSlash - tokensToSlash, 'Indexer stake should have decreased') - - // Slasher should have received the tokens - const slasherAfterBalance = await graphToken.balanceOf(slasher.address) - expect(slasherAfterBalance).to.equal( - slasherBeforeBalance + tokensVerifier, - 'Slasher should have received the tokens', - ) - }) - }) - - describe('Locked tokens', () => { - before(() => { - const indexerFixture = indexers[1] - indexer = indexerFixture.address - tokensToSlash = indexerFixture.stake - }) - - it('should be able to slash locked tokens', async () => { - // Before slash state - const tokensVerifier = tokensToSlash / 2n - const slasherBeforeBalance = await graphToken.balanceOf(slasher.address) - - // Slash tokens - await horizonStaking.connect(slasher).slash(indexer, tokensToSlash, tokensVerifier, slasher.address) - - // Indexer's entire stake should have been slashed - const indexerStakeAfterSlash = await horizonStaking.getServiceProvider(indexer) - expect(indexerStakeAfterSlash.tokensStaked).to.equal(0n, 'Indexer stake should have been slashed') - - // Slasher should have received the tokens - const slasherAfterBalance = await graphToken.balanceOf(slasher.address) - expect(slasherAfterBalance).to.equal( - slasherBeforeBalance + tokensVerifier, - 'Slasher should have received the tokens', - ) - }) - }) -}) diff --git a/packages/horizon/test/unit/GraphBase.t.sol b/packages/horizon/test/unit/GraphBase.t.sol index 1433cc802..9984a2302 100644 --- a/packages/horizon/test/unit/GraphBase.t.sol +++ b/packages/horizon/test/unit/GraphBase.t.sol @@ -191,7 +191,7 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { escrow = PaymentsEscrow(escrowProxyAddress); } - stakingBase = new HorizonStaking(address(controller), address(0), subgraphDataServiceLegacyAddress); + stakingBase = new HorizonStaking(address(controller), subgraphDataServiceLegacyAddress); graphTallyCollector = new GraphTallyCollector( "GraphTallyCollector", diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol index ccdcc1837..4bc81d44f 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingBase.sol @@ -25,6 +25,12 @@ interface IHorizonStakingBase { */ error HorizonStakingInvalidThawRequestType(); + /** + * @notice Gets the address of the subgraph data service. + * @return The address of the subgraph data service. + */ + function getSubgraphService() external view returns (address); + /** * @notice Gets the details of a service provider. * @param serviceProvider The address of the service provider. diff --git a/packages/toolshed/src/deployments/horizon/actions.ts b/packages/toolshed/src/deployments/horizon/actions.ts index 8fc9bd4df..144342a82 100644 --- a/packages/toolshed/src/deployments/horizon/actions.ts +++ b/packages/toolshed/src/deployments/horizon/actions.ts @@ -62,15 +62,6 @@ export function loadActions(contracts: GraphHorizonContracts) { */ provision: (signer: HardhatEthersSigner, args: Parameters) => provision(contracts, signer, args), - /** - * [Legacy] Collects query fees from the Horizon staking contract - * Note that it will approve HorizonStaking to spend the tokens - * @param signer - The signer that will execute the collect transaction - * @param args Parameters: - * - `[tokens, allocationID]` - The collect parameters - */ - collect: (signer: HardhatEthersSigner, args: Parameters) => - collect(contracts, signer, args), /** * Delegates tokens in the Horizon staking contract * Note that it will approve HorizonStaking to spend the tokens @@ -157,18 +148,6 @@ async function provision( await HorizonStaking.connect(signer).provision(serviceProvider, verifier, tokens, maxVerifierCut, thawingPeriod) } -async function collect( - contracts: GraphHorizonContracts, - signer: HardhatEthersSigner, - args: Parameters, -) { - const { GraphToken, HorizonStaking } = contracts - const [tokens, allocationID] = args - - await GraphToken.connect(signer).approve(HorizonStaking.target, tokens) - await HorizonStaking.connect(signer).collect(tokens, allocationID) -} - async function delegate( contracts: GraphHorizonContracts, signer: HardhatEthersSigner, From ac103d7fc2f5a9dc623e63a9b663a84b3d37c471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 1 Dec 2025 09:47:00 -0300 Subject: [PATCH 25/59] fix: contracts changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/rewards/RewardsManager.sol | 28 ++++++------------- .../HorizonStakingShared.t.sol | 5 +--- 2 files changed, 10 insertions(+), 23 deletions(-) diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index 767449026..ef2fbfa73 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -21,11 +21,10 @@ import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/r * @title Rewards Manager Contract * @author Edge & Node * @notice Manages rewards distribution for indexers and delegators in the Graph Protocol - * @dev Tracks how inflationary GRT rewards should be handed out. Relies on the Curation contract - * and the Staking contract. Signaled GRT in Curation determine what percentage of the tokens go - * towards each subgraph. Then each Subgraph can have multiple Indexers Staked on it. Thus, the - * total rewards for the Subgraph are split up for each Indexer based on much they have Staked on - * that Subgraph. + * @dev Tracks how inflationary GRT rewards should be handed out. Signaled GRT in Curation determine + * what percentage of the tokens go towards each subgraph. Then each Subgraph can have multiple + * Indexers Staked on it. Thus, the total rewards for the Subgraph are split up for each Indexer based + * on much they have Staked on that Subgraph. * * Note: * The contract provides getter functions to query the state of accrued rewards: @@ -249,11 +248,8 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa subgraph.accRewardsForSubgraphSnapshot ); - // There are two contributors to subgraph allocated tokens: - // - the legacy allocations on the legacy staking contract - // - the new allocations on the subgraph service uint256 subgraphAllocatedTokens = 0; - address[2] memory rewardsIssuers = [address(staking()), address(subgraphService)]; + address[1] memory rewardsIssuers = [address(subgraphService)]; for (uint256 i = 0; i < rewardsIssuers.length; i++) { if (rewardsIssuers[i] != address(0)) { subgraphAllocatedTokens += IRewardsIssuer(rewardsIssuers[i]).getSubgraphAllocatedTokens( @@ -303,7 +299,7 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa /** * @inheritdoc IRewardsManager - * @dev Hook called from the Staking contract on allocate() and close() + * @dev Hook called from the IRewardsIssuer contract on allocate() and close() */ function onSubgraphAllocationUpdate(bytes32 _subgraphDeploymentID) public override returns (uint256) { Subgraph storage subgraph = subgraphs[_subgraphDeploymentID]; @@ -317,10 +313,7 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa /// @inheritdoc IRewardsManager function getRewards(address _rewardsIssuer, address _allocationID) external view override returns (uint256) { - require( - _rewardsIssuer == address(staking()) || _rewardsIssuer == address(subgraphService), - "Not a rewards issuer" - ); + require(_rewardsIssuer == address(subgraphService), "Not a rewards issuer"); ( bool isActive, @@ -372,15 +365,12 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa /** * @inheritdoc IRewardsManager * @dev This function can only be called by an authorized rewards issuer which are - * the staking contract (for legacy allocations), and the subgraph service (for new allocations). + * - the subgraph service (for new allocations). * Mints 0 tokens if the allocation is not active. */ function takeRewards(address _allocationID) external override returns (uint256) { address rewardsIssuer = msg.sender; - require( - rewardsIssuer == address(staking()) || rewardsIssuer == address(subgraphService), - "Caller must be a rewards issuer" - ); + require(rewardsIssuer == address(subgraphService), "Caller must be a rewards issuer"); ( bool isActive, diff --git a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol index 85f0bb755..babaaca09 100644 --- a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol +++ b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol @@ -205,10 +205,7 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { assertEq(afterStakingBalance, beforeStakingBalance - _tokens); assertEq(afterServiceProvider.tokensStaked, beforeServiceProvider.tokensStaked - _tokens); assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); - assertEq( - afterServiceProvider.__DEPRECATED_tokensAllocated, - beforeServiceProvider.__DEPRECATED_tokensAllocated - ); + assertEq(afterServiceProvider.__DEPRECATED_tokensAllocated, beforeServiceProvider.__DEPRECATED_tokensAllocated); assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, beforeServiceProvider.__DEPRECATED_tokensLocked); assertEq( afterServiceProvider.__DEPRECATED_tokensLockedUntil, From c541c8de19574cf7926891de4d6400019c828fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 1 Dec 2025 10:27:08 -0300 Subject: [PATCH 26/59] feat: remove stuff from subgraph service contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/horizon/scripts/integration | 6 -- packages/horizon/tasks/test/integration.ts | 11 +-- .../tasks/transitions/thawing-period.ts | 22 ------ .../horizon/internal/IHorizonStakingMain.sol | 3 +- .../internal/ILegacyAllocation.sol | 2 +- .../contracts/DisputeManager.sol | 40 ---------- .../contracts/SubgraphService.sol | 9 --- .../contracts/libraries/LegacyAllocation.sol | 32 +------- .../contracts/utilities/AllocationManager.sol | 27 +------ packages/subgraph-service/scripts/integration | 7 -- .../tasks/test/integration.ts | 11 +-- .../test/unit/SubgraphBaseTest.t.sol | 5 +- .../unit/disputeManager/DisputeManager.t.sol | 75 ------------------- .../unit/disputeManager/disputes/legacy.t.sol | 54 ------------- .../unit/shared/HorizonStakingShared.t.sol | 62 --------------- .../subgraphService/SubgraphService.t.sol | 14 +++- .../subgraphService/allocation/start.t.sol | 4 +- .../subgraphService/governance/legacy.t.sol | 23 ------ 18 files changed, 21 insertions(+), 386 deletions(-) delete mode 100644 packages/horizon/tasks/transitions/thawing-period.ts delete mode 100644 packages/subgraph-service/test/unit/disputeManager/disputes/legacy.t.sol delete mode 100644 packages/subgraph-service/test/unit/subgraphService/governance/legacy.t.sol diff --git a/packages/horizon/scripts/integration b/packages/horizon/scripts/integration index baf48cf5e..c92a85ee8 100755 --- a/packages/horizon/scripts/integration +++ b/packages/horizon/scripts/integration @@ -100,12 +100,6 @@ npx hardhat deploy:migrate --network localhost --horizon-config integration --st # Step 4 - Governor npx hardhat deploy:migrate --network localhost --horizon-config integration --step 4 --patch-config --account-index 1 --hide-banner --standalone -# Run integration tests - During transition period -npx hardhat test:integration --phase during-transition-period --network localhost - -# Clear thawing period -npx hardhat transition:clear-thawing --network localhost - # Run integration tests - After transition period npx hardhat test:integration --phase after-transition-period --network localhost diff --git a/packages/horizon/tasks/test/integration.ts b/packages/horizon/tasks/test/integration.ts index 95b2ea230..bba9fa1c2 100644 --- a/packages/horizon/tasks/test/integration.ts +++ b/packages/horizon/tasks/test/integration.ts @@ -4,13 +4,9 @@ import { TASK_TEST } from 'hardhat/builtin-tasks/task-names' import { task } from 'hardhat/config' task('test:integration', 'Runs all integration tests') - .addParam( - 'phase', - 'Test phase to run: "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled"', - ) + .addParam('phase', 'Test phase to run: "after-transition-period", "after-delegation-slashing-enabled"') .setAction(async (taskArgs, hre) => { // Get test files for each phase - const duringTransitionPeriodFiles = await glob('test/integration/during-transition-period/**/*.{js,ts}') const afterTransitionPeriodFiles = await glob('test/integration/after-transition-period/**/*.{js,ts}') const afterDelegationSlashingEnabledFiles = await glob( 'test/integration/after-delegation-slashing-enabled/**/*.{js,ts}', @@ -20,9 +16,6 @@ task('test:integration', 'Runs all integration tests') printBanner(taskArgs.phase, 'INTEGRATION TESTS: ') switch (taskArgs.phase) { - case 'during-transition-period': - await hre.run(TASK_TEST, { testFiles: duringTransitionPeriodFiles }) - break case 'after-transition-period': await hre.run(TASK_TEST, { testFiles: afterTransitionPeriodFiles }) break @@ -31,7 +24,7 @@ task('test:integration', 'Runs all integration tests') break default: throw new Error( - 'Invalid phase. Must be "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled", or "all"', + 'Invalid phase. Must be "after-transition-period", "after-delegation-slashing-enabled", or "all"', ) } }) diff --git a/packages/horizon/tasks/transitions/thawing-period.ts b/packages/horizon/tasks/transitions/thawing-period.ts deleted file mode 100644 index e21e2bad2..000000000 --- a/packages/horizon/tasks/transitions/thawing-period.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { requireLocalNetwork } from '@graphprotocol/toolshed/hardhat' -import { printBanner } from '@graphprotocol/toolshed/utils' -import { task, types } from 'hardhat/config' - -task('transition:clear-thawing', 'Clears the thawing period in HorizonStaking') - .addOptionalParam('governorIndex', 'Derivation path index for the governor account', 1, types.int) - .addFlag('skipNetworkCheck', 'Skip the network check (use with caution)') - .setAction(async (taskArgs, hre) => { - printBanner('CLEARING THAWING PERIOD') - - if (!taskArgs.skipNetworkCheck) { - requireLocalNetwork(hre) - } - - const graph = hre.graph() - const governor = await graph.accounts.getGovernor(taskArgs.governorIndex) - const horizonStaking = graph.horizon.contracts.HorizonStaking - - console.log('Clearing thawing period...') - await horizonStaking.connect(governor).clearThawingPeriod() - console.log('Thawing period cleared') - }) diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index f4f9499ae..11f7f575f 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -536,8 +536,7 @@ interface IHorizonStakingMain { * Stake is immediately removed from the protocol. * @dev Requirements: * - `_tokens` cannot be zero. - * - `_serviceProvider` must have enough idle stake to cover the staking amount and any - * legacy allocation. + * - `_serviceProvider` must have enough idle stake to cover the staking amount. * * Emits a {HorizonStakeWithdrawn} event. * diff --git a/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol b/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol index c5bf7f8c7..c0497fe3b 100644 --- a/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol +++ b/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol @@ -23,7 +23,7 @@ interface ILegacyAllocation { } /** - * @notice Thrown when attempting to migrate an allocation with an existing id + * @notice Thrown when attempting to create an allocation with an existing legacy id * @param allocationId The allocation id */ error LegacyAllocationAlreadyExists(address allocationId); diff --git a/packages/subgraph-service/contracts/DisputeManager.sol b/packages/subgraph-service/contracts/DisputeManager.sol index 6f73b2c5d..660f19251 100644 --- a/packages/subgraph-service/contracts/DisputeManager.sol +++ b/packages/subgraph-service/contracts/DisputeManager.sol @@ -204,46 +204,6 @@ contract DisputeManager is return (dId1, dId2); } - /// @inheritdoc IDisputeManager - function createAndAcceptLegacyDispute( - address allocationId, - address fisherman, - uint256 tokensSlash, - uint256 tokensRewards - ) external override onlyArbitrator returns (bytes32) { - // Create a disputeId - bytes32 disputeId = keccak256(abi.encodePacked(allocationId, "legacy")); - - // Get the indexer for the legacy allocation - address indexer = _graphStaking().getAllocation(allocationId).indexer; - require(indexer != address(0), DisputeManagerIndexerNotFound(allocationId)); - - // Store dispute - disputes[disputeId] = Dispute( - indexer, - fisherman, - 0, - 0, - DisputeType.LegacyDispute, - IDisputeManager.DisputeStatus.Accepted, - block.timestamp, - block.timestamp + disputePeriod, - 0 - ); - - // Slash the indexer - ISubgraphService subgraphService_ = _getSubgraphService(); - subgraphService_.slash(indexer, abi.encode(tokensSlash, tokensRewards)); - - // Reward the fisherman - _graphToken().pushTokens(fisherman, tokensRewards); - - emit LegacyDisputeCreated(disputeId, indexer, fisherman, allocationId, tokensSlash, tokensRewards); - emit DisputeAccepted(disputeId, indexer, fisherman, tokensRewards); - - return disputeId; - } - /// @inheritdoc IDisputeManager function acceptDispute( bytes32 disputeId, diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 0ba0b3035..23f1d5a61 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -327,15 +327,6 @@ contract SubgraphService is _resizeAllocation(allocationId, tokens, _delegationRatio); } - /// @inheritdoc ISubgraphService - function migrateLegacyAllocation( - address indexer, - address allocationId, - bytes32 subgraphDeploymentID - ) external override onlyOwner { - _migrateLegacyAllocation(indexer, allocationId, subgraphDeploymentID); - } - /// @inheritdoc ISubgraphService function setPauseGuardian(address pauseGuardian, bool allowed) external override onlyOwner { _setPauseGuardian(pauseGuardian, allowed); diff --git a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol index 4717cefed..af7fc66db 100644 --- a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol +++ b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol @@ -1,7 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.27; -import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; /** @@ -14,27 +13,6 @@ import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph- library LegacyAllocation { using LegacyAllocation for ILegacyAllocation.State; - /** - * @notice Migrate a legacy allocation - * @dev Requirements: - * - The allocation must not have been previously migrated - * @param self The legacy allocation list mapping - * @param indexer The indexer that owns the allocation - * @param allocationId The allocation id - * @param subgraphDeploymentId The subgraph deployment id the allocation is for - * @custom:error LegacyAllocationAlreadyMigrated if the allocation has already been migrated - */ - function migrate( - mapping(address => ILegacyAllocation.State) storage self, - address indexer, - address allocationId, - bytes32 subgraphDeploymentId - ) internal { - require(!self[allocationId].exists(), ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId)); - - self[allocationId] = ILegacyAllocation.State({ indexer: indexer, subgraphDeploymentId: subgraphDeploymentId }); - } - /** * @notice Get a legacy allocation * @param self The legacy allocation list mapping @@ -50,23 +28,15 @@ library LegacyAllocation { /** * @notice Revert if a legacy allocation exists - * @dev We first check the migrated mapping then the old staking contract. - * @dev TRANSITION PERIOD: after the transition period when all the allocations are migrated we can - * remove the call to the staking contract. + * @dev We check the migrated allocations mapping. * @param self The legacy allocation list mapping - * @param graphStaking The Horizon Staking contract * @param allocationId The allocation id */ function revertIfExists( mapping(address => ILegacyAllocation.State) storage self, - IHorizonStaking graphStaking, address allocationId ) internal view { require(!self[allocationId].exists(), ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId)); - require( - !graphStaking.isAllocation(allocationId), - ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId) - ); } /** diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index 08608d8b4..4c642561c 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -116,18 +116,6 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca 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 @@ -175,19 +163,6 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca */ function __AllocationManager_init_unchained() internal onlyInitializing {} - /** - * @notice Imports a legacy allocation id into the subgraph service - * This is a governor only action that is required to prevent indexers from re-using allocation ids from the - * legacy staking contract. It will revert with LegacyAllocationAlreadyMigrated if the allocation has already been migrated. - * @param _indexer The address of the indexer - * @param _allocationId The id of the allocation - * @param _subgraphDeploymentId The id of the subgraph deployment - */ - function _migrateLegacyAllocation(address _indexer, address _allocationId, bytes32 _subgraphDeploymentId) internal { - _legacyAllocations.migrate(_indexer, _allocationId, _subgraphDeploymentId); - emit LegacyAllocationMigrated(_indexer, _allocationId, _subgraphDeploymentId); - } - /** * @notice Create an allocation * @dev The `_allocationProof` is a 65-bytes Ethereum signed message of `keccak256(indexerAddress,allocationId)` @@ -218,7 +193,7 @@ abstract contract AllocationManager is EIP712Upgradeable, GraphDirectory, Alloca // Ensure allocation id is not reused // need to check both subgraph service (on allocations.create()) and legacy allocations - _legacyAllocations.revertIfExists(_graphStaking(), _allocationId); + _legacyAllocations.revertIfExists(_allocationId); uint256 currentEpoch = _graphEpochManager().currentEpoch(); IAllocation.State memory allocation = _allocations.create( diff --git a/packages/subgraph-service/scripts/integration b/packages/subgraph-service/scripts/integration index d5d7f1c0d..58a7ba4fe 100755 --- a/packages/subgraph-service/scripts/integration +++ b/packages/subgraph-service/scripts/integration @@ -124,13 +124,6 @@ npx hardhat deploy:migrate --network localhost --horizon-config integration --st cd ../subgraph-service npx hardhat test:seed --network localhost -# Run integration tests - During transition period -npx hardhat test:integration --phase during-transition-period --network localhost - -# Clear thawing period -cd ../horizon -npx hardhat transition:clear-thawing --network localhost --governor-index 1 - # Run integration tests - After transition period cd ../subgraph-service npx hardhat test:integration --phase after-transition-period --network localhost diff --git a/packages/subgraph-service/tasks/test/integration.ts b/packages/subgraph-service/tasks/test/integration.ts index 130058e90..ef63c42f4 100644 --- a/packages/subgraph-service/tasks/test/integration.ts +++ b/packages/subgraph-service/tasks/test/integration.ts @@ -4,13 +4,9 @@ import { TASK_TEST } from 'hardhat/builtin-tasks/task-names' import { task } from 'hardhat/config' task('test:integration', 'Runs all integration tests') - .addParam( - 'phase', - 'Test phase to run: "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled"', - ) + .addParam('phase', 'Test phase to run: "after-transition-period", "after-delegation-slashing-enabled"') .setAction(async (taskArgs, hre) => { // Get test files for each phase - const duringTransitionPeriodFiles = await glob('test/integration/during-transition-period/**/*.{js,ts}') const afterTransitionPeriodFiles = await glob('test/integration/after-transition-period/**/*.{js,ts}') // Display banner for the current test phase @@ -18,15 +14,12 @@ task('test:integration', 'Runs all integration tests') // Run tests for the current phase switch (taskArgs.phase) { - case 'during-transition-period': - await hre.run(TASK_TEST, { testFiles: duringTransitionPeriodFiles }) - break case 'after-transition-period': await hre.run(TASK_TEST, { testFiles: afterTransitionPeriodFiles }) break default: throw new Error( - 'Invalid phase. Must be "during-transition-period", "after-transition-period", "after-delegation-slashing-enabled", or "all"', + 'Invalid phase. Must be "after-transition-period", "after-delegation-slashing-enabled", or "all"', ) } }) diff --git a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol index 0997d1aeb..574f120cb 100644 --- a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol +++ b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol @@ -8,7 +8,6 @@ import { GraphPayments } from "@graphprotocol/horizon/contracts/payments/GraphPa import { GraphProxy } from "@graphprotocol/contracts/contracts/upgrades/GraphProxy.sol"; import { GraphProxyAdmin } from "@graphprotocol/contracts/contracts/upgrades/GraphProxyAdmin.sol"; import { HorizonStaking } from "@graphprotocol/horizon/contracts/staking/HorizonStaking.sol"; -import { HorizonStakingExtension } from "@graphprotocol/horizon/contracts/staking/HorizonStakingExtension.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; @@ -45,7 +44,6 @@ abstract contract SubgraphBaseTest is Utils, Constants { GraphTallyCollector graphTallyCollector; HorizonStaking private stakingBase; - HorizonStakingExtension private stakingExtension; MockCuration curation; MockGRTToken token; @@ -174,8 +172,7 @@ abstract contract SubgraphBaseTest is Utils, Constants { ); subgraphService = SubgraphService(subgraphServiceProxy); - stakingExtension = new HorizonStakingExtension(address(controller), address(subgraphService)); - stakingBase = new HorizonStaking(address(controller), address(stakingExtension), address(subgraphService)); + stakingBase = new HorizonStaking(address(controller), address(subgraphService)); graphPayments = new GraphPayments{ salt: saltGraphPayments }(address(controller), protocolPaymentCut); escrow = new PaymentsEscrow{ salt: saltEscrow }(address(controller), withdrawEscrowThawingPeriod); diff --git a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol index e6115057e..d49d2b6a7 100644 --- a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol @@ -205,81 +205,6 @@ contract DisputeManagerTest is SubgraphServiceSharedTest { return _disputeID; } - struct Balances { - uint256 indexer; - uint256 fisherman; - uint256 arbitrator; - uint256 disputeManager; - uint256 staking; - } - - function _createAndAcceptLegacyDispute( - address _allocationId, - address _fisherman, - uint256 _tokensSlash, - uint256 _tokensRewards - ) internal returns (bytes32) { - (, address arbitrator, ) = vm.readCallers(); - address indexer = staking.getAllocation(_allocationId).indexer; - - Balances memory beforeBalances = Balances({ - indexer: token.balanceOf(indexer), - fisherman: token.balanceOf(_fisherman), - arbitrator: token.balanceOf(arbitrator), - disputeManager: token.balanceOf(address(disputeManager)), - staking: token.balanceOf(address(staking)) - }); - - vm.expectEmit(address(disputeManager)); - emit IDisputeManager.LegacyDisputeCreated( - keccak256(abi.encodePacked(_allocationId, "legacy")), - indexer, - _fisherman, - _allocationId, - _tokensSlash, - _tokensRewards - ); - vm.expectEmit(address(disputeManager)); - emit IDisputeManager.DisputeAccepted( - keccak256(abi.encodePacked(_allocationId, "legacy")), - indexer, - _fisherman, - _tokensRewards - ); - bytes32 _disputeId = disputeManager.createAndAcceptLegacyDispute( - _allocationId, - _fisherman, - _tokensSlash, - _tokensRewards - ); - - Balances memory afterBalances = Balances({ - indexer: token.balanceOf(indexer), - fisherman: token.balanceOf(_fisherman), - arbitrator: token.balanceOf(arbitrator), - disputeManager: token.balanceOf(address(disputeManager)), - staking: token.balanceOf(address(staking)) - }); - - assertEq(afterBalances.indexer, beforeBalances.indexer); - assertEq(afterBalances.fisherman, beforeBalances.fisherman + _tokensRewards); - assertEq(afterBalances.arbitrator, beforeBalances.arbitrator); - assertEq(afterBalances.disputeManager, beforeBalances.disputeManager); - assertEq(afterBalances.staking, beforeBalances.staking - _tokensSlash); - - IDisputeManager.Dispute memory dispute = _getDispute(_disputeId); - assertEq(dispute.indexer, indexer); - assertEq(dispute.fisherman, _fisherman); - assertEq(dispute.deposit, 0); - assertEq(dispute.relatedDisputeId, bytes32(0)); - assertEq(uint8(dispute.disputeType), uint8(IDisputeManager.DisputeType.LegacyDispute)); - assertEq(uint8(dispute.status), uint8(IDisputeManager.DisputeStatus.Accepted)); - assertEq(dispute.createdAt, block.timestamp); - assertEq(dispute.stakeSnapshot, 0); - - return _disputeId; - } - struct BeforeValues_CreateQueryDisputeConflict { IAttestation.State attestation1; IAttestation.State attestation2; diff --git a/packages/subgraph-service/test/unit/disputeManager/disputes/legacy.t.sol b/packages/subgraph-service/test/unit/disputeManager/disputes/legacy.t.sol deleted file mode 100644 index 3bab2eaa6..000000000 --- a/packages/subgraph-service/test/unit/disputeManager/disputes/legacy.t.sol +++ /dev/null @@ -1,54 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.27; - -import "forge-std/Test.sol"; - -import { Attestation } from "../../../../contracts/libraries/Attestation.sol"; -import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; -import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol"; -import { DisputeManagerTest } from "../DisputeManager.t.sol"; - -contract DisputeManagerLegacyDisputeTest is DisputeManagerTest { - using PPMMath for uint256; - - bytes32 private requestCID = keccak256(abi.encodePacked("Request CID")); - bytes32 private responseCID = keccak256(abi.encodePacked("Response CID")); - bytes32 private subgraphDeploymentId = keccak256(abi.encodePacked("Subgraph Deployment ID")); - - /* - * TESTS - */ - - function test_LegacyDispute( - uint256 tokensStaked, - uint256 tokensProvisioned, - uint256 tokensSlash, - uint256 tokensRewards - ) public { - vm.assume(tokensStaked <= MAX_TOKENS); - vm.assume(tokensStaked >= minimumProvisionTokens); - tokensProvisioned = bound(tokensProvisioned, minimumProvisionTokens, tokensStaked); - tokensSlash = bound(tokensSlash, 2, tokensProvisioned); - tokensRewards = bound(tokensRewards, 1, tokensSlash.mulPPM(fishermanRewardPercentage)); - - // setup indexer state - resetPrank(users.indexer); - _stake(tokensStaked); - _setStorage_allocation_hardcoded(users.indexer, allocationID, tokensStaked - tokensProvisioned); - _provision(users.indexer, tokensProvisioned, fishermanRewardPercentage, disputePeriod); - - resetPrank(users.arbitrator); - _createAndAcceptLegacyDispute(allocationID, users.fisherman, tokensSlash, tokensRewards); - } - - function test_LegacyDispute_RevertIf_NotArbitrator() public useIndexer { - vm.expectRevert(abi.encodeWithSelector(IDisputeManager.DisputeManagerNotArbitrator.selector)); - disputeManager.createAndAcceptLegacyDispute(allocationID, users.fisherman, 0, 0); - } - - function test_LegacyDispute_RevertIf_AllocationNotFound() public useIndexer { - resetPrank(users.arbitrator); - vm.expectRevert(abi.encodeWithSelector(IDisputeManager.DisputeManagerIndexerNotFound.selector, address(0))); - disputeManager.createAndAcceptLegacyDispute(address(0), users.fisherman, 0, 0); - } -} diff --git a/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol b/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol index 290644bea..66b67a408 100644 --- a/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol +++ b/packages/subgraph-service/test/unit/shared/HorizonStakingShared.t.sol @@ -5,7 +5,6 @@ import "forge-std/Test.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; -import { IHorizonStakingExtension } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingExtension.sol"; import { SubgraphBaseTest } from "../SubgraphBaseTest.t.sol"; @@ -77,67 +76,6 @@ abstract contract HorizonStakingSharedTest is SubgraphBaseTest { staking.setProvisionParameters(_indexer, _verifier, _maxVerifierCut, _thawingPeriod); } - function _setStorage_allocation_hardcoded(address indexer, address allocationId, uint256 tokens) internal { - IHorizonStakingExtension.Allocation memory allocation = IHorizonStakingExtension.Allocation({ - indexer: indexer, - subgraphDeploymentID: bytes32("0x12344321"), - tokens: tokens, - createdAtEpoch: 1234, - closedAtEpoch: 1235, - collectedFees: 1234, - __DEPRECATED_effectiveAllocation: 1222234, - accRewardsPerAllocatedToken: 1233334, - distributedRebates: 1244434 - }); - - // __DEPRECATED_allocations - uint256 allocationsSlot = 15; - bytes32 allocationBaseSlot = keccak256(abi.encode(allocationId, allocationsSlot)); - vm.store(address(staking), allocationBaseSlot, bytes32(uint256(uint160(allocation.indexer)))); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 1), allocation.subgraphDeploymentID); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 2), bytes32(tokens)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 3), bytes32(allocation.createdAtEpoch)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 4), bytes32(allocation.closedAtEpoch)); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 5), bytes32(allocation.collectedFees)); - vm.store( - address(staking), - bytes32(uint256(allocationBaseSlot) + 6), - bytes32(allocation.__DEPRECATED_effectiveAllocation) - ); - vm.store( - address(staking), - bytes32(uint256(allocationBaseSlot) + 7), - bytes32(allocation.accRewardsPerAllocatedToken) - ); - vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 8), bytes32(allocation.distributedRebates)); - - // _serviceProviders - uint256 serviceProviderSlot = 14; - bytes32 serviceProviderBaseSlot = keccak256(abi.encode(allocation.indexer, serviceProviderSlot)); - uint256 currentTokensStaked = uint256(vm.load(address(staking), serviceProviderBaseSlot)); - uint256 currentTokensProvisioned = uint256( - vm.load(address(staking), bytes32(uint256(serviceProviderBaseSlot) + 1)) - ); - vm.store( - address(staking), - bytes32(uint256(serviceProviderBaseSlot) + 0), - bytes32(currentTokensStaked + tokens) - ); - vm.store( - address(staking), - bytes32(uint256(serviceProviderBaseSlot) + 1), - bytes32(currentTokensProvisioned + tokens) - ); - - // __DEPRECATED_subgraphAllocations - uint256 subgraphsAllocationsSlot = 16; - bytes32 subgraphAllocationsBaseSlot = keccak256( - abi.encode(allocation.subgraphDeploymentID, subgraphsAllocationsSlot) - ); - uint256 currentAllocatedTokens = uint256(vm.load(address(staking), subgraphAllocationsBaseSlot)); - vm.store(address(staking), subgraphAllocationsBaseSlot, bytes32(currentAllocatedTokens + tokens)); - } - function _stakeTo(address _indexer, uint256 _tokens) internal { token.approve(address(staking), _tokens); staking.stakeTo(_indexer, _tokens); diff --git a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol index 74c677504..2fac2595a 100644 --- a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol @@ -383,7 +383,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) ); @@ -487,10 +487,16 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { } function _migrateLegacyAllocation(address _indexer, address _allocationId, bytes32 _subgraphDeploymentID) internal { - vm.expectEmit(address(subgraphService)); - emit AllocationManager.LegacyAllocationMigrated(_indexer, _allocationId, _subgraphDeploymentID); + // migrate fn was removed, we simulate history by manually setting the storage state + uint256 legacyAllocationsSlot = 208; + bytes32 legacyAllocationBaseSlot = keccak256(abi.encode(_allocationId, legacyAllocationsSlot)); - subgraphService.migrateLegacyAllocation(_indexer, _allocationId, _subgraphDeploymentID); + vm.store(address(subgraphService), legacyAllocationBaseSlot, bytes32(uint256(uint160(_indexer)))); + vm.store( + address(subgraphService), + bytes32(uint256(legacyAllocationBaseSlot) + 1), + bytes32(_subgraphDeploymentID) + ); ILegacyAllocation.State memory afterLegacyAllocation = subgraphService.getLegacyAllocation(_allocationId); assertEq(afterLegacyAllocation.indexer, _indexer); 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 4a251f506..c44f5364a 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol @@ -170,8 +170,8 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { _createProvision(users.indexer, tokens, fishermanRewardPercentage, disputePeriod); _register(users.indexer, abi.encode("url", "geoHash", address(0))); - // create dummy allo in staking contract - _setStorage_allocation_hardcoded(users.indexer, allocationID, tokens); + // simulate legacy allocation migration + _migrateLegacyAllocation(users.indexer, allocationID, subgraphDeployment); bytes memory data = _generateData(tokens); vm.expectRevert(abi.encodeWithSelector(ILegacyAllocation.LegacyAllocationAlreadyExists.selector, allocationID)); diff --git a/packages/subgraph-service/test/unit/subgraphService/governance/legacy.t.sol b/packages/subgraph-service/test/unit/subgraphService/governance/legacy.t.sol deleted file mode 100644 index d1b5dd124..000000000 --- a/packages/subgraph-service/test/unit/subgraphService/governance/legacy.t.sol +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity 0.8.27; - -import "forge-std/Test.sol"; - -import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; - -import { SubgraphServiceTest } from "../SubgraphService.t.sol"; - -contract SubgraphServiceLegacyAllocation is SubgraphServiceTest { - /* - * TESTS - */ - - function test_MigrateAllocation() public useGovernor { - _migrateLegacyAllocation(users.indexer, allocationID, subgraphDeployment); - } - - function test_MigrateAllocation_WhenNotGovernor() public useIndexer { - vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, users.indexer)); - subgraphService.migrateLegacyAllocation(users.indexer, allocationID, subgraphDeployment); - } -} From abcafaa5d723f4df9c1aa0cad3106cb13e74733a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 1 Dec 2025 10:41:09 -0300 Subject: [PATCH 27/59] chore: remove dead code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/horizon/test/unit/GraphBase.t.sol | 3 +- packages/horizon/test/unit/utils/Users.sol | 1 - .../internal/ILegacyAllocation.sol | 6 ---- .../contracts/libraries/LegacyAllocation.sol | 28 ------------------- 4 files changed, 1 insertion(+), 37 deletions(-) diff --git a/packages/horizon/test/unit/GraphBase.t.sol b/packages/horizon/test/unit/GraphBase.t.sol index 9984a2302..efd1533d8 100644 --- a/packages/horizon/test/unit/GraphBase.t.sol +++ b/packages/horizon/test/unit/GraphBase.t.sol @@ -69,8 +69,7 @@ abstract contract GraphBaseTest is IHorizonStakingTypes, Utils, Constants { operator: createUser("operator"), gateway: createUser("gateway"), verifier: createUser("verifier"), - delegator: createUser("delegator"), - legacySlasher: createUser("legacySlasher") + delegator: createUser("delegator") }); // Deploy protocol contracts diff --git a/packages/horizon/test/unit/utils/Users.sol b/packages/horizon/test/unit/utils/Users.sol index 6213e4e82..c47395a14 100644 --- a/packages/horizon/test/unit/utils/Users.sol +++ b/packages/horizon/test/unit/utils/Users.sol @@ -9,5 +9,4 @@ struct Users { address gateway; address verifier; address delegator; - address legacySlasher; } diff --git a/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol b/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol index c0497fe3b..b6422fad8 100644 --- a/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol +++ b/packages/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol @@ -27,10 +27,4 @@ interface ILegacyAllocation { * @param allocationId The allocation id */ error LegacyAllocationAlreadyExists(address allocationId); - - /** - * @notice Thrown when trying to get a non-existent allocation - * @param allocationId The allocation id - */ - error LegacyAllocationDoesNotExist(address allocationId); } diff --git a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol index af7fc66db..ed9003433 100644 --- a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol +++ b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol @@ -13,19 +13,6 @@ import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph- library LegacyAllocation { using LegacyAllocation for ILegacyAllocation.State; - /** - * @notice Get a legacy allocation - * @param self The legacy allocation list mapping - * @param allocationId The allocation id - * @return The legacy allocation details - */ - function get( - mapping(address => ILegacyAllocation.State) storage self, - address allocationId - ) internal view returns (ILegacyAllocation.State memory) { - return _get(self, allocationId); - } - /** * @notice Revert if a legacy allocation exists * @dev We check the migrated allocations mapping. @@ -47,19 +34,4 @@ library LegacyAllocation { function exists(ILegacyAllocation.State memory self) internal pure returns (bool) { return self.indexer != address(0); } - - /** - * @notice Get a legacy allocation - * @param self The legacy allocation list mapping - * @param allocationId The allocation id - * @return The legacy allocation details - */ - function _get( - mapping(address => ILegacyAllocation.State) storage self, - address allocationId - ) private view returns (ILegacyAllocation.State storage) { - ILegacyAllocation.State storage allocation = self[allocationId]; - require(allocation.exists(), ILegacyAllocation.LegacyAllocationDoesNotExist(allocationId)); - return allocation; - } } From a2abc90cfa0a2dbdc6358c3775cd5f3a48e5d3cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 1 Dec 2025 14:12:44 -0300 Subject: [PATCH 28/59] test(contract): hack outdated test suite to pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/contracts/rewards/RewardsManager.sol | 10 +++------- .../contracts/test/tests/unit/disputes/poi.test.ts | 8 +++++++- .../contracts/test/tests/unit/disputes/query.test.ts | 8 +++++++- .../contracts/test/tests/unit/l2/l2Staking.test.ts | 8 +++++++- .../contracts/test/tests/unit/rewards/rewards.test.ts | 4 ++++ .../test/tests/unit/staking/allocation.test.ts | 4 ++++ .../test/tests/unit/staking/delegation.test.ts | 8 +++++++- 7 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index ef2fbfa73..c66c3b7bc 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -249,13 +249,9 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa ); uint256 subgraphAllocatedTokens = 0; - address[1] memory rewardsIssuers = [address(subgraphService)]; - for (uint256 i = 0; i < rewardsIssuers.length; i++) { - if (rewardsIssuers[i] != address(0)) { - subgraphAllocatedTokens += IRewardsIssuer(rewardsIssuers[i]).getSubgraphAllocatedTokens( - _subgraphDeploymentID - ); - } + address rewardsIssuer = address(subgraphService); + if (rewardsIssuer != address(0)) { + subgraphAllocatedTokens += IRewardsIssuer(rewardsIssuer).getSubgraphAllocatedTokens(_subgraphDeploymentID); } if (subgraphAllocatedTokens == 0) { diff --git a/packages/contracts/test/tests/unit/disputes/poi.test.ts b/packages/contracts/test/tests/unit/disputes/poi.test.ts index b465f5986..b391dd0d4 100644 --- a/packages/contracts/test/tests/unit/disputes/poi.test.ts +++ b/packages/contracts/test/tests/unit/disputes/poi.test.ts @@ -1,4 +1,4 @@ -import { DisputeManager } from '@graphprotocol/contracts' +import { DisputeManager, IRewardsManager } from '@graphprotocol/contracts' import { EpochManager } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' import { IStaking } from '@graphprotocol/contracts' @@ -30,6 +30,7 @@ describe('DisputeManager:POI', () => { let epochManager: EpochManager let grt: GraphToken let staking: IStaking + let rewardsManager: IRewardsManager // Derive some channel keys for each indexer used to sign attestations const indexerChannelKey = deriveChannelKey() @@ -92,10 +93,15 @@ describe('DisputeManager:POI', () => { epochManager = contracts.EpochManager as EpochManager grt = contracts.GraphToken as GraphToken staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager as IRewardsManager // Give some funds to the fisherman await grt.connect(governor).mint(fisherman.address, fishermanTokens) await grt.connect(fisherman).approve(disputeManager.address, fishermanTokens) + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts/test/tests/unit/disputes/query.test.ts b/packages/contracts/test/tests/unit/disputes/query.test.ts index 73238b4e0..e411bd028 100644 --- a/packages/contracts/test/tests/unit/disputes/query.test.ts +++ b/packages/contracts/test/tests/unit/disputes/query.test.ts @@ -1,5 +1,5 @@ import { createAttestation, Receipt } from '@graphprotocol/common-ts' -import { DisputeManager } from '@graphprotocol/contracts' +import { DisputeManager, IRewardsManager } from '@graphprotocol/contracts' import { EpochManager } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' import { IStaking } from '@graphprotocol/contracts' @@ -35,6 +35,7 @@ describe('DisputeManager:Query', () => { let epochManager: EpochManager let grt: GraphToken let staking: IStaking + let rewardsManager: IRewardsManager // Derive some channel keys for each indexer used to sign attestations const indexer1ChannelKey = deriveChannelKey() @@ -121,6 +122,7 @@ describe('DisputeManager:Query', () => { epochManager = contracts.EpochManager as EpochManager grt = contracts.GraphToken as GraphToken staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager as IRewardsManager // Give some funds to the fisherman for (const dst of [fisherman, fisherman2]) { @@ -139,6 +141,10 @@ describe('DisputeManager:Query', () => { indexerAddress: indexer.address, receipt, } + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts/test/tests/unit/l2/l2Staking.test.ts b/packages/contracts/test/tests/unit/l2/l2Staking.test.ts index 39dc75e7a..cf22eaba0 100644 --- a/packages/contracts/test/tests/unit/l2/l2Staking.test.ts +++ b/packages/contracts/test/tests/unit/l2/l2Staking.test.ts @@ -1,4 +1,4 @@ -import { IL2Staking } from '@graphprotocol/contracts' +import { IL2Staking, IRewardsManager } from '@graphprotocol/contracts' import { L2GraphTokenGateway } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' import { EpochManager, L1GNS, L1GraphTokenGateway, L1Staking } from '@graphprotocol/contracts' @@ -35,6 +35,7 @@ describe('L2Staking', () => { let l2GraphTokenGateway: L2GraphTokenGateway let staking: IL2Staking let grt: GraphToken + let rewardsManager: IRewardsManager const tokens10k = toGRT('10000') const tokens100k = toGRT('100000') @@ -88,6 +89,7 @@ describe('L2Staking', () => { l1StakingMock = l1MockContracts.L1Staking as L1Staking l1GNSMock = l1MockContracts.L1GNS as L1GNS l1GRTGatewayMock = l1MockContracts.L1GraphTokenGateway as L1GraphTokenGateway + rewardsManager = fixtureContracts.RewardsManager as IRewardsManager // Deploy L2 arbitrum bridge await fixture.loadL2ArbitrumBridge(governor) @@ -99,6 +101,10 @@ describe('L2Staking', () => { await grt.connect(me).approve(staking.address, tokens1m) await grt.connect(governor).mint(other.address, tokens1m) await grt.connect(other).approve(staking.address, tokens1m) + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts/test/tests/unit/rewards/rewards.test.ts b/packages/contracts/test/tests/unit/rewards/rewards.test.ts index e6171cc13..fbf0912eb 100644 --- a/packages/contracts/test/tests/unit/rewards/rewards.test.ts +++ b/packages/contracts/test/tests/unit/rewards/rewards.test.ts @@ -151,6 +151,10 @@ describe('Rewards', () => { await grt.connect(wallet).approve(staking.address, toGRT('1000000')) await grt.connect(wallet).approve(curation.address, toGRT('1000000')) } + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts/test/tests/unit/staking/allocation.test.ts b/packages/contracts/test/tests/unit/staking/allocation.test.ts index dd28aa73d..76de77a35 100644 --- a/packages/contracts/test/tests/unit/staking/allocation.test.ts +++ b/packages/contracts/test/tests/unit/staking/allocation.test.ts @@ -379,6 +379,10 @@ describe('Staking:Allocation', () => { // Give some funds to the delegator and approve staking contract to use funds on delegator behalf await grt.connect(governor).mint(delegator.address, tokensToDelegate) await grt.connect(delegator).approve(staking.address, tokensToDelegate) + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { diff --git a/packages/contracts/test/tests/unit/staking/delegation.test.ts b/packages/contracts/test/tests/unit/staking/delegation.test.ts index 71f911006..3542e817e 100644 --- a/packages/contracts/test/tests/unit/staking/delegation.test.ts +++ b/packages/contracts/test/tests/unit/staking/delegation.test.ts @@ -1,4 +1,4 @@ -import { EpochManager } from '@graphprotocol/contracts' +import { EpochManager, IRewardsManager } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' import { IStaking } from '@graphprotocol/contracts' import { deriveChannelKey, GraphNetworkContracts, helpers, randomHexBytes, toBN, toGRT } from '@graphprotocol/sdk' @@ -29,6 +29,7 @@ describe('Staking::Delegation', () => { let epochManager: EpochManager let grt: GraphToken let staking: IStaking + let rewardsManager: IRewardsManager // Test values const poi = randomHexBytes() @@ -159,6 +160,7 @@ describe('Staking::Delegation', () => { epochManager = contracts.EpochManager as EpochManager grt = contracts.GraphToken as GraphToken staking = contracts.Staking as IStaking + rewardsManager = contracts.RewardsManager as IRewardsManager // Distribute test funds for (const wallet of [delegator, delegator2]) { @@ -173,6 +175,10 @@ describe('Staking::Delegation', () => { } await grt.connect(governor).mint(assetHolder.address, tokensToCollect) await grt.connect(assetHolder).approve(staking.address, tokensToCollect) + + // HACK: we set the staking contract as the subgraph service to make tests pass. + // This is due to the test suite being outdated. + await rewardsManager.connect(governor).setSubgraphService(staking.address) }) beforeEach(async function () { From bee3e79ff892a8f38fd2abf4b50d760abf0ea975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 1 Dec 2025 14:41:41 -0300 Subject: [PATCH 29/59] test: fix vm.assume too many rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/horizon/test/unit/escrow/getters.t.sol | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/horizon/test/unit/escrow/getters.t.sol b/packages/horizon/test/unit/escrow/getters.t.sol index 262192125..84f5661ac 100644 --- a/packages/horizon/test/unit/escrow/getters.t.sol +++ b/packages/horizon/test/unit/escrow/getters.t.sol @@ -35,11 +35,10 @@ contract GraphEscrowGettersTest is GraphEscrowTest { uint256 amountThawing, uint256 amountCollected ) public useGateway useDeposit(amountDeposit) { - vm.assume(amountThawing > 0); - vm.assume(amountDeposit > 0); - vm.assume(amountDeposit >= amountThawing); - vm.assume(amountDeposit >= amountCollected); - vm.assume(amountDeposit - amountCollected < amountThawing); + // amountCollected must be >= 1 for valid range, and <= amountDeposit + amountCollected = bound(amountCollected, 1, amountDeposit); + // amountThawing must be in (amountDeposit - amountCollected, amountDeposit] + amountThawing = bound(amountThawing, amountDeposit - amountCollected + 1, amountDeposit); // thaw some funds _thawEscrow(users.verifier, users.indexer, amountThawing); From a5bbbf80d4822907b7356cf069f25bd000250e75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Tue, 2 Dec 2025 13:20:03 -0300 Subject: [PATCH 30/59] chore: address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/rewards/RewardsManager.sol | 2 +- .../contracts/staking/HorizonStaking.sol | 3 +- .../dispute-manager.test.ts | 157 ----------- .../governance.test.ts | 76 ------ .../during-transition-period/indexer.test.ts | 100 ------- .../legacy-dispute-manager.test.ts | 256 ------------------ 6 files changed, 2 insertions(+), 592 deletions(-) delete mode 100644 packages/subgraph-service/test/integration/during-transition-period/dispute-manager.test.ts delete mode 100644 packages/subgraph-service/test/integration/during-transition-period/governance.test.ts delete mode 100644 packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts delete mode 100644 packages/subgraph-service/test/integration/during-transition-period/legacy-dispute-manager.test.ts diff --git a/packages/contracts/contracts/rewards/RewardsManager.sol b/packages/contracts/contracts/rewards/RewardsManager.sol index c66c3b7bc..5e1908064 100644 --- a/packages/contracts/contracts/rewards/RewardsManager.sol +++ b/packages/contracts/contracts/rewards/RewardsManager.sol @@ -361,7 +361,7 @@ contract RewardsManager is RewardsManagerV5Storage, GraphUpgradeable, IRewardsMa /** * @inheritdoc IRewardsManager * @dev This function can only be called by an authorized rewards issuer which are - * - the subgraph service (for new allocations). + * - the subgraph service (for allocations). * Mints 0 tokens if the allocation is not active. */ function takeRewards(address _allocationID) external override returns (uint256) { diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 66aa35b5d..fbafbfc67 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -545,9 +545,8 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { require(_tokens <= tokensIdle, HorizonStakingInsufficientIdleStake(_tokens, tokensIdle)); ServiceProviderInternal storage sp = _serviceProviders[serviceProvider]; - uint256 stakedTokens = sp.tokensStaked; + sp.tokensStaked -= _tokens; - sp.tokensStaked = stakedTokens - _tokens; _graphToken().pushTokens(serviceProvider, _tokens); emit HorizonStakeWithdrawn(serviceProvider, _tokens); } diff --git a/packages/subgraph-service/test/integration/during-transition-period/dispute-manager.test.ts b/packages/subgraph-service/test/integration/during-transition-period/dispute-manager.test.ts deleted file mode 100644 index a24f9703a..000000000 --- a/packages/subgraph-service/test/integration/during-transition-period/dispute-manager.test.ts +++ /dev/null @@ -1,157 +0,0 @@ -import { - DisputeManager, - HorizonStaking, - L2GraphToken, - LegacyDisputeManager, - SubgraphService, -} from '@graphprotocol/interfaces' -import { generateLegacyIndexingDisputeId, generateLegacyTypeDisputeId } from '@graphprotocol/toolshed' -import { indexersData as indexers } from '@graphprotocol/toolshed/fixtures' -import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import { ethers } from 'hardhat' -import hre from 'hardhat' - -describe('Dispute Manager', () => { - let disputeManager: DisputeManager - let legacyDisputeManager: LegacyDisputeManager - let graphToken: L2GraphToken - let staking: HorizonStaking - let subgraphService: SubgraphService - - let snapshotId: string - - // Test addresses - let governor: HardhatEthersSigner - let fisherman: HardhatEthersSigner - let arbitrator: HardhatEthersSigner - let indexer: HardhatEthersSigner - - let disputeDeposit: bigint - - // Allocation variables - let allocationId: string - - before(async () => { - // Get contracts - const graph = hre.graph() - disputeManager = graph.subgraphService.contracts.DisputeManager - legacyDisputeManager = graph.subgraphService.contracts.LegacyDisputeManager - graphToken = graph.horizon.contracts.GraphToken - staking = graph.horizon.contracts.HorizonStaking - subgraphService = graph.subgraphService.contracts.SubgraphService - - // Get signers - governor = await graph.accounts.getGovernor() - arbitrator = await graph.accounts.getArbitrator() - ;[fisherman] = await graph.accounts.getTestAccounts() - - // Get indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Get allocation - const allocation = indexerFixture.legacyAllocations[0] - allocationId = allocation.allocationID - - // Get dispute deposit - disputeDeposit = ethers.parseEther('10000') - - // Set GRT balance for fisherman - await setGRTBalance(graph.provider, graphToken.target, fisherman.address, ethers.parseEther('1000000')) - - // Set arbitrator - await legacyDisputeManager.connect(governor).setArbitrator(arbitrator.address) - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Legacy dispute type', () => { - describe('Arbitrator', () => { - it('should allow arbitrator to create and accept a legacy dispute on the new dispute manager after slashing on the legacy dispute manager', async () => { - // Create an indexing dispute on legacy dispute manager - await graphToken.connect(fisherman).approve(legacyDisputeManager.target, disputeDeposit) - await legacyDisputeManager.connect(fisherman).createIndexingDispute(allocationId, disputeDeposit) - const legacyDisputeId = generateLegacyIndexingDisputeId(allocationId) - - // Accept the dispute on the legacy dispute manager - await legacyDisputeManager.connect(arbitrator).acceptDispute(legacyDisputeId) - - // Get fisherman's balance before creating dispute - const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) - - // Get indexer's provision before creating dispute - const provision = await staking.getProviderTokensAvailable(indexer.address, await subgraphService.getAddress()) - - // Create and accept legacy dispute using the same allocation ID - const tokensToSlash = ethers.parseEther('100000') - const tokensToReward = tokensToSlash / 2n - await disputeManager - .connect(arbitrator) - .createAndAcceptLegacyDispute(allocationId, fisherman.address, tokensToSlash, tokensToReward) - - // Get dispute ID from event - const disputeId = generateLegacyTypeDisputeId(allocationId) - - // Verify dispute was created and accepted - const dispute = await disputeManager.disputes(disputeId) - expect(dispute.indexer).to.equal(indexer.address, 'Indexer address mismatch') - expect(dispute.fisherman).to.equal(fisherman.address, 'Fisherman address mismatch') - expect(dispute.disputeType).to.equal(3, 'Dispute type should be legacy') - expect(dispute.status).to.equal(1, 'Dispute status should be accepted') - - // Verify indexer's stake was slashed - const updatedProvision = await staking.getProviderTokensAvailable( - indexer.address, - await subgraphService.getAddress(), - ) - expect(updatedProvision).to.equal(provision - tokensToSlash, 'Indexer stake should be slashed') - - // Verify fisherman got the reward - const fishermanBalance = await graphToken.balanceOf(fisherman.address) - expect(fishermanBalance).to.equal( - fishermanBalanceBefore + tokensToReward, - 'Fisherman balance should be increased by the reward', - ) - }) - - it('should not allow creating a legacy dispute for non-existent allocation', async () => { - const tokensToSlash = ethers.parseEther('1000') - const tokensToReward = tokensToSlash / 2n - - // Attempt to create legacy dispute with non-existent allocation - await expect( - disputeManager - .connect(arbitrator) - .createAndAcceptLegacyDispute( - ethers.Wallet.createRandom().address, - fisherman.address, - tokensToSlash, - tokensToReward, - ), - ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerIndexerNotFound') - }) - }) - - it('should not allow non-arbitrator to create a legacy dispute', async () => { - const tokensToSlash = ethers.parseEther('1000') - const tokensToReward = tokensToSlash / 2n - - // Attempt to create legacy dispute as fisherman - await expect( - disputeManager - .connect(fisherman) - .createAndAcceptLegacyDispute(allocationId, fisherman.address, tokensToSlash, tokensToReward), - ).to.be.revertedWithCustomError(disputeManager, 'DisputeManagerNotArbitrator') - }) - }) -}) diff --git a/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts b/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts deleted file mode 100644 index ad638b306..000000000 --- a/packages/subgraph-service/test/integration/during-transition-period/governance.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { SubgraphService } from '@graphprotocol/interfaces' -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import { ethers } from 'hardhat' -import hre from 'hardhat' - -describe('Governance', () => { - let subgraphService: SubgraphService - let snapshotId: string - - // Test addresses - let governor: HardhatEthersSigner - let indexer: HardhatEthersSigner - let nonOwner: HardhatEthersSigner - let allocationId: string - let subgraphDeploymentId: string - - const graph = hre.graph() - - before(() => { - subgraphService = graph.subgraphService.contracts.SubgraphService - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - - // Get signers - governor = await graph.accounts.getGovernor() - ;[indexer, nonOwner] = await graph.accounts.getTestAccounts() - - // Generate test addresses - allocationId = ethers.Wallet.createRandom().address - subgraphDeploymentId = ethers.keccak256(ethers.toUtf8Bytes('test-subgraph-deployment')) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Legacy Allocation Migration', () => { - it('should migrate legacy allocation', async () => { - // Migrate legacy allocation - await subgraphService - .connect(governor) - .migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId) - - // Verify the legacy allocation was migrated - const legacyAllocation = await subgraphService.getLegacyAllocation(allocationId) - expect(legacyAllocation.indexer).to.equal(indexer.address) - expect(legacyAllocation.subgraphDeploymentId).to.equal(subgraphDeploymentId) - }) - - it('should not allow non-owner to migrate legacy allocation', async () => { - // Attempt to migrate legacy allocation as non-owner - await expect( - subgraphService.connect(nonOwner).migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId), - ).to.be.revertedWithCustomError(subgraphService, 'OwnableUnauthorizedAccount') - }) - - it('should not allow migrating a legacy allocation that was already migrated', async () => { - // First migration - await subgraphService - .connect(governor) - .migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId) - - // Attempt to migrate the same allocation again - await expect( - subgraphService.connect(governor).migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId), - ) - .to.be.revertedWithCustomError(subgraphService, 'LegacyAllocationAlreadyExists') - .withArgs(allocationId) - }) - }) -}) diff --git a/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts b/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts deleted file mode 100644 index 7fd508c40..000000000 --- a/packages/subgraph-service/test/integration/during-transition-period/indexer.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { SubgraphService } from '@graphprotocol/interfaces' -import { encodeStartServiceData, generateAllocationProof } from '@graphprotocol/toolshed' -import { indexersData as indexers } from '@graphprotocol/toolshed/fixtures' -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import { ethers } from 'hardhat' -import hre from 'hardhat' - -describe('Indexer', () => { - let subgraphService: SubgraphService - let snapshotId: string - let chainId: number - - // Test addresses - let governor: HardhatEthersSigner - let indexer: HardhatEthersSigner - let allocationId: string - let subgraphDeploymentId: string - let allocationPrivateKey: string - let subgraphServiceAddress: string - - const graph = hre.graph() - - before(async () => { - // Get contracts - subgraphService = graph.subgraphService.contracts.SubgraphService - - // Get governor and non-owner - governor = await graph.accounts.getGovernor() - - // Get chain id - chainId = Number((await hre.ethers.provider.getNetwork()).chainId) - - // Get subgraph service address - subgraphServiceAddress = await subgraphService.getAddress() - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Allocation', () => { - beforeEach(async () => { - // Get indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Generate test addresses - const allocation = indexerFixture.legacyAllocations[0] - allocationId = allocation.allocationID - subgraphDeploymentId = allocation.subgraphDeploymentID - allocationPrivateKey = allocation.allocationPrivateKey - }) - - it('should not be able to create an allocation with an AllocationID that already exists in HorizonStaking contract', async () => { - // Build allocation proof - const signature = await generateAllocationProof( - indexer.address, - allocationPrivateKey, - subgraphServiceAddress, - chainId, - ) - - // Attempt to create an allocation with the same ID - const data = encodeStartServiceData(subgraphDeploymentId, 1000n, allocationId, signature) - - await expect(subgraphService.connect(indexer).startService(indexer.address, data)) - .to.be.revertedWithCustomError(subgraphService, 'LegacyAllocationAlreadyExists') - .withArgs(allocationId) - }) - - it('should not be able to create an allocation that was already migrated by the owner', async () => { - // Migrate legacy allocation - await subgraphService - .connect(governor) - .migrateLegacyAllocation(indexer.address, allocationId, subgraphDeploymentId) - - // Build allocation proof - const signature = await generateAllocationProof( - indexer.address, - allocationPrivateKey, - subgraphServiceAddress, - chainId, - ) - - // Attempt to create the same allocation - const data = encodeStartServiceData(subgraphDeploymentId, 1000n, allocationId, signature) - - await expect(subgraphService.connect(indexer).startService(indexer.address, data)) - .to.be.revertedWithCustomError(subgraphService, 'LegacyAllocationAlreadyExists') - .withArgs(allocationId) - }) - }) -}) diff --git a/packages/subgraph-service/test/integration/during-transition-period/legacy-dispute-manager.test.ts b/packages/subgraph-service/test/integration/during-transition-period/legacy-dispute-manager.test.ts deleted file mode 100644 index 51cfc557c..000000000 --- a/packages/subgraph-service/test/integration/during-transition-period/legacy-dispute-manager.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { HorizonStaking, L2GraphToken, LegacyDisputeManager } from '@graphprotocol/interfaces' -import { - generateAttestationData, - generateLegacyIndexingDisputeId, - generateLegacyQueryDisputeId, -} from '@graphprotocol/toolshed' -import { indexersData as indexers } from '@graphprotocol/toolshed/fixtures' -import { setGRTBalance } from '@graphprotocol/toolshed/hardhat' -import { HardhatEthersSigner } from '@nomicfoundation/hardhat-ethers/signers' -import { expect } from 'chai' -import { ethers } from 'hardhat' -import hre from 'hardhat' - -describe('Legacy Dispute Manager', () => { - let legacyDisputeManager: LegacyDisputeManager - let graphToken: L2GraphToken - let staking: HorizonStaking - - let snapshotId: string - - let governor: HardhatEthersSigner - let arbitrator: HardhatEthersSigner - let indexer: HardhatEthersSigner - let fisherman: HardhatEthersSigner - - let disputeDeposit: bigint - - const graph = hre.graph() - - // We have to use Aribtrm Sepolia since we're testing an already deployed contract but running on a hardhat fork - const chainId = 421614 - - before(async () => { - governor = await graph.accounts.getGovernor() - ;[arbitrator, fisherman] = await graph.accounts.getTestAccounts() - - // Get contract instances with correct types - legacyDisputeManager = graph.subgraphService.contracts.LegacyDisputeManager - graphToken = graph.horizon.contracts.GraphToken - staking = graph.horizon.contracts.HorizonStaking - - // Set GRT balances - await setGRTBalance(graph.provider, graphToken.target, fisherman.address, ethers.parseEther('100000')) - }) - - beforeEach(async () => { - // Take a snapshot before each test - snapshotId = await ethers.provider.send('evm_snapshot', []) - - // Legacy dispute manager - disputeDeposit = ethers.parseEther('10000') - - // Set arbitrator - await legacyDisputeManager.connect(governor).setArbitrator(arbitrator.address) - }) - - afterEach(async () => { - // Revert to the snapshot after each test - await ethers.provider.send('evm_revert', [snapshotId]) - }) - - describe('Indexing Disputes', () => { - let allocationId: string - - beforeEach(async () => { - // Get Indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Get allocation - allocationId = indexerFixture.legacyAllocations[0].allocationID - }) - - it('should allow creating and accepting indexing disputes', async () => { - // Create an indexing dispute - await graphToken.connect(fisherman).approve(legacyDisputeManager.target, disputeDeposit) - await legacyDisputeManager.connect(fisherman).createIndexingDispute(allocationId, disputeDeposit) - const disputeId = generateLegacyIndexingDisputeId(allocationId) - - // Verify dispute was created - const disputeExists = await legacyDisputeManager.isDisputeCreated(disputeId) - expect(disputeExists).to.be.true - - // Get state before slashing - const idxSlashingPercentage = 25000n - const indexerStakeBefore = (await staking.getServiceProvider(indexer.address)).tokensStaked - const slashedAmount = (indexerStakeBefore * idxSlashingPercentage) / 1_000_000n - const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) - - // Accept the dispute - await legacyDisputeManager.connect(arbitrator).acceptDispute(disputeId) - - // Verify indexer was slashed for 2.5% of their stake - const indexerStake = (await staking.getServiceProvider(indexer.address)).tokensStaked - expect(indexerStake).to.equal(indexerStakeBefore - slashedAmount, 'Indexer stake was not slashed correctly') - - // Verify fisherman received their deposit and 50% of the slashed amount - const fishermanBalance = await graphToken.balanceOf(fisherman.address) - expect(fishermanBalance).to.equal( - fishermanBalanceBefore + slashedAmount / 2n + disputeDeposit, - 'Fisherman balance was not updated correctly', - ) - }) - }) - - describe('Query Disputes', () => { - let allocationPrivateKey: string - let subgraphDeploymentId: string - - beforeEach(async () => { - // Get Indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Get allocation - const allocation = indexerFixture.legacyAllocations[0] - allocationPrivateKey = allocation.allocationPrivateKey - subgraphDeploymentId = allocation.subgraphDeploymentID - }) - - it('should allow creating and accepting query disputes', async () => { - // Create attestation data - const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) - const responseHash = ethers.keccak256(ethers.toUtf8Bytes('test-response')) - const attestationData = await generateAttestationData( - queryHash, - responseHash, - subgraphDeploymentId, - allocationPrivateKey, - await legacyDisputeManager.getAddress(), - chainId, - ) - - // Create a query dispute - await graphToken.connect(fisherman).approve(legacyDisputeManager.target, disputeDeposit) - await legacyDisputeManager.connect(fisherman).createQueryDispute(attestationData, disputeDeposit) - const disputeId = generateLegacyQueryDisputeId( - queryHash, - responseHash, - subgraphDeploymentId, - indexer.address, - fisherman.address, - ) - - // Verify dispute was created - const disputeExists = await legacyDisputeManager.isDisputeCreated(disputeId) - expect(disputeExists).to.be.true - - // Get state before slashing - const qrySlashingPercentage = 25000n - const indexerStakeBefore = (await staking.getServiceProvider(indexer.address)).tokensStaked - const slashedAmount = (indexerStakeBefore * qrySlashingPercentage) / 1_000_000n - const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) - - // Accept the dispute - await legacyDisputeManager.connect(arbitrator).acceptDispute(disputeId) - - // Verify indexer was slashed for 2.5% of their stake - const indexerStake = (await staking.getServiceProvider(indexer.address)).tokensStaked - expect(indexerStake).to.equal(indexerStakeBefore - slashedAmount, 'Indexer stake was not slashed correctly') - - // Verify fisherman received their deposit and 50% of the slashed amount - const fishermanBalance = await graphToken.balanceOf(fisherman.address) - expect(fishermanBalance).to.equal( - fishermanBalanceBefore + slashedAmount / 2n + disputeDeposit, - 'Fisherman balance was not updated correctly', - ) - }) - }) - - describe('Query Dispute Conflict', () => { - let allocationPrivateKey: string - let subgraphDeploymentId: string - - beforeEach(async () => { - // Get Indexer - const indexerFixture = indexers[0] - indexer = await ethers.getSigner(indexerFixture.address) - - // Get allocation - const allocation = indexerFixture.legacyAllocations[0] - allocationPrivateKey = allocation.allocationPrivateKey - subgraphDeploymentId = allocation.subgraphDeploymentID - }) - - it('should allow creating conflicting query disputes', async () => { - // Create first attestation data - const queryHash = ethers.keccak256(ethers.toUtf8Bytes('test-query')) - const responseHash1 = ethers.keccak256(ethers.toUtf8Bytes('test-response-1')) - const attestationData1 = await generateAttestationData( - queryHash, - responseHash1, - subgraphDeploymentId, - allocationPrivateKey, - await legacyDisputeManager.getAddress(), - chainId, - ) - - // Create second attestation data with different query/response - const responseHash2 = ethers.keccak256(ethers.toUtf8Bytes('test-response-2')) - const attestationData2 = await generateAttestationData( - queryHash, - responseHash2, - subgraphDeploymentId, - allocationPrivateKey, - await legacyDisputeManager.getAddress(), - chainId, - ) - - // Create query dispute - await legacyDisputeManager.connect(fisherman).createQueryDisputeConflict(attestationData1, attestationData2) - - // Create dispute IDs - const disputeId1 = generateLegacyQueryDisputeId( - queryHash, - responseHash1, - subgraphDeploymentId, - indexer.address, - fisherman.address, - ) - const disputeId2 = generateLegacyQueryDisputeId( - queryHash, - responseHash2, - subgraphDeploymentId, - indexer.address, - fisherman.address, - ) - - // Verify both disputes were created - const disputeExists1 = await legacyDisputeManager.isDisputeCreated(disputeId1) - const disputeExists2 = await legacyDisputeManager.isDisputeCreated(disputeId2) - expect(disputeExists1).to.be.true - expect(disputeExists2).to.be.true - - // Get state before slashing - const qrySlashingPercentage = 25000n - const indexerStakeBefore = (await staking.getServiceProvider(indexer.address)).tokensStaked - const slashedAmount = (indexerStakeBefore * qrySlashingPercentage) / 1_000_000n - const fishermanBalanceBefore = await graphToken.balanceOf(fisherman.address) - - // Accept one dispute - await legacyDisputeManager.connect(arbitrator).acceptDispute(disputeId1) - - // Verify indexer was slashed for 2.5% of their stake - const indexerStake = (await staking.getServiceProvider(indexer.address)).tokensStaked - expect(indexerStake).to.equal(indexerStakeBefore - slashedAmount, 'Indexer stake was not slashed correctly') - - // Verify fisherman received 50% of the slashed amount - const fishermanBalance = await graphToken.balanceOf(fisherman.address) - expect(fishermanBalance).to.equal( - fishermanBalanceBefore + slashedAmount / 2n, - 'Fisherman balance was not updated correctly', - ) - }) - }) -}) From da2dba755bede6611c00ce08e052b5f1d7e4cd5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Wed, 3 Dec 2025 11:28:40 -0300 Subject: [PATCH 31/59] feat: add fn to force withdraw legacy stake and delegation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/staking/HorizonStaking.sol | 75 ++++++---- .../delegation/forceWithdrawDelegated.t.sol | 134 ++++++++++++++++++ .../unit/staking/stake/forceWithdraw.t.sol | 118 +++++++++++++++ .../horizon/internal/IHorizonStakingMain.sol | 26 ++++ 4 files changed, 327 insertions(+), 26 deletions(-) create mode 100644 packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol create mode 100644 packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index fbafbfc67..57ef2453f 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -111,6 +111,11 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { _withdraw(msg.sender); } + /// @inheritdoc IHorizonStakingMain + function forceWithdraw(address serviceProvider) external override notPaused { + _withdraw(serviceProvider); + } + /* * PROVISIONS */ @@ -322,33 +327,15 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { address serviceProvider, address // deprecated - kept for backwards compatibility ) external override notPaused returns (uint256) { - // Get the delegation pool of the indexer - address delegator = msg.sender; - DelegationPoolInternal storage pool = _legacyDelegationPools[serviceProvider]; - DelegationInternal storage delegation = pool.delegators[delegator]; - - // Validation - uint256 tokensToWithdraw = 0; - uint256 currentEpoch = _graphEpochManager().currentEpoch(); - if ( - delegation.__DEPRECATED_tokensLockedUntil > 0 && currentEpoch >= delegation.__DEPRECATED_tokensLockedUntil - ) { - tokensToWithdraw = delegation.__DEPRECATED_tokensLocked; - } - require(tokensToWithdraw > 0, HorizonStakingNothingToWithdraw()); - - // Reset lock - delegation.__DEPRECATED_tokensLocked = 0; - delegation.__DEPRECATED_tokensLockedUntil = 0; - - emit StakeDelegatedWithdrawn(serviceProvider, delegator, tokensToWithdraw); - - // -- Interactions -- - - // Return tokens to the delegator - _graphToken().pushTokens(delegator, tokensToWithdraw); + return _withdrawDelegatedLegacy(serviceProvider, msg.sender); + } - return tokensToWithdraw; + /// @inheritdoc IHorizonStakingMain + function forceWithdrawDelegated( + address serviceProvider, + address delegator + ) external override notPaused returns (uint256) { + return _withdrawDelegatedLegacy(serviceProvider, delegator); } /* @@ -1122,6 +1109,42 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { emit OperatorSet(msg.sender, _verifier, _operator, _allowed); } + /** + * @notice Withdraw legacy undelegated tokens for a delegator. + * @dev This function handles pre-Horizon undelegations where tokens are locked + * in the legacy delegation pool. + * @param _serviceProvider The service provider address + * @param _delegator The delegator address + * @return The amount of tokens withdrawn + */ + function _withdrawDelegatedLegacy(address _serviceProvider, address _delegator) private returns (uint256) { + DelegationPoolInternal storage pool = _legacyDelegationPools[_serviceProvider]; + DelegationInternal storage delegation = pool.delegators[_delegator]; + + // Validation + uint256 tokensToWithdraw = 0; + uint256 currentEpoch = _graphEpochManager().currentEpoch(); + if ( + delegation.__DEPRECATED_tokensLockedUntil > 0 && currentEpoch >= delegation.__DEPRECATED_tokensLockedUntil + ) { + tokensToWithdraw = delegation.__DEPRECATED_tokensLocked; + } + require(tokensToWithdraw > 0, HorizonStakingNothingToWithdraw()); + + // Reset lock + delegation.__DEPRECATED_tokensLocked = 0; + delegation.__DEPRECATED_tokensLockedUntil = 0; + + emit StakeDelegatedWithdrawn(_serviceProvider, _delegator, tokensToWithdraw); + + // -- Interactions -- + + // Return tokens to the delegator + _graphToken().pushTokens(_delegator, tokensToWithdraw); + + return tokensToWithdraw; + } + /** * @notice Check if an operator is authorized for the caller on a specific verifier / data service. * @dev Note that this function handles the special case where the verifier is the subgraph data service, diff --git a/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol b/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol new file mode 100644 index 000000000..d60afe029 --- /dev/null +++ b/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import "forge-std/Test.sol"; + +import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; + +import { HorizonStakingTest } from "../HorizonStaking.t.sol"; + +contract HorizonStakingForceWithdrawDelegatedTest is HorizonStakingTest { + /* + * MODIFIERS + */ + + modifier useDelegator() { + resetPrank(users.delegator); + _; + } + + /* + * HELPERS + */ + + function _setLegacyDelegation( + address _indexer, + address _delegator, + uint256 _shares, + uint256 __DEPRECATED_tokensLocked, + uint256 __DEPRECATED_tokensLockedUntil + ) public { + // Calculate the base storage slot for the serviceProvider in the mapping + bytes32 baseSlot = keccak256(abi.encode(_indexer, uint256(20))); + + // Calculate the slot for the delegator's DelegationInternal struct + bytes32 delegatorSlot = keccak256(abi.encode(_delegator, bytes32(uint256(baseSlot) + 4))); + + // Use vm.store to set each field of the struct + vm.store(address(staking), bytes32(uint256(delegatorSlot)), bytes32(_shares)); + vm.store(address(staking), bytes32(uint256(delegatorSlot) + 1), bytes32(__DEPRECATED_tokensLocked)); + vm.store(address(staking), bytes32(uint256(delegatorSlot) + 2), bytes32(__DEPRECATED_tokensLockedUntil)); + } + + /* + * ACTIONS + */ + + function _forceWithdrawDelegated(address _indexer, address _delegator) internal { + IHorizonStakingTypes.DelegationPool memory pool = staking.getDelegationPool( + _indexer, + subgraphDataServiceLegacyAddress + ); + uint256 beforeStakingBalance = token.balanceOf(address(staking)); + uint256 beforeDelegatorBalance = token.balanceOf(_delegator); + + vm.expectEmit(address(staking)); + emit IHorizonStakingMain.StakeDelegatedWithdrawn(_indexer, _delegator, pool.tokens); + staking.forceWithdrawDelegated(_indexer, _delegator); + + uint256 afterStakingBalance = token.balanceOf(address(staking)); + uint256 afterDelegatorBalance = token.balanceOf(_delegator); + + assertEq(afterStakingBalance, beforeStakingBalance - pool.tokens); + assertEq(afterDelegatorBalance - pool.tokens, beforeDelegatorBalance); + + DelegationInternal memory delegation = _getStorage_Delegation( + _indexer, + subgraphDataServiceLegacyAddress, + _delegator, + true + ); + assertEq(delegation.shares, 0); + assertEq(delegation.__DEPRECATED_tokensLocked, 0); + assertEq(delegation.__DEPRECATED_tokensLockedUntil, 0); + } + + /* + * TESTS + */ + + function testForceWithdrawDelegated_Tokens(uint256 tokensLocked) public useDelegator { + vm.assume(tokensLocked > 0); + + _setStorage_DelegationPool(users.indexer, tokensLocked, 0, 0); + _setLegacyDelegation(users.indexer, users.delegator, 0, tokensLocked, 1); + token.transfer(address(staking), tokensLocked); + + // switch to a third party (not the delegator) + resetPrank(users.operator); + + _forceWithdrawDelegated(users.indexer, users.delegator); + } + + function testForceWithdrawDelegated_CalledByDelegator(uint256 tokensLocked) public useDelegator { + vm.assume(tokensLocked > 0); + + _setStorage_DelegationPool(users.indexer, tokensLocked, 0, 0); + _setLegacyDelegation(users.indexer, users.delegator, 0, tokensLocked, 1); + token.transfer(address(staking), tokensLocked); + + // delegator can also call forceWithdrawDelegated on themselves + _forceWithdrawDelegated(users.indexer, users.delegator); + } + + function testForceWithdrawDelegated_RevertWhen_NoTokens() public useDelegator { + _setStorage_DelegationPool(users.indexer, 0, 0, 0); + _setLegacyDelegation(users.indexer, users.delegator, 0, 0, 0); + + // switch to a third party + resetPrank(users.operator); + + bytes memory expectedError = abi.encodeWithSignature("HorizonStakingNothingToWithdraw()"); + vm.expectRevert(expectedError); + staking.forceWithdrawDelegated(users.indexer, users.delegator); + } + + function testForceWithdrawDelegated_RevertWhen_StillLocked(uint256 tokensLocked) public useDelegator { + vm.assume(tokensLocked > 0); + + // Set a future epoch for tokensLockedUntil + uint256 futureEpoch = 1000; + _setStorage_DelegationPool(users.indexer, tokensLocked, 0, 0); + _setLegacyDelegation(users.indexer, users.delegator, 0, tokensLocked, futureEpoch); + token.transfer(address(staking), tokensLocked); + + // switch to a third party + resetPrank(users.operator); + + // Should revert because tokens are still locked (current epoch < futureEpoch) + bytes memory expectedError = abi.encodeWithSignature("HorizonStakingNothingToWithdraw()"); + vm.expectRevert(expectedError); + staking.forceWithdrawDelegated(users.indexer, users.delegator); + } +} diff --git a/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol b/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol new file mode 100644 index 000000000..bd666d270 --- /dev/null +++ b/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import "forge-std/Test.sol"; + +import { IHorizonStakingMain } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol"; + +import { HorizonStakingTest } from "../HorizonStaking.t.sol"; + +contract HorizonStakingForceWithdrawTest is HorizonStakingTest { + /* + * HELPERS + */ + + function _forceWithdraw(address _serviceProvider) internal { + (, address msgSender, ) = vm.readCallers(); + + // before + ServiceProviderInternal memory beforeServiceProvider = _getStorage_ServiceProviderInternal(_serviceProvider); + uint256 beforeServiceProviderBalance = token.balanceOf(_serviceProvider); + uint256 beforeCallerBalance = token.balanceOf(msgSender); + uint256 beforeStakingBalance = token.balanceOf(address(staking)); + + // forceWithdraw + vm.expectEmit(address(staking)); + emit IHorizonStakingMain.HorizonStakeWithdrawn(_serviceProvider, beforeServiceProvider.__DEPRECATED_tokensLocked); + staking.forceWithdraw(_serviceProvider); + + // after + ServiceProviderInternal memory afterServiceProvider = _getStorage_ServiceProviderInternal(_serviceProvider); + uint256 afterServiceProviderBalance = token.balanceOf(_serviceProvider); + uint256 afterCallerBalance = token.balanceOf(msgSender); + uint256 afterStakingBalance = token.balanceOf(address(staking)); + + // assert - tokens go to service provider, not caller + assertEq(afterServiceProviderBalance - beforeServiceProviderBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); + assertEq(afterCallerBalance, beforeCallerBalance); // caller balance unchanged + assertEq(beforeStakingBalance - afterStakingBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); + + // assert - service provider state updated + assertEq( + afterServiceProvider.tokensStaked, + beforeServiceProvider.tokensStaked - beforeServiceProvider.__DEPRECATED_tokensLocked + ); + assertEq(afterServiceProvider.tokensProvisioned, beforeServiceProvider.tokensProvisioned); + assertEq(afterServiceProvider.__DEPRECATED_tokensAllocated, beforeServiceProvider.__DEPRECATED_tokensAllocated); + assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, 0); + assertEq(afterServiceProvider.__DEPRECATED_tokensLockedUntil, 0); + } + + /* + * TESTS + */ + + function testForceWithdraw_Tokens(uint256 tokens, uint256 tokensLocked) public useIndexer { + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + tokensLocked = bound(tokensLocked, 1, tokens); + + // simulate locked tokens ready to withdraw + token.transfer(address(staking), tokens); + _setStorage_ServiceProvider(users.indexer, tokens, 0, tokensLocked, block.number, 0); + + _createProvision(users.indexer, subgraphDataServiceAddress, tokens, 0, MAX_THAWING_PERIOD); + + // switch to a different user (not the service provider) + resetPrank(users.delegator); + + _forceWithdraw(users.indexer); + } + + function testForceWithdraw_CalledByServiceProvider(uint256 tokens, uint256 tokensLocked) public useIndexer { + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + tokensLocked = bound(tokensLocked, 1, tokens); + + // simulate locked tokens ready to withdraw + token.transfer(address(staking), tokens); + _setStorage_ServiceProvider(users.indexer, tokens, 0, tokensLocked, block.number, 0); + + _createProvision(users.indexer, subgraphDataServiceAddress, tokens, 0, MAX_THAWING_PERIOD); + + // before + ServiceProviderInternal memory beforeServiceProvider = _getStorage_ServiceProviderInternal(users.indexer); + uint256 beforeServiceProviderBalance = token.balanceOf(users.indexer); + uint256 beforeStakingBalance = token.balanceOf(address(staking)); + + // service provider can also call forceWithdraw on themselves + vm.expectEmit(address(staking)); + emit IHorizonStakingMain.HorizonStakeWithdrawn(users.indexer, beforeServiceProvider.__DEPRECATED_tokensLocked); + staking.forceWithdraw(users.indexer); + + // after + ServiceProviderInternal memory afterServiceProvider = _getStorage_ServiceProviderInternal(users.indexer); + uint256 afterServiceProviderBalance = token.balanceOf(users.indexer); + uint256 afterStakingBalance = token.balanceOf(address(staking)); + + // assert + assertEq(afterServiceProviderBalance - beforeServiceProviderBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); + assertEq(beforeStakingBalance - afterStakingBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); + assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, 0); + assertEq(afterServiceProvider.__DEPRECATED_tokensLockedUntil, 0); + } + + function testForceWithdraw_RevertWhen_ZeroTokens(uint256 tokens) public useIndexer { + tokens = bound(tokens, 1, MAX_STAKING_TOKENS); + + // simulate zero locked tokens + token.transfer(address(staking), tokens); + _setStorage_ServiceProvider(users.indexer, tokens, 0, 0, 0, 0); + + _createProvision(users.indexer, subgraphDataServiceLegacyAddress, tokens, 0, MAX_THAWING_PERIOD); + + // switch to a different user + resetPrank(users.delegator); + + vm.expectRevert(abi.encodeWithSelector(IHorizonStakingMain.HorizonStakingInvalidZeroTokens.selector)); + staking.forceWithdraw(users.indexer); + } +} diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index 11f7f575f..b5763a8f2 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -952,4 +952,30 @@ interface IHorizonStakingMain { * @return Whether the operator is authorized or not */ function isAuthorized(address serviceProvider, address verifier, address operator) external view returns (bool); + + /** + * @notice Withdraw service provider legacy locked tokens. + * This is a permissionless function that allows anyone to withdraw on behalf of a service provider. + * It only allows withdrawing tokens that were unstaked before the Horizon upgrade. + * @dev Tokens are always sent to the service provider, not the caller. + * + * Emits a {HorizonStakeWithdrawn} event. + * + * @param serviceProvider Address of service provider to withdraw funds from + */ + function forceWithdraw(address serviceProvider) external; + + /** + * @notice Withdraw delegator legacy undelegated tokens. + * This is a permissionless function that allows anyone to withdraw on behalf of a delegator. + * It only allows withdrawing tokens that were undelegated before the Horizon upgrade. + * @dev Tokens are always sent to the delegator, not the caller. + * + * Emits a {StakeDelegatedWithdrawn} event. + * + * @param serviceProvider The service provider address + * @param delegator The delegator address to withdraw funds for + * @return The amount of tokens withdrawn + */ + function forceWithdrawDelegated(address serviceProvider, address delegator) external returns (uint256); } From dd8d62416aed798bc42ea9c8414c132c71bf0ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Wed, 3 Dec 2025 11:29:09 -0300 Subject: [PATCH 32/59] chore: lint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../test/unit/staking/stake/forceWithdraw.t.sol | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol b/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol index bd666d270..5e75b7d25 100644 --- a/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol +++ b/packages/horizon/test/unit/staking/stake/forceWithdraw.t.sol @@ -23,7 +23,10 @@ contract HorizonStakingForceWithdrawTest is HorizonStakingTest { // forceWithdraw vm.expectEmit(address(staking)); - emit IHorizonStakingMain.HorizonStakeWithdrawn(_serviceProvider, beforeServiceProvider.__DEPRECATED_tokensLocked); + emit IHorizonStakingMain.HorizonStakeWithdrawn( + _serviceProvider, + beforeServiceProvider.__DEPRECATED_tokensLocked + ); staking.forceWithdraw(_serviceProvider); // after @@ -33,7 +36,10 @@ contract HorizonStakingForceWithdrawTest is HorizonStakingTest { uint256 afterStakingBalance = token.balanceOf(address(staking)); // assert - tokens go to service provider, not caller - assertEq(afterServiceProviderBalance - beforeServiceProviderBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); + assertEq( + afterServiceProviderBalance - beforeServiceProviderBalance, + beforeServiceProvider.__DEPRECATED_tokensLocked + ); assertEq(afterCallerBalance, beforeCallerBalance); // caller balance unchanged assertEq(beforeStakingBalance - afterStakingBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); @@ -94,7 +100,10 @@ contract HorizonStakingForceWithdrawTest is HorizonStakingTest { uint256 afterStakingBalance = token.balanceOf(address(staking)); // assert - assertEq(afterServiceProviderBalance - beforeServiceProviderBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); + assertEq( + afterServiceProviderBalance - beforeServiceProviderBalance, + beforeServiceProvider.__DEPRECATED_tokensLocked + ); assertEq(beforeStakingBalance - afterStakingBalance, beforeServiceProvider.__DEPRECATED_tokensLocked); assertEq(afterServiceProvider.__DEPRECATED_tokensLocked, 0); assertEq(afterServiceProvider.__DEPRECATED_tokensLockedUntil, 0); From 0f71288102508fe2eec830791acf9889aad42759 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 19 Dec 2025 11:51:01 -0300 Subject: [PATCH 33/59] fix: re-validate thawingPeriod when accepting provision parameters (OZ L-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/staking/HorizonStaking.sol | 5 +++ .../unit/staking/provision/parameters.t.sol | 32 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 57ef2453f..9b8f5b817 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -216,6 +216,11 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { require(prov.createdAt != 0, HorizonStakingInvalidProvision(serviceProvider, verifier)); if ((prov.maxVerifierCutPending != prov.maxVerifierCut) || (prov.thawingPeriodPending != prov.thawingPeriod)) { + // Re-validate thawing period in case governor reduced _maxThawingPeriod after staging + require( + prov.thawingPeriodPending <= _maxThawingPeriod, + HorizonStakingInvalidThawingPeriod(prov.thawingPeriodPending, _maxThawingPeriod) + ); prov.maxVerifierCut = prov.maxVerifierCutPending; prov.thawingPeriod = prov.thawingPeriodPending; emit ProvisionParametersSet(serviceProvider, verifier, prov.maxVerifierCut, prov.thawingPeriod); diff --git a/packages/horizon/test/unit/staking/provision/parameters.t.sol b/packages/horizon/test/unit/staking/provision/parameters.t.sol index f7c74f508..f9ab3202a 100644 --- a/packages/horizon/test/unit/staking/provision/parameters.t.sol +++ b/packages/horizon/test/unit/staking/provision/parameters.t.sol @@ -177,4 +177,36 @@ contract HorizonStakingProvisionParametersTest is HorizonStakingTest { ); staking.acceptProvisionParameters(users.indexer); } + + function test_ProvisionParametersAccept_RevertWhen_MaxThawingPeriodReduced( + uint256 amount, + uint32 maxVerifierCut, + uint64 thawingPeriod + ) public useIndexer useValidParameters(maxVerifierCut, thawingPeriod) { + vm.assume(amount > 0); + vm.assume(amount <= MAX_STAKING_TOKENS); + vm.assume(thawingPeriod > 0); + + // Create provision with initial parameters (thawingPeriod = 0) + _createProvision(users.indexer, subgraphDataServiceAddress, amount, 0, 0); + + // Stage new parameters with valid thawing period + _setProvisionParameters(users.indexer, subgraphDataServiceAddress, maxVerifierCut, thawingPeriod); + + // Governor reduces max thawing period to below the staged value + uint64 newMaxThawingPeriod = thawingPeriod - 1; + resetPrank(users.governor); + _setMaxThawingPeriod(newMaxThawingPeriod); + + // Verifier tries to accept the parameters - should revert + resetPrank(subgraphDataServiceAddress); + vm.expectRevert( + abi.encodeWithSelector( + IHorizonStakingMain.HorizonStakingInvalidThawingPeriod.selector, + thawingPeriod, + newMaxThawingPeriod + ) + ); + staking.acceptProvisionParameters(users.indexer); + } } From a91f59ad479aa470b6f892ec1dab7012464087a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 19 Dec 2025 11:59:35 -0300 Subject: [PATCH 34/59] fix: return correct result for getThawedTokens when called for delegations (OZ L-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/staking/HorizonStakingBase.sol | 20 +++++-- .../unit/staking/delegation/withdraw.t.sol | 52 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/horizon/contracts/staking/HorizonStakingBase.sol b/packages/horizon/contracts/staking/HorizonStakingBase.sol index 75e2cd37c..ab637d5da 100644 --- a/packages/horizon/contracts/staking/HorizonStakingBase.sol +++ b/packages/horizon/contracts/staking/HorizonStakingBase.sol @@ -182,14 +182,26 @@ abstract contract HorizonStakingBase is } uint256 thawedTokens = 0; - Provision storage prov = _provisions[serviceProvider][verifier]; - uint256 tokensThawing = prov.tokensThawing; - uint256 sharesThawing = prov.sharesThawing; + uint256 tokensThawing; + uint256 sharesThawing; + uint256 thawingNonce; + + if (requestType == ThawRequestType.Provision) { + Provision storage prov = _provisions[serviceProvider][verifier]; + tokensThawing = prov.tokensThawing; + sharesThawing = prov.sharesThawing; + thawingNonce = prov.thawingNonce; + } else { + DelegationPoolInternal storage pool = _getDelegationPool(serviceProvider, verifier); + tokensThawing = pool.tokensThawing; + sharesThawing = pool.sharesThawing; + thawingNonce = pool.thawingNonce; + } bytes32 thawRequestId = thawRequestList.head; while (thawRequestId != bytes32(0)) { ThawRequest storage thawRequest = _getThawRequest(requestType, thawRequestId); - if (thawRequest.thawingNonce == prov.thawingNonce) { + if (thawRequest.thawingNonce == thawingNonce) { if (thawRequest.thawingUntil <= block.timestamp) { // sharesThawing cannot be zero if there is a valid thaw request so the next division is safe uint256 tokens = (thawRequest.shares * tokensThawing) / sharesThawing; diff --git a/packages/horizon/test/unit/staking/delegation/withdraw.t.sol b/packages/horizon/test/unit/staking/delegation/withdraw.t.sol index 948961591..faeb4d78b 100644 --- a/packages/horizon/test/unit/staking/delegation/withdraw.t.sol +++ b/packages/horizon/test/unit/staking/delegation/withdraw.t.sol @@ -162,4 +162,56 @@ contract HorizonStakingWithdrawDelegationTest is HorizonStakingTest { resetPrank(users.delegator); _withdrawDelegated(users.indexer, subgraphDataServiceAddress, 0); } + + function testWithdrawDelegation_GetThawedTokens( + uint256 delegationAmount, + uint256 withdrawShares + ) + public + useIndexer + useProvision(10_000_000 ether, 0, MAX_THAWING_PERIOD) + useDelegation(delegationAmount) + useUndelegate(withdrawShares) + { + ILinkedList.List memory thawingRequests = staking.getThawRequestList( + IHorizonStakingTypes.ThawRequestType.Delegation, + users.indexer, + subgraphDataServiceAddress, + users.delegator + ); + ThawRequest memory thawRequest = staking.getThawRequest( + IHorizonStakingTypes.ThawRequestType.Delegation, + thawingRequests.tail + ); + + // Before thawing period passes, thawed tokens should be 0 + uint256 thawedTokensBefore = staking.getThawedTokens( + IHorizonStakingTypes.ThawRequestType.Delegation, + users.indexer, + subgraphDataServiceAddress, + users.delegator + ); + assertEq(thawedTokensBefore, 0); + + // Skip past thawing period + skip(thawRequest.thawingUntil + 1); + + // After thawing period, thawed tokens should match expected amount + uint256 thawedTokensAfter = staking.getThawedTokens( + IHorizonStakingTypes.ThawRequestType.Delegation, + users.indexer, + subgraphDataServiceAddress, + users.delegator + ); + + // Thawed tokens should be greater than 0 and should match what we can withdraw + assertGt(thawedTokensAfter, 0); + + // Withdraw and verify the amount matches + uint256 balanceBefore = token.balanceOf(users.delegator); + _withdrawDelegated(users.indexer, subgraphDataServiceAddress, 0); + uint256 balanceAfter = token.balanceOf(users.delegator); + + assertEq(balanceAfter - balanceBefore, thawedTokensAfter); + } } From ed356fe40546c2a9fcab1072d815767d657148bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 19 Dec 2025 12:10:15 -0300 Subject: [PATCH 35/59] fix: remove more deprecated code (OZ N-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/l2/curation/L2Curation.sol | 7 ++----- .../test/tests/unit/l2/l2Curation.test.ts | 2 +- .../contracts/staking/HorizonStaking.sol | 5 +---- .../delegation/forceWithdrawDelegated.t.sol | 18 ------------------ 4 files changed, 4 insertions(+), 28 deletions(-) diff --git a/packages/contracts/contracts/l2/curation/L2Curation.sol b/packages/contracts/contracts/l2/curation/L2Curation.sol index 56e83c13a..fd26bd2ac 100644 --- a/packages/contracts/contracts/l2/curation/L2Curation.sol +++ b/packages/contracts/contracts/l2/curation/L2Curation.sol @@ -171,11 +171,8 @@ contract L2Curation is CurationV3Storage, GraphUpgradeable, IL2Curation { * @param _tokens Amount of Graph Tokens to add to reserves */ function collect(bytes32 _subgraphDeploymentID, uint256 _tokens) external override { - // Only SubgraphService and Staking contract are authorized as callers - require( - msg.sender == subgraphService || msg.sender == address(staking()), - "Caller must be the subgraph service or staking contract" - ); + // Only SubgraphService is authorized as caller + require(msg.sender == subgraphService, "Caller must be the subgraph service"); // Must be curated to accept tokens require(isCurated(_subgraphDeploymentID), "Subgraph deployment must be curated to collect fees"); diff --git a/packages/contracts/test/tests/unit/l2/l2Curation.test.ts b/packages/contracts/test/tests/unit/l2/l2Curation.test.ts index 6ee8a5cd3..77024b4b0 100644 --- a/packages/contracts/test/tests/unit/l2/l2Curation.test.ts +++ b/packages/contracts/test/tests/unit/l2/l2Curation.test.ts @@ -529,7 +529,7 @@ describe('L2Curation', () => { it('reject collect tokens distributed from invalid address', async function () { const tx = curation.connect(me).collect(subgraphDeploymentID, tokensToCollect) - await expect(tx).revertedWith('Caller must be the subgraph service or staking contract') + await expect(tx).revertedWith('Caller must be the subgraph service') }) it('should collect tokens distributed to the curation pool', async function () { diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 9b8f5b817..6d1de6226 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -1128,10 +1128,7 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { // Validation uint256 tokensToWithdraw = 0; - uint256 currentEpoch = _graphEpochManager().currentEpoch(); - if ( - delegation.__DEPRECATED_tokensLockedUntil > 0 && currentEpoch >= delegation.__DEPRECATED_tokensLockedUntil - ) { + if (delegation.__DEPRECATED_tokensLockedUntil > 0) { tokensToWithdraw = delegation.__DEPRECATED_tokensLocked; } require(tokensToWithdraw > 0, HorizonStakingNothingToWithdraw()); diff --git a/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol b/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol index d60afe029..af823a8d3 100644 --- a/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol +++ b/packages/horizon/test/unit/staking/delegation/forceWithdrawDelegated.t.sol @@ -113,22 +113,4 @@ contract HorizonStakingForceWithdrawDelegatedTest is HorizonStakingTest { vm.expectRevert(expectedError); staking.forceWithdrawDelegated(users.indexer, users.delegator); } - - function testForceWithdrawDelegated_RevertWhen_StillLocked(uint256 tokensLocked) public useDelegator { - vm.assume(tokensLocked > 0); - - // Set a future epoch for tokensLockedUntil - uint256 futureEpoch = 1000; - _setStorage_DelegationPool(users.indexer, tokensLocked, 0, 0); - _setLegacyDelegation(users.indexer, users.delegator, 0, tokensLocked, futureEpoch); - token.transfer(address(staking), tokensLocked); - - // switch to a third party - resetPrank(users.operator); - - // Should revert because tokens are still locked (current epoch < futureEpoch) - bytes memory expectedError = abi.encodeWithSignature("HorizonStakingNothingToWithdraw()"); - vm.expectRevert(expectedError); - staking.forceWithdrawDelegated(users.indexer, users.delegator); - } } From 1c3e30607f1775886fcfa1f618a2c9b74786f231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 19 Dec 2025 12:14:42 -0300 Subject: [PATCH 36/59] fix: outdated documentation (OZ N-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- packages/horizon/contracts/staking/HorizonStaking.sol | 4 ++-- .../contracts/contracts/rewards/IRewardsManager.sol | 2 +- .../contracts/horizon/internal/IHorizonStakingMain.sol | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 6d1de6226..8998728e5 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -842,8 +842,8 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { * @dev The parameter `nThawRequests` can be set to a non zero value to fulfill a specific number of thaw * requests in the event that fulfilling all of them results in a gas limit error. Otherwise, the function * will attempt to fulfill all thaw requests until the first one that is not yet expired is found. - * @dev If the delegation pool was completely slashed before withdrawing, calling this function will fulfill - * the thaw requests with an amount equal to zero. + * @dev If the delegation pool was completely slashed before withdrawing, calling this function will revert + * until the pool state is repaired with {IHorizonStakingMain-addToDelegationPool}. * @param _serviceProvider The service provider address * @param _verifier The verifier address * @param _newServiceProvider The new service provider address diff --git a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol index 72a73e19b..33daf71fe 100644 --- a/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol +++ b/packages/interfaces/contracts/contracts/rewards/IRewardsManager.sol @@ -122,7 +122,7 @@ interface IRewardsManager { /** * @notice Pull rewards from the contract for a particular allocation - * @dev This function can only be called by the Staking contract. + * @dev This function can only be called by the Subgraph Service contract. * This function will mint the necessary tokens to reward based on the inflation calculation. * @param allocationID Allocation * @return Assigned rewards amount diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index b5763a8f2..ddc595409 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -783,7 +783,7 @@ interface IHorizonStakingMain { * - `newServiceProvider` and `newVerifier` must not be the zero address. * - `newServiceProvider` must have previously provisioned stake to `newVerifier`. * - * Emits {ThawRequestFulfilled}, {ThawRequestsFulfilled} and {DelegatedTokensWithdrawn} events. + * Emits {ThawRequestFulfilled} and {ThawRequestsFulfilled} events. * * @param oldServiceProvider The old service provider address * @param oldVerifier The old verifier address From d9f053a7d96a8a4d81415303ae1d537f836f887c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Fri, 19 Dec 2025 12:39:26 -0300 Subject: [PATCH 37/59] test: fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../test/tests/unit/l2/l2Curation.test.ts | 25 +++-- .../test/tests/unit/l2/l2GNS.test.ts | 106 +----------------- 2 files changed, 15 insertions(+), 116 deletions(-) diff --git a/packages/contracts/test/tests/unit/l2/l2Curation.test.ts b/packages/contracts/test/tests/unit/l2/l2Curation.test.ts index 77024b4b0..a680ec28c 100644 --- a/packages/contracts/test/tests/unit/l2/l2Curation.test.ts +++ b/packages/contracts/test/tests/unit/l2/l2Curation.test.ts @@ -154,7 +154,7 @@ describe('L2Curation', () => { let me: SignerWithAddress let governor: SignerWithAddress let curator: SignerWithAddress - let stakingMock: SignerWithAddress + let subgraphServiceMock: SignerWithAddress let gnsImpersonator: Signer let fixture: NetworkFixture @@ -310,8 +310,8 @@ describe('L2Curation', () => { const beforeTotalBalance = await grt.balanceOf(curation.address) // Source of tokens must be the staking for this to work - await grt.connect(stakingMock).transfer(curation.address, tokensToCollect) - const tx = curation.connect(stakingMock).collect(subgraphDeploymentID, tokensToCollect) + await grt.connect(subgraphServiceMock).transfer(curation.address, tokensToCollect) + const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect) await expect(tx).emit(curation, 'Collected').withArgs(subgraphDeploymentID, tokensToCollect) // After state @@ -325,7 +325,7 @@ describe('L2Curation', () => { before(async function () { // Use stakingMock so we can call collect - ;[me, curator, stakingMock] = await graph.getTestAccounts() + ;[me, curator, subgraphServiceMock] = await graph.getTestAccounts() ;({ governor } = await graph.getNamedAccounts()) fixture = new NetworkFixture(graph.provider) contracts = await fixture.load(governor, true) @@ -343,8 +343,11 @@ describe('L2Curation', () => { await grt.connect(gnsImpersonator).approve(curation.address, curatorTokens) // Give some funds to the staking contract and approve the curation contract - await grt.connect(governor).mint(stakingMock.address, tokensToCollect) - await grt.connect(stakingMock).approve(curation.address, tokensToCollect) + await grt.connect(governor).mint(subgraphServiceMock.address, tokensToCollect) + await grt.connect(subgraphServiceMock).approve(curation.address, tokensToCollect) + + // Set the subgraph service + await curation.connect(governor).setSubgraphService(subgraphServiceMock.address) }) beforeEach(async function () { @@ -514,10 +517,10 @@ describe('L2Curation', () => { context('> not curated', function () { it('reject collect tokens distributed to the curation pool', async function () { // Source of tokens must be the staking for this to work - await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address) + await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking - const tx = curation.connect(stakingMock).collect(subgraphDeploymentID, tokensToCollect) + const tx = curation.connect(subgraphServiceMock).collect(subgraphDeploymentID, tokensToCollect) await expect(tx).revertedWith('Subgraph deployment must be curated to collect fees') }) }) @@ -533,7 +536,7 @@ describe('L2Curation', () => { }) it('should collect tokens distributed to the curation pool', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address) + await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking await shouldCollect(toGRT('1')) @@ -544,7 +547,7 @@ describe('L2Curation', () => { }) it('should collect tokens and then unsignal all', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address) + await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking // Collect increase the pool reserves @@ -556,7 +559,7 @@ describe('L2Curation', () => { }) it('should collect tokens and then unsignal multiple times', async function () { - await controller.connect(governor).setContractProxy(utils.id('Staking'), stakingMock.address) + await controller.connect(governor).setContractProxy(utils.id('Staking'), subgraphServiceMock.address) await curation.connect(governor).syncAllContracts() // call sync because we change the proxy for staking // Collect increase the pool reserves diff --git a/packages/contracts/test/tests/unit/l2/l2GNS.test.ts b/packages/contracts/test/tests/unit/l2/l2GNS.test.ts index 5b8f1d028..0fd691939 100644 --- a/packages/contracts/test/tests/unit/l2/l2GNS.test.ts +++ b/packages/contracts/test/tests/unit/l2/l2GNS.test.ts @@ -2,12 +2,10 @@ import { L2GNS } from '@graphprotocol/contracts' import { L2GraphTokenGateway } from '@graphprotocol/contracts' import { L2Curation } from '@graphprotocol/contracts' import { GraphToken } from '@graphprotocol/contracts' -import { IL2Staking } from '@graphprotocol/contracts' import { L1GNS, L1GraphTokenGateway } from '@graphprotocol/contracts' import { buildSubgraph, buildSubgraphId, - deriveChannelKey, GraphNetworkContracts, helpers, PublishSubgraph, @@ -44,7 +42,6 @@ interface L1SubgraphParams { describe('L2GNS', () => { const graph = hre.graph() let me: SignerWithAddress - let attacker: SignerWithAddress let other: SignerWithAddress let governor: SignerWithAddress let fixture: NetworkFixture @@ -58,7 +55,6 @@ describe('L2GNS', () => { let gns: L2GNS let curation: L2Curation let grt: GraphToken - let staking: IL2Staking let newSubgraph0: PublishSubgraph let newSubgraph1: PublishSubgraph @@ -109,7 +105,7 @@ describe('L2GNS', () => { before(async function () { newSubgraph0 = buildSubgraph() - ;[me, attacker, other] = await graph.getTestAccounts() + ;[me, other] = await graph.getTestAccounts() ;({ governor } = await graph.getNamedAccounts()) fixture = new NetworkFixture(graph.provider) @@ -118,7 +114,6 @@ describe('L2GNS', () => { fixtureContracts = await fixture.load(governor, true) l2GraphTokenGateway = fixtureContracts.L2GraphTokenGateway as L2GraphTokenGateway gns = fixtureContracts.L2GNS as L2GNS - staking = fixtureContracts.L2Staking as unknown as IL2Staking curation = fixtureContracts.L2Curation as L2Curation grt = fixtureContracts.GraphToken as GraphToken @@ -354,61 +349,6 @@ describe('L2GNS', () => { .emit(gns, 'SignalMinted') .withArgs(l2SubgraphId, me.address, expectedNSignal, expectedSignal, curatedTokens) }) - it('protects the owner against a rounding attack', async function () { - const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - const collectTokens = curatedTokens.mul(20) - - await staking.connect(governor).setCurationPercentage(100000) - - // Set up an indexer account with some stake - await grt.connect(governor).mint(attacker.address, toGRT('1000000')) - // Curate 1 wei GRT by minting 1 GRT and burning most of it - await grt.connect(attacker).approve(curation.address, toBN(1)) - await curation.connect(attacker).mint(newSubgraph0.subgraphDeploymentID, toBN(1), 0) - - // Check this actually gave us 1 wei signal - expect(await curation.getCurationPoolTokens(newSubgraph0.subgraphDeploymentID)).eq(1) - await grt.connect(attacker).approve(staking.address, toGRT('1000000')) - await staking.connect(attacker).stake(toGRT('100000')) - const channelKey = deriveChannelKey() - // Allocate to the same deployment ID - await staking - .connect(attacker) - .allocateFrom( - attacker.address, - newSubgraph0.subgraphDeploymentID, - toGRT('100000'), - channelKey.address, - randomHexBytes(32), - await channelKey.generateProof(attacker.address), - ) - // Spoof some query fees, 10% of which will go to the Curation pool - await staking.connect(attacker).collect(collectTokens, channelKey.address) - // The curation pool now has 1 wei shares and a lot of tokens, so the rounding attack is prepared - // But L2GNS will protect the owner by sending the tokens - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) - await gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatedTokens, callhookData) - - const l2SubgraphId = await gns.getAliasedL2SubgraphID(l1SubgraphId) - const tx = gns - .connect(me) - .finishSubgraphTransferFromL1( - l2SubgraphId, - newSubgraph0.subgraphDeploymentID, - subgraphMetadata, - versionMetadata, - ) - await expect(tx) - .emit(gns, 'SubgraphPublished') - .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, DEFAULT_RESERVE_RATIO) - await expect(tx).emit(gns, 'SubgraphMetadataUpdated').withArgs(l2SubgraphId, subgraphMetadata) - await expect(tx).emit(gns, 'CuratorBalanceReturnedToBeneficiary') - await expect(tx).emit(gns, 'SubgraphUpgraded').withArgs(l2SubgraphId, 0, 0, newSubgraph0.subgraphDeploymentID) - await expect(tx) - .emit(gns, 'SubgraphVersionUpdated') - .withArgs(l2SubgraphId, newSubgraph0.subgraphDeploymentID, versionMetadata) - await expect(tx).emit(gns, 'SubgraphL2TransferFinalized').withArgs(l2SubgraphId) - }) it('cannot be called by someone other than the subgraph owner', async function () { const { l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(0), l1SubgraphId, me.address]) @@ -654,50 +594,6 @@ describe('L2GNS', () => { expect(gnsBalanceAfter).eq(gnsBalanceBefore) }) - it('protects the curator against a rounding attack', async function () { - // Transfer a subgraph from L1 with only 1 wei GRT of curated signal - const { l1SubgraphId, subgraphMetadata, versionMetadata } = await defaultL1SubgraphParams() - const curatedTokens = toBN('1') - await transferMockSubgraphFromL1(l1SubgraphId, curatedTokens, subgraphMetadata, versionMetadata) - // Prepare the rounding attack by setting up an indexer and collecting a lot of query fees - const curatorTokens = toGRT('10000') - const collectTokens = curatorTokens.mul(20) - await staking.connect(governor).setCurationPercentage(100000) - // Set up an indexer account with some stake - await grt.connect(governor).mint(attacker.address, toGRT('1000000')) - - await grt.connect(attacker).approve(staking.address, toGRT('1000000')) - await staking.connect(attacker).stake(toGRT('100000')) - const channelKey = deriveChannelKey() - // Allocate to the same deployment ID - await staking - .connect(attacker) - .allocateFrom( - attacker.address, - newSubgraph0.subgraphDeploymentID, - toGRT('100000'), - channelKey.address, - randomHexBytes(32), - await channelKey.generateProof(attacker.address), - ) - // Spoof some query fees, 10% of which will go to the Curation pool - await staking.connect(attacker).collect(collectTokens, channelKey.address) - - const callhookData = defaultAbiCoder.encode(['uint8', 'uint256', 'address'], [toBN(1), l1SubgraphId, me.address]) - const curatorTokensBefore = await grt.balanceOf(me.address) - const gnsBalanceBefore = await grt.balanceOf(gns.address) - const tx = gatewayFinalizeTransfer(l1GNSMock.address, gns.address, curatorTokens, callhookData) - await expect(tx) - .emit(gns, 'CuratorBalanceReturnedToBeneficiary') - .withArgs(l1SubgraphId, me.address, curatorTokens) - const curatorTokensAfter = await grt.balanceOf(me.address) - expect(curatorTokensAfter).eq(curatorTokensBefore.add(curatorTokens)) - const gnsBalanceAfter = await grt.balanceOf(gns.address) - // gatewayFinalizeTransfer will mint the tokens that are sent to the curator, - // so the GNS balance should be the same - expect(gnsBalanceAfter).eq(gnsBalanceBefore) - }) - it('if a subgraph was deprecated after transfer, it returns the tokens to the beneficiary', async function () { const l1GNSMockL2Alias = await helpers.getL2SignerFromL1(l1GNSMock.address) // Eth for gas: From 1ea8d5b3fd05ba7eac99d2e4ae77ae5c477ad1d1 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 9 Feb 2026 15:58:44 -0300 Subject: [PATCH 38/59] chore: bump solidty version for dips contracts --- .../horizon/contracts/data-service/libraries/StakeClaims.sol | 2 +- packages/horizon/contracts/interfaces/IRecurringCollector.sol | 2 +- .../contracts/payments/collectors/RecurringCollector.sol | 2 +- packages/horizon/test/unit/libraries/LinkedList.t.sol | 2 +- packages/horizon/test/unit/libraries/PPMMath.t.sol | 2 +- packages/horizon/test/unit/libraries/StakeClaims.t.sol | 2 +- .../unit/payments/recurring-collector/PaymentsEscrowMock.t.sol | 2 +- .../RecurringCollectorAuthorizableTest.t.sol | 2 +- .../payments/recurring-collector/RecurringCollectorHelper.t.sol | 2 +- .../horizon/test/unit/payments/recurring-collector/accept.t.sol | 2 +- .../horizon/test/unit/payments/recurring-collector/base.t.sol | 2 +- .../horizon/test/unit/payments/recurring-collector/cancel.t.sol | 2 +- .../test/unit/payments/recurring-collector/collect.t.sol | 2 +- .../horizon/test/unit/payments/recurring-collector/shared.t.sol | 2 +- .../horizon/test/unit/payments/recurring-collector/update.t.sol | 2 +- .../subgraph-service/contracts/libraries/AllocationHandler.sol | 2 +- .../subgraph-service/contracts/libraries/IndexingAgreement.sol | 2 +- .../contracts/libraries/IndexingAgreementDecoder.sol | 2 +- .../contracts/libraries/IndexingAgreementDecoderRaw.sol | 2 +- .../test/unit/libraries/IndexingAgreement.t.sol | 2 +- .../test/unit/subgraphService/indexing-agreement/accept.t.sol | 2 +- .../test/unit/subgraphService/indexing-agreement/base.t.sol | 2 +- .../test/unit/subgraphService/indexing-agreement/cancel.t.sol | 2 +- .../test/unit/subgraphService/indexing-agreement/collect.t.sol | 2 +- .../unit/subgraphService/indexing-agreement/integration.t.sol | 2 +- .../test/unit/subgraphService/indexing-agreement/shared.t.sol | 2 +- .../test/unit/subgraphService/indexing-agreement/update.t.sol | 2 +- 27 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol index 5269d7ec4..e535eed44 100644 --- a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol +++ b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { ProvisionTracker } from "./ProvisionTracker.sol"; import { IHorizonStaking } from "../../interfaces/IHorizonStaking.sol"; diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol index c4930a954..447655823 100644 --- a/packages/horizon/contracts/interfaces/IRecurringCollector.sol +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IPaymentsCollector } from "./IPaymentsCollector.sol"; import { IGraphPayments } from "./IGraphPayments.sol"; diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 99bf5d3fb..d04d3723b 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { EIP712 } from "@openzeppelin/contracts/utils/cryptography/EIP712.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; diff --git a/packages/horizon/test/unit/libraries/LinkedList.t.sol b/packages/horizon/test/unit/libraries/LinkedList.t.sol index 13f31b8ad..f44e5c2a2 100644 --- a/packages/horizon/test/unit/libraries/LinkedList.t.sol +++ b/packages/horizon/test/unit/libraries/LinkedList.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity 0.8.33; import "forge-std/console.sol"; import { Test } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/libraries/PPMMath.t.sol b/packages/horizon/test/unit/libraries/PPMMath.t.sol index a2d011aeb..f0a8e9d31 100644 --- a/packages/horizon/test/unit/libraries/PPMMath.t.sol +++ b/packages/horizon/test/unit/libraries/PPMMath.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity 0.8.33; import "forge-std/console.sol"; import { Test } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/libraries/StakeClaims.t.sol b/packages/horizon/test/unit/libraries/StakeClaims.t.sol index d98bdf78e..48eca89a7 100644 --- a/packages/horizon/test/unit/libraries/StakeClaims.t.sol +++ b/packages/horizon/test/unit/libraries/StakeClaims.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { Test } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol index 36ebdda18..3e97f1685 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol"; import { IPaymentsEscrow } from "../../../../contracts/interfaces/IPaymentsEscrow.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol index 91244fea1..6c5883e5e 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IAuthorizable } from "../../../../contracts/interfaces/IAuthorizable.sol"; import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol index 6ddbdfa0b..2c34d3850 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol index f7a4c3823..7950ae59e 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/base.t.sol b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol index 9512fbf87..ece717397 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/base.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol index fa3b595a0..c6282825f 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index 738a0415c..232c95a20 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol index 2e76c048e..c05f5495e 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { Test } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol index ea34f895b..05805f016 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index 62720951c..d22f611cb 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { IEpochManager } from "@graphprotocol/contracts/contracts/epochs/IEpochManager.sol"; diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 02f99be88..74d77c7c0 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol index a50e53f0d..def26d5b5 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IndexingAgreementDecoderRaw } from "./IndexingAgreementDecoderRaw.sol"; import { IndexingAgreement } from "./IndexingAgreement.sol"; diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol index 93b1718bf..4cd9e7cc7 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IndexingAgreement } from "./IndexingAgreement.sol"; diff --git a/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol index a545c8571..d3271c7ff 100644 --- a/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol +++ b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { Test } from "forge-std/Test.sol"; 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 index 8e7cafdf6..5b178d5d9 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; 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 index 5b7aba56f..923d3941f 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; 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 index 2c904f156..38916be5d 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; 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 index 3f7a5657c..9d55dadd8 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; 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 index 660658450..6ca23de35 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; 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 index 09660ff57..8f72e501e 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; 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 index ba14d8e55..758728944 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.33; import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; From 3c1a2f1113c4ef28a55378c9cdec8883dfdc02d8 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 9 Feb 2026 19:28:28 -0300 Subject: [PATCH 39/59] chore: update pragma versions and centralize import paths for horizon --- .../contracts/data-service/libraries/StakeClaims.sol | 11 ++++++----- packages/horizon/contracts/mocks/imports.sol | 2 +- .../payments/collectors/RecurringCollector.sol | 6 +++--- .../data-service/utilities/ProvisionManager.t.sol | 2 +- .../test/unit/libraries/ListImplementation.sol | 2 +- .../horizon/test/unit/mocks/HorizonStakingMock.t.sol | 4 ++-- .../test/unit/mocks/InvalidControllerMock.t.sol | 2 +- .../test/unit/mocks/PartialControllerMock.t.sol | 2 +- .../recurring-collector/PaymentsEscrowMock.t.sol | 12 ++++++++++-- .../RecurringCollectorAuthorizableTest.t.sol | 2 +- .../RecurringCollectorHelper.t.sol | 2 +- .../unit/payments/recurring-collector/accept.t.sol | 2 +- .../unit/payments/recurring-collector/base.t.sol | 2 +- .../unit/payments/recurring-collector/cancel.t.sol | 2 +- .../unit/payments/recurring-collector/collect.t.sol | 6 +++--- .../unit/payments/recurring-collector/shared.t.sol | 8 ++++---- .../unit/payments/recurring-collector/update.t.sol | 2 +- .../horizon/test/unit/utilities/Authorizable.t.sol | 2 +- packages/horizon/test/unit/utils/Bounder.t.sol | 2 +- 19 files changed, 41 insertions(+), 32 deletions(-) diff --git a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol index e535eed44..1fbfd8a91 100644 --- a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol +++ b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol @@ -1,13 +1,14 @@ // SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.33; +pragma solidity 0.8.27 || 0.8.33; import { ProvisionTracker } from "./ProvisionTracker.sol"; -import { IHorizonStaking } from "../../interfaces/IHorizonStaking.sol"; +import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; +import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { LinkedList } from "../../libraries/LinkedList.sol"; library StakeClaims { using ProvisionTracker for mapping(address => uint256); - using LinkedList for LinkedList.List; + using LinkedList for ILinkedList.List; /** * @notice A stake claim, representing provisioned stake that gets locked @@ -94,7 +95,7 @@ library StakeClaims { function lockStake( mapping(address => uint256) storage feesProvisionTracker, mapping(bytes32 => StakeClaim) storage claims, - mapping(address serviceProvider => LinkedList.List list) storage claimsLists, + mapping(address serviceProvider => ILinkedList.List list) storage claimsLists, IHorizonStaking graphStaking, address _dataService, uint32 _delegationRatio, @@ -105,7 +106,7 @@ library StakeClaims { require(_tokens != 0, StakeClaimsZeroTokens()); feesProvisionTracker.lock(graphStaking, _serviceProvider, _tokens, _delegationRatio); - LinkedList.List storage claimsList = claimsLists[_serviceProvider]; + ILinkedList.List storage claimsList = claimsLists[_serviceProvider]; // Save item and add to list bytes32 claimId = _buildStakeClaimId(_dataService, _serviceProvider, claimsList.nonce); diff --git a/packages/horizon/contracts/mocks/imports.sol b/packages/horizon/contracts/mocks/imports.sol index 3a05b2b4d..73a67f3b0 100644 --- a/packages/horizon/contracts/mocks/imports.sol +++ b/packages/horizon/contracts/mocks/imports.sol @@ -1,7 +1,7 @@ // solhint-disable no-global-import // SPDX-License-Identifier: GPL-2.0-or-later -pragma solidity ^0.7.6 || 0.8.27; +pragma solidity ^0.7.6 || 0.8.27 || 0.8.33; // We import these here to force Hardhat to compile them. // This ensures that their artifacts are available for Hardhat Ignition to use. diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index d04d3723b..d0e4e156c 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -8,9 +8,9 @@ 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 { IPaymentsCollector } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsCollector.sol"; // for @inheritdoc +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { PPMMath } from "../../libraries/PPMMath.sol"; /** diff --git a/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol b/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol index 3617e95a5..b7f2b4ee2 100644 --- a/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol +++ b/packages/horizon/test/unit/data-service/utilities/ProvisionManager.t.sol @@ -4,7 +4,7 @@ pragma solidity 0.8.27; import { Test } from "forge-std/Test.sol"; import { ProvisionManager } from "../../../../contracts/data-service/utilities/ProvisionManager.sol"; -import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { PartialControllerMock } from "../../mocks/PartialControllerMock.t.sol"; import { HorizonStakingMock } from "../../mocks/HorizonStakingMock.t.sol"; import { ProvisionManagerImpl } from "./ProvisionManagerImpl.t.sol"; diff --git a/packages/horizon/test/unit/libraries/ListImplementation.sol b/packages/horizon/test/unit/libraries/ListImplementation.sol index dad859f59..38c8c8fa9 100644 --- a/packages/horizon/test/unit/libraries/ListImplementation.sol +++ b/packages/horizon/test/unit/libraries/ListImplementation.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity 0.8.27 || 0.8.33; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { LinkedList } from "../../../contracts/libraries/LinkedList.sol"; diff --git a/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol b/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol index d08975e09..4a74c867d 100644 --- a/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol +++ b/packages/horizon/test/unit/mocks/HorizonStakingMock.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.27 || 0.8.33; -import { IHorizonStakingTypes } from "../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; contract HorizonStakingMock { mapping(address => mapping(address => IHorizonStakingTypes.Provision)) public provisions; diff --git a/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol b/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol index f4d31da12..0e0a991a2 100644 --- a/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol +++ b/packages/horizon/test/unit/mocks/InvalidControllerMock.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.27 || 0.8.33; import { PartialControllerMock } from "./PartialControllerMock.t.sol"; diff --git a/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol b/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol index f315ff5ea..8fdc8b886 100644 --- a/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol +++ b/packages/horizon/test/unit/mocks/PartialControllerMock.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity 0.8.27; +pragma solidity 0.8.27 || 0.8.33; import { Test } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol index 3e97f1685..4bd0d7346 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/PaymentsEscrowMock.t.sol @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol"; -import { IPaymentsEscrow } from "../../../../contracts/interfaces/IPaymentsEscrow.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; contract PaymentsEscrowMock is IPaymentsEscrow { function initialize() external {} @@ -22,4 +22,12 @@ contract PaymentsEscrowMock is IPaymentsEscrow { function getBalance(address, address, address) external pure returns (uint256) { return 0; } + + function MAX_WAIT_PERIOD() external pure returns (uint256) { + return 0; + } + + function WITHDRAW_ESCROW_THAWING_PERIOD() 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 index 6c5883e5e..98cc251f0 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IAuthorizable } from "../../../../contracts/interfaces/IAuthorizable.sol"; +import { IAuthorizable } from "@graphprotocol/interfaces/contracts/horizon/IAuthorizable.sol"; import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; import { AuthorizableTest } from "../../../unit/utilities/Authorizable.t.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol index 2c34d3850..5a8bf5aa7 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/RecurringCollectorHelper.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/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"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol index 7950ae59e..3c2e47ba2 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/accept.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { RecurringCollectorSharedTest } from "./shared.t.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/base.t.sol b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol index ece717397..338418835 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/base.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/base.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { RecurringCollectorSharedTest } from "./shared.t.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol index c6282825f..09dc66c3c 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/cancel.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { RecurringCollectorSharedTest } from "./shared.t.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol index 232c95a20..588c7859e 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/collect.t.sol @@ -1,9 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; -import { IGraphPayments } from "../../../../contracts/interfaces/IGraphPayments.sol"; -import { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { RecurringCollectorSharedTest } from "./shared.t.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol index c05f5495e..d59d5f1e2 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/shared.t.sol @@ -3,10 +3,10 @@ pragma solidity 0.8.33; 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 { IHorizonStakingTypes } from "../../../../contracts/interfaces/internal/IHorizonStakingTypes.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsCollector } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { RecurringCollector } from "../../../../contracts/payments/collectors/RecurringCollector.sol"; import { Bounder } from "../../../unit/utils/Bounder.t.sol"; diff --git a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol index 05805f016..951d271d9 100644 --- a/packages/horizon/test/unit/payments/recurring-collector/update.t.sol +++ b/packages/horizon/test/unit/payments/recurring-collector/update.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "../../../../contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { RecurringCollectorSharedTest } from "./shared.t.sol"; diff --git a/packages/horizon/test/unit/utilities/Authorizable.t.sol b/packages/horizon/test/unit/utilities/Authorizable.t.sol index 2a0f0f9b9..27e9c8515 100644 --- a/packages/horizon/test/unit/utilities/Authorizable.t.sol +++ b/packages/horizon/test/unit/utilities/Authorizable.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity 0.8.27 || 0.8.33; import { Test } from "forge-std/Test.sol"; diff --git a/packages/horizon/test/unit/utils/Bounder.t.sol b/packages/horizon/test/unit/utils/Bounder.t.sol index 9b95a3425..d532cd9df 100644 --- a/packages/horizon/test/unit/utils/Bounder.t.sol +++ b/packages/horizon/test/unit/utils/Bounder.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: UNLICENSED -pragma solidity 0.8.27; +pragma solidity 0.8.27 || 0.8.33; import { Test } from "forge-std/Test.sol"; From 26c6c445e7480e8c609c102c09496d973938cbdb Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 9 Feb 2026 19:28:44 -0300 Subject: [PATCH 40/59] refactor: extract IndexingAgreement types to IIndexingAgreement interface --- .../contracts/DisputeManager.sol | 6 +- .../contracts/SubgraphService.sol | 3 +- .../contracts/libraries/AllocationHandler.sol | 23 +--- .../contracts/libraries/IndexingAgreement.sol | 113 ++++++++---------- .../contracts/utilities/AllocationManager.sol | 3 - 5 files changed, 62 insertions(+), 86 deletions(-) diff --git a/packages/subgraph-service/contracts/DisputeManager.sol b/packages/subgraph-service/contracts/DisputeManager.sol index 420e72c90..19c6e2a5a 100644 --- a/packages/subgraph-service/contracts/DisputeManager.sol +++ b/packages/subgraph-service/contracts/DisputeManager.sol @@ -11,12 +11,12 @@ import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-se import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IAttestation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAttestation.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { MathUtils } from "@graphprotocol/horizon/contracts/libraries/MathUtils.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"; @@ -540,7 +540,7 @@ contract DisputeManager is uint256 _entities, uint256 _blockNumber ) private returns (bytes32) { - IndexingAgreement.AgreementWrapper memory wrapper = _getSubgraphService().getIndexingAgreement(_agreementId); + IIndexingAgreement.AgreementWrapper memory wrapper = _getSubgraphService().getIndexingAgreement(_agreementId); // Agreement must have been collected on and be a version 1 require( @@ -548,7 +548,7 @@ contract DisputeManager is DisputeManagerIndexingAgreementNotDisputable(_agreementId) ); require( - wrapper.agreement.version == IndexingAgreement.IndexingAgreementVersion.V1, + wrapper.agreement.version == IIndexingAgreement.IndexingAgreementVersion.V1, DisputeManagerIndexingAgreementInvalidVersion(wrapper.agreement.version) ); diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index d4b560acb..189d2181a 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -8,6 +8,7 @@ import { IRewardsIssuer } from "@graphprotocol/interfaces/contracts/contracts/re import { IDataService } from "@graphprotocol/interfaces/contracts/data-service/IDataService.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; @@ -518,7 +519,7 @@ contract SubgraphService is /// @inheritdoc ISubgraphService function getIndexingAgreement( bytes16 agreementId - ) external view returns (IndexingAgreement.AgreementWrapper memory) { + ) external view returns (IIndexingAgreement.AgreementWrapper memory) { return IndexingAgreement._getStorageManager().get(agreementId); } diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index 7aab78feb..c7570313d 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -267,8 +267,7 @@ library AllocationHandler { // Update total allocated tokens for the subgraph deployment _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] + - allocation.tokens; + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] + allocation.tokens; emit AllocationHandler.AllocationCreated( params._indexer, @@ -566,10 +565,7 @@ library AllocationHandler { IAllocation.State memory allocation = _allocations.get(_allocationId); // Reclaim uncollected rewards before closing - uint256 reclaimedRewards = graphRewardsManager.reclaimRewards( - RewardsCondition.CLOSE_ALLOCATION, - _allocationId - ); + uint256 reclaimedRewards = graphRewardsManager.reclaimRewards(RewardsCondition.CLOSE_ALLOCATION, _allocationId); // Take rewards snapshot to prevent other allos from counting tokens from this allo _allocations.snapshotRewards( @@ -588,8 +584,7 @@ library AllocationHandler { // Update total allocated tokens for the subgraph deployment _subgraphAllocatedTokens[allocation.subgraphDeploymentId] = - _subgraphAllocatedTokens[allocation.subgraphDeploymentId] - - allocation.tokens; + _subgraphAllocatedTokens[allocation.subgraphDeploymentId] - allocation.tokens; emit AllocationHandler.AllocationClosed( allocation.indexer, @@ -628,11 +623,7 @@ library AllocationHandler { tokensDelegationRewards = pool.shares > 0 ? _rewardsCollected.mulPPM(delegatorCut) : 0; if (tokensDelegationRewards > 0) { _params.graphToken.approve(address(_params.graphStaking), tokensDelegationRewards); - _params.graphStaking.addToDelegationPool( - _allocation.indexer, - _params.dataService, - tokensDelegationRewards - ); + _params.graphStaking.addToDelegationPool(_allocation.indexer, _params.dataService, tokensDelegationRewards); } // Distribute indexer share @@ -640,11 +631,7 @@ library AllocationHandler { if (tokensIndexerRewards > 0) { if (_params._paymentsDestination == address(0)) { _params.graphToken.approve(address(_params.graphStaking), tokensIndexerRewards); - _params.graphStaking.stakeToProvision( - _allocation.indexer, - _params.dataService, - tokensIndexerRewards - ); + _params.graphStaking.stakeToProvision(_allocation.indexer, _params.dataService, tokensIndexerRewards); } else { _params.graphToken.pushTokens(_params._paymentsDestination, tokensIndexerRewards); } diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 74d77c7c0..c00e75034 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -1,10 +1,12 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.33; -import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; -import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; +import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; -import { ISubgraphService } from "../interfaces/ISubgraphService.sol"; import { AllocationHandler } from "../libraries/AllocationHandler.sol"; import { Directory } from "../utilities/Directory.sol"; import { Allocation } from "./Allocation.sol"; @@ -12,34 +14,8 @@ 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; - } + using Allocation for IAllocation.State; + using Allocation for mapping(address => IAllocation.State); /** * @notice Accept Indexing Agreement metadata @@ -49,7 +25,7 @@ library IndexingAgreement { */ struct AcceptIndexingAgreementMetadata { bytes32 subgraphDeploymentId; - IndexingAgreementVersion version; + IIndexingAgreement.IndexingAgreementVersion version; bytes terms; } @@ -59,7 +35,7 @@ library IndexingAgreement { * @param terms The indexing agreement terms */ struct UpdateIndexingAgreementMetadata { - IndexingAgreementVersion version; + IIndexingAgreement.IndexingAgreementVersion version; bytes terms; } @@ -118,7 +94,7 @@ library IndexingAgreement { * @custom:storage-location erc7201:graphprotocol.subgraph-service.storage.StorageManager.IndexingAgreement */ struct StorageManager { - mapping(bytes16 => State) agreements; + mapping(bytes16 => IIndexingAgreement.State) agreements; mapping(bytes16 agreementId => IndexingAgreementTermsV1 data) termsV1; mapping(address allocationId => bytes16 agreementId) allocationToActiveAgreementId; } @@ -188,7 +164,7 @@ library IndexingAgreement { bytes16 indexed agreementId, address allocationId, bytes32 subgraphDeploymentId, - IndexingAgreementVersion version, + IIndexingAgreement.IndexingAgreementVersion version, bytes versionTerms ); @@ -206,7 +182,7 @@ library IndexingAgreement { address indexed payer, bytes16 indexed agreementId, address allocationId, - IndexingAgreementVersion version, + IIndexingAgreement.IndexingAgreementVersion version, bytes versionTerms ); @@ -214,7 +190,7 @@ library IndexingAgreement { * @notice Thrown when trying to interact with an agreement with an invalid version * @param version The invalid version */ - error IndexingAgreementInvalidVersion(IndexingAgreementVersion version); + error IndexingAgreementInvalidVersion(IIndexingAgreement.IndexingAgreementVersion version); /** * @notice Thrown when an agreement is not for the subgraph data service @@ -301,11 +277,11 @@ library IndexingAgreement { */ function accept( StorageManager storage self, - mapping(address allocationId => Allocation.State allocation) storage allocations, + mapping(address allocationId => IAllocation.State allocation) storage allocations, address allocationId, IRecurringCollector.SignedRCA calldata signedRCA ) external returns (bytes16) { - Allocation.State memory allocation = _requireValidAllocation( + IAllocation.State memory allocation = _requireValidAllocation( allocations, allocationId, signedRCA.rca.serviceProvider @@ -328,7 +304,7 @@ library IndexingAgreement { signedRCA.rca.nonce ); - State storage agreement = self.agreements[agreementId]; + IIndexingAgreement.State storage agreement = self.agreements[agreementId]; require(agreement.allocationId == address(0), IndexingAgreementAlreadyAccepted(agreementId)); @@ -351,7 +327,10 @@ library IndexingAgreement { agreement.version = metadata.version; agreement.allocationId = allocationId; - require(metadata.version == IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version)); + require( + metadata.version == IIndexingAgreement.IndexingAgreementVersion.V1, + IndexingAgreementInvalidVersion(metadata.version) + ); _setTermsV1(self, agreementId, metadata.terms, signedRCA.rca.maxOngoingTokensPerSecond); emit IndexingAgreementAccepted( @@ -388,7 +367,7 @@ library IndexingAgreement { address indexer, IRecurringCollector.SignedRCAU calldata signedRCAU ) external { - AgreementWrapper memory wrapper = _get(self, signedRCAU.rcau.agreementId); + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, signedRCAU.rcau.agreementId); require(_isActive(wrapper), IndexingAgreementNotActive(signedRCAU.rcau.agreementId)); require( wrapper.collectorAgreement.serviceProvider == indexer, @@ -399,8 +378,14 @@ library IndexingAgreement { signedRCAU.rcau.metadata ); - require(wrapper.agreement.version == IndexingAgreementVersion.V1, "internal: invalid version"); - require(metadata.version == IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(metadata.version)); + require( + wrapper.agreement.version == IIndexingAgreement.IndexingAgreementVersion.V1, + "internal: invalid version" + ); + require( + metadata.version == IIndexingAgreement.IndexingAgreementVersion.V1, + IndexingAgreementInvalidVersion(metadata.version) + ); _setTermsV1( self, signedRCAU.rcau.agreementId, @@ -436,7 +421,7 @@ library IndexingAgreement { * @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); + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); require(_isActive(wrapper), IndexingAgreementNotActive(agreementId)); require( wrapper.collectorAgreement.serviceProvider == indexer, @@ -473,7 +458,7 @@ library IndexingAgreement { return; } - AgreementWrapper memory wrapper = _get(self, agreementId); + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); if (!_isActive(wrapper)) { return; } @@ -504,7 +489,7 @@ library IndexingAgreement { * @param agreementId The id of the agreement to cancel */ function cancelByPayer(StorageManager storage self, bytes16 agreementId) external { - AgreementWrapper memory wrapper = _get(self, agreementId); + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); require(_isActive(wrapper), IndexingAgreementNotActive(agreementId)); require( _directory().recurringCollector().isAuthorized(wrapper.collectorAgreement.payer, msg.sender), @@ -540,11 +525,11 @@ library IndexingAgreement { */ function collect( StorageManager storage self, - mapping(address allocationId => Allocation.State allocation) storage allocations, + mapping(address allocationId => IAllocation.State allocation) storage allocations, CollectParams memory params ) external returns (address, uint256) { - AgreementWrapper memory wrapper = _get(self, params.agreementId); - Allocation.State memory allocation = _requireValidAllocation( + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, params.agreementId); + IAllocation.State memory allocation = _requireValidAllocation( allocations, wrapper.agreement.allocationId, wrapper.collectorAgreement.serviceProvider @@ -560,7 +545,7 @@ library IndexingAgreement { require(_isValid(wrapper) && isCollectable, IndexingAgreementNotCollectable(params.agreementId)); require( - wrapper.agreement.version == IndexingAgreementVersion.V1, + wrapper.agreement.version == IIndexingAgreement.IndexingAgreementVersion.V1, IndexingAgreementInvalidVersion(wrapper.agreement.version) ); @@ -610,8 +595,11 @@ library IndexingAgreement { * @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); + function get( + StorageManager storage self, + bytes16 agreementId + ) external view returns (IIndexingAgreement.AgreementWrapper memory) { + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); require(wrapper.collectorAgreement.dataService == address(this), IndexingAgreementNotActive(agreementId)); return wrapper; @@ -665,7 +653,7 @@ library IndexingAgreement { function _cancel( StorageManager storage _manager, bytes16 _agreementId, - State memory _agreement, + IIndexingAgreement.State memory _agreement, IRecurringCollector.AgreementData memory _collectorAgreement, IRecurringCollector.CancelAgreementBy _cancelBy ) private { @@ -698,11 +686,11 @@ library IndexingAgreement { * @return The allocation state */ function _requireValidAllocation( - mapping(address => Allocation.State) storage _allocations, + mapping(address => IAllocation.State) storage _allocations, address _allocationId, address _indexer - ) private view returns (Allocation.State memory) { - Allocation.State memory allocation = _allocations.get(_allocationId); + ) private view returns (IAllocation.State memory) { + IAllocation.State memory allocation = _allocations.get(_allocationId); require( allocation.indexer == _indexer, ISubgraphService.SubgraphServiceAllocationNotAuthorized(_indexer, _allocationId) @@ -738,7 +726,7 @@ library IndexingAgreement { * @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) { + function _isActive(IIndexingAgreement.AgreementWrapper memory wrapper) private view returns (bool) { return _isValid(wrapper) && wrapper.collectorAgreement.state == IRecurringCollector.AgreementState.Accepted; } @@ -759,7 +747,7 @@ library IndexingAgreement { * @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data * @return True if the agreement is valid, false otherwise **/ - function _isValid(AgreementWrapper memory wrapper) private view returns (bool) { + function _isValid(IIndexingAgreement.AgreementWrapper memory wrapper) private view returns (bool) { return wrapper.collectorAgreement.dataService == address(this) && wrapper.agreement.allocationId != address(0); } @@ -778,9 +766,12 @@ library IndexingAgreement { * @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) { + function _get( + StorageManager storage self, + bytes16 agreementId + ) private view returns (IIndexingAgreement.AgreementWrapper memory) { return - AgreementWrapper({ + IIndexingAgreement.AgreementWrapper({ agreement: self.agreements[agreementId], collectorAgreement: _directory().recurringCollector().getAgreement(agreementId) }); diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index bef8b5fbb..fcc5db3db 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -1,13 +1,10 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.33; -import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { IGraphToken } from "@graphprotocol/interfaces/contracts/contracts/token/IGraphToken.sol"; -import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; -import { RewardsCondition } from "@graphprotocol/interfaces/contracts/contracts/rewards/RewardsCondition.sol"; import { GraphDirectory } from "@graphprotocol/horizon/contracts/utilities/GraphDirectory.sol"; import { AllocationManagerV1Storage } from "./AllocationManagerStorage.sol"; From 9742dd809f9cbc31b815ffe1a434bb64c316ceed Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 9 Feb 2026 19:29:21 -0300 Subject: [PATCH 41/59] chore: rename test constants to UPPER_SNAKE_CASE and update imports --- .../test/unit/SubgraphBaseTest.t.sol | 2 +- .../unit/libraries/IndexingAgreement.t.sol | 9 +++--- .../subgraphService/SubgraphService.t.sol | 1 + .../indexing-agreement/accept.t.sol | 19 ++++++------ .../indexing-agreement/base.t.sol | 9 +++--- .../indexing-agreement/cancel.t.sol | 16 +++++----- .../indexing-agreement/collect.t.sol | 29 ++++++++++--------- .../indexing-agreement/integration.t.sol | 7 +++-- .../indexing-agreement/shared.t.sol | 15 +++++----- .../indexing-agreement/update.t.sol | 16 +++++----- 10 files changed, 65 insertions(+), 58 deletions(-) diff --git a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol index 2b1f10d74..e64b875ff 100644 --- a/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol +++ b/packages/subgraph-service/test/unit/SubgraphBaseTest.t.sol @@ -158,7 +158,7 @@ abstract contract SubgraphBaseTest is Utils, Constants { "RecurringCollector", "1", address(controller), - revokeSignerThawingPeriod + REVOKE_SIGNER_THAWING_PERIOD ); address subgraphServiceImplementation = address( diff --git a/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol index d3271c7ff..cb5c99ec8 100644 --- a/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol +++ b/packages/subgraph-service/test/unit/libraries/IndexingAgreement.t.sol @@ -3,7 +3,8 @@ pragma solidity 0.8.33; import { Test } from "forge-std/Test.sol"; -import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { IndexingAgreement } from "../../../contracts/libraries/IndexingAgreement.sol"; import { Directory } from "../../../contracts/utilities/Directory.sol"; @@ -41,7 +42,7 @@ contract IndexingAgreementTest is Test { abi.encode(collectorAgreement) ); - IndexingAgreement.AgreementWrapper memory wrapper = IndexingAgreement.get(_storageManager, agreementId); + IIndexingAgreement.AgreementWrapper memory wrapper = IndexingAgreement.get(_storageManager, agreementId); assertEq(wrapper.collectorAgreement.dataService, address(this)); } @@ -78,9 +79,9 @@ contract IndexingAgreementTest is Test { collectorAgreement.dataService = address(this); collectorAgreement.state = IRecurringCollector.AgreementState.Accepted; - _storageManager.agreements[agreementId] = IndexingAgreement.State({ + _storageManager.agreements[agreementId] = IIndexingAgreement.State({ allocationId: allocationId, - version: IndexingAgreement.IndexingAgreementVersion.V1 + version: IIndexingAgreement.IndexingAgreementVersion.V1 }); vm.mockCall( diff --git a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol index 9042ead23..3763d3c47 100644 --- a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol @@ -14,6 +14,7 @@ import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-servic import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; +import { StakeClaims } from "@graphprotocol/horizon/contracts/data-service/libraries/StakeClaims.sol"; import { Allocation } from "../../../contracts/libraries/Allocation.sol"; import { SubgraphServiceSharedTest } from "../shared/SubgraphServiceShared.t.sol"; 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 index 5b178d5d9..d0e86b778 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -3,13 +3,14 @@ pragma solidity 0.8.33; 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 { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.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 { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; @@ -54,18 +55,18 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg address allocationId, IRecurringCollector.SignedRCA memory signedRCA ) public withSafeIndexerOrOperator(indexer) { - uint256 tokens = bound(unboundedTokens, 1, minimumProvisionTokens - 1); + uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); mint(indexer, tokens); resetPrank(indexer); - _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); signedRCA.rca.serviceProvider = indexer; bytes memory expectedErr = abi.encodeWithSelector( ProvisionManager.ProvisionManagerInvalidValue.selector, "tokens", tokens, - minimumProvisionTokens, - maximumProvisionTokens + MINIMUM_PROVISION_TOKENS, + MAXIMUM_PROVISION_TOKENS ); vm.expectRevert(expectedErr); subgraphService.acceptIndexingAgreement(allocationId, signedRCA); @@ -77,10 +78,10 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg address allocationId, IRecurringCollector.SignedRCA memory signedRCA ) public withSafeIndexerOrOperator(indexer) { - uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); mint(indexer, tokens); resetPrank(indexer); - _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); signedRCA.rca.serviceProvider = indexer; bytes memory expectedErr = abi.encodeWithSelector( ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, @@ -144,7 +145,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); bytes memory expectedErr = abi.encodeWithSelector( - Allocation.AllocationDoesNotExist.selector, + IAllocation.AllocationDoesNotExist.selector, invalidAllocationId ); vm.expectRevert(expectedErr); 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 index 923d3941f..2fb1e1bc8 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/base.t.sol @@ -2,7 +2,8 @@ pragma solidity 0.8.33; import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; -import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; @@ -35,7 +36,7 @@ contract SubgraphServiceIndexingAgreementBaseTest is SubgraphServiceIndexingAgre ctx, indexerState ); - IndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); + IIndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); _assertEqualAgreement(accepted.rca, agreement); } @@ -52,11 +53,11 @@ contract SubgraphServiceIndexingAgreementBaseTest is SubgraphServiceIndexingAgre address indexer = GRAPH_PROXY_ADMIN_ADDRESS; assertFalse(_isSafeSubgraphServiceCaller(indexer)); - uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); mint(indexer, tokens); resetPrank(indexer); vm.expectRevert("Cannot fallback to proxy target"); - staking.provision(indexer, address(subgraphService), tokens, maxSlashingPercentage, disputePeriod); + staking.provision(indexer, address(subgraphService), tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); } /* 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 index 38916be5d..c11c5549d 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/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 { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; @@ -138,17 +138,17 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg bytes16 agreementId, uint256 unboundedTokens ) public withSafeIndexerOrOperator(indexer) { - uint256 tokens = bound(unboundedTokens, 1, minimumProvisionTokens - 1); + uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); mint(indexer, tokens); resetPrank(indexer); - _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); bytes memory expectedErr = abi.encodeWithSelector( ProvisionManager.ProvisionManagerInvalidValue.selector, "tokens", tokens, - minimumProvisionTokens, - maximumProvisionTokens + MINIMUM_PROVISION_TOKENS, + MAXIMUM_PROVISION_TOKENS ); vm.expectRevert(expectedErr); subgraphService.cancelIndexingAgreement(indexer, agreementId); @@ -159,10 +159,10 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg bytes16 agreementId, uint256 unboundedTokens ) public withSafeIndexerOrOperator(indexer) { - uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); mint(indexer, tokens); resetPrank(indexer); - _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); bytes memory expectedErr = abi.encodeWithSelector( ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, indexer 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 index 9d55dadd8..7dd47e717 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -1,13 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -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 { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IPaymentsCollector } from "@graphprotocol/interfaces/contracts/horizon/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 { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; import { Allocation } from "../../../../contracts/libraries/Allocation.sol"; import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; @@ -49,7 +50,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA maxSlippage: type(uint256).max }) ); - uint256 tokensCollected = bound(unboundedTokensCollected, 1, indexerState.tokens / stakeToFeesRatio); + uint256 tokensCollected = bound(unboundedTokensCollected, 1, indexerState.tokens / STAKE_TO_FEES_RATIO); vm.mockCall( address(recurringCollector), @@ -68,7 +69,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA assertEq( subgraphService.feesProvisionTracker(indexerState.addr), - tokensCollected * stakeToFeesRatio, + tokensCollected * STAKE_TO_FEES_RATIO, "Should be exactly locked tokens" ); } @@ -122,18 +123,18 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA uint256 entities, bytes32 poi ) public withSafeIndexerOrOperator(indexer) { - uint256 tokens = bound(unboundedTokens, 1, minimumProvisionTokens - 1); + uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); uint256 currentEpochBlock = epochManager.currentEpochBlock(); mint(indexer, tokens); resetPrank(indexer); - _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); bytes memory expectedErr = abi.encodeWithSelector( ProvisionManager.ProvisionManagerInvalidValue.selector, "tokens", tokens, - minimumProvisionTokens, - maximumProvisionTokens + MINIMUM_PROVISION_TOKENS, + MAXIMUM_PROVISION_TOKENS ); vm.expectRevert(expectedErr); subgraphService.collect( @@ -150,11 +151,11 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA uint256 entities, bytes32 poi ) public withSafeIndexerOrOperator(indexer) { - uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); uint256 currentEpochBlock = epochManager.currentEpochBlock(); mint(indexer, tokens); resetPrank(indexer); - _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); bytes memory expectedErr = abi.encodeWithSelector( ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, indexer @@ -192,7 +193,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA IndexerState memory indexerState = _withIndexer(ctx); uint256 currentEpochBlock = epochManager.currentEpochBlock(); - bytes memory expectedErr = abi.encodeWithSelector(Allocation.AllocationDoesNotExist.selector, address(0)); + bytes memory expectedErr = abi.encodeWithSelector(IAllocation.AllocationDoesNotExist.selector, address(0)); vm.expectRevert(expectedErr); resetPrank(indexerState.addr); subgraphService.collect( @@ -290,7 +291,7 @@ contract SubgraphServiceIndexingAgreementCollectTest is SubgraphServiceIndexingA IndexerState memory indexerState = _withIndexer(ctx); (, bytes16 acceptedAgreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); - skip(maxPOIStaleness + 1); + skip(MAX_POI_STALENESS + 1); resetPrank(indexerState.addr); subgraphService.closeStaleAllocation(indexerState.allocationId); 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 index 6ca23de35..490befeca 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; -import { IGraphPayments } from "@graphprotocol/horizon/contracts/interfaces/IGraphPayments.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; @@ -179,7 +180,7 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex function _newExpectedTokens(uint256 _fuzzyTokensCollected) internal view returns (ExpectedTokens memory) { uint256 expectedTotalTokensCollected = bound(_fuzzyTokensCollected, 1000, 1_000_000); - uint256 expectedTokensLocked = stakeToFeesRatio * expectedTotalTokensCollected; + uint256 expectedTokensLocked = STAKE_TO_FEES_RATIO * expectedTotalTokensCollected; uint256 expectedProtocolTokensBurnt = expectedTotalTokensCollected.mulPPMRoundUp( graphPayments.PROTOCOL_PAYMENT_CUT() ); 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 index 8f72e501e..ba39cb8b6 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -1,7 +1,8 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IIndexingAgreement } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol"; import { Strings } from "@openzeppelin/contracts/utils/Strings.sol"; import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; @@ -134,7 +135,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun vm.assume(_ctx.allocations[allocationId] == address(0)); _ctx.allocations[allocationId] = _seed.addr; - uint256 tokens = bound(_seed.unboundedProvisionTokens, minimumProvisionTokens, MAX_TOKENS); + uint256 tokens = bound(_seed.unboundedProvisionTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); IndexerState memory indexer = IndexerState({ addr: _seed.addr, @@ -149,7 +150,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun // Create the indexer address originalPrank = _subgraphServiceSafePrank(indexer.addr); - _createProvision(indexer.addr, indexer.tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer.addr, indexer.tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); _register(indexer.addr, abi.encode("url", "geoHash", address(0))); bytes memory data = _createSubgraphAllocationData( indexer.addr, @@ -345,7 +346,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun return IndexingAgreement.AcceptIndexingAgreementMetadata({ subgraphDeploymentId: _subgraphDeploymentId, - version: IndexingAgreement.IndexingAgreementVersion.V1, + version: IIndexingAgreement.IndexingAgreementVersion.V1, terms: _terms }); } @@ -356,7 +357,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun ) internal pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { return IndexingAgreement.UpdateIndexingAgreementMetadata({ - version: IndexingAgreement.IndexingAgreementVersion.V1, + version: IIndexingAgreement.IndexingAgreementVersion.V1, terms: abi.encode( IndexingAgreement.IndexingAgreementTermsV1({ tokensPerSecond: _tokensPerSecond, @@ -406,7 +407,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun abi.encode( IndexingAgreement.AcceptIndexingAgreementMetadata({ subgraphDeploymentId: _subgraphDeploymentId, - version: IndexingAgreement.IndexingAgreementVersion.V1, + version: IIndexingAgreement.IndexingAgreementVersion.V1, terms: abi.encode(_terms) }) ); @@ -420,7 +421,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun function _assertEqualAgreement( IRecurringCollector.RecurringCollectionAgreement memory _expected, - IndexingAgreement.AgreementWrapper memory _actual + IIndexingAgreement.AgreementWrapper memory _actual ) internal pure { assertEq(_expected.dataService, _actual.collectorAgreement.dataService); assertEq(_expected.payer, _actual.collectorAgreement.payer); 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 index 758728944..dd5af140c 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -1,11 +1,11 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.33; -import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/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 { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; @@ -50,17 +50,17 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA uint256 unboundedTokens, IRecurringCollector.SignedRCAU memory signedRCAU ) public withSafeIndexerOrOperator(indexer) { - uint256 tokens = bound(unboundedTokens, 1, minimumProvisionTokens - 1); + uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); mint(indexer, tokens); resetPrank(indexer); - _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); bytes memory expectedErr = abi.encodeWithSelector( ProvisionManager.ProvisionManagerInvalidValue.selector, "tokens", tokens, - minimumProvisionTokens, - maximumProvisionTokens + MINIMUM_PROVISION_TOKENS, + MAXIMUM_PROVISION_TOKENS ); vm.expectRevert(expectedErr); subgraphService.updateIndexingAgreement(indexer, signedRCAU); @@ -71,10 +71,10 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA uint256 unboundedTokens, IRecurringCollector.SignedRCAU memory signedRCAU ) public withSafeIndexerOrOperator(indexer) { - uint256 tokens = bound(unboundedTokens, minimumProvisionTokens, MAX_TOKENS); + uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); mint(indexer, tokens); resetPrank(indexer); - _createProvision(indexer, tokens, fishermanRewardPercentage, disputePeriod); + _createProvision(indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); bytes memory expectedErr = abi.encodeWithSelector( ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, From fa57fbc0901e2b176fe50017da2706e73e17ce36 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 9 Feb 2026 19:29:38 -0300 Subject: [PATCH 42/59] fix: update expected errors and assertions in failing tests --- .../unit/subgraphService/allocation/resize.t.sol | 6 +++--- .../test/unit/subgraphService/allocation/start.t.sol | 6 +++--- .../test/unit/subgraphService/collect/collect.t.sol | 12 ++++++++---- .../subgraphService/collect/indexing/indexing.t.sol | 4 ++-- .../indexing-agreement/integration.t.sol | 11 ++++++++--- 5 files changed, 24 insertions(+), 15 deletions(-) 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 5df845f99..204fa0111 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.27; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; -import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; contract SubgraphServiceAllocationResizeTest is SubgraphServiceTest { @@ -85,7 +85,7 @@ contract SubgraphServiceAllocationResizeTest is SubgraphServiceTest { ) public useIndexer useAllocation(tokens) { vm.expectRevert( abi.encodeWithSelector( - IAllocationManager.AllocationManagerAllocationSameSize.selector, + AllocationHandler.AllocationHandlerAllocationSameSize.selector, allocationId, tokens ) @@ -101,7 +101,7 @@ contract SubgraphServiceAllocationResizeTest is SubgraphServiceTest { bytes memory data = abi.encode(allocationId); _stopService(users.indexer, data); vm.expectRevert( - abi.encodeWithSelector(IAllocationManager.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 0896e9473..5ccc9c2d2 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol @@ -5,7 +5,7 @@ import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/utilities/ProvisionManager.sol"; import { ProvisionTracker } from "@graphprotocol/horizon/contracts/data-service/libraries/ProvisionTracker.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; -import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; +import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; @@ -94,7 +94,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(IAllocationManager.AllocationManagerInvalidZeroAllocationId.selector)); + vm.expectRevert(abi.encodeWithSelector(AllocationHandler.AllocationHandlerInvalidZeroAllocationId.selector)); subgraphService.startService(users.indexer, data); } @@ -110,7 +110,7 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { bytes memory data = abi.encode(subgraphDeployment, tokens, allocationId, abi.encodePacked(r, s, v)); vm.expectRevert( abi.encodeWithSelector( - IAllocationManager.AllocationManagerInvalidAllocationProof.selector, + AllocationHandler.AllocationHandlerInvalidAllocationProof.selector, signer, allocationId ) diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol index e77942714..982d7fe83 100644 --- a/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/collect/collect.t.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; -import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; +import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; import { SubgraphServiceTest } from "../SubgraphService.t.sol"; contract SubgraphServiceCollectTest is SubgraphServiceTest { @@ -14,10 +14,14 @@ contract SubgraphServiceCollectTest is SubgraphServiceTest { function test_SubgraphService_Collect_RevertWhen_InvalidPayment( uint256 tokens ) public useIndexer useAllocation(tokens) { - IGraphPayments.PaymentTypes invalidPaymentType = IGraphPayments.PaymentTypes.IndexingFee; + IGraphPayments.PaymentTypes paymentType = IGraphPayments.PaymentTypes.IndexingFee; vm.expectRevert( - abi.encodeWithSelector(ISubgraphService.SubgraphServiceInvalidPaymentType.selector, invalidPaymentType) + abi.encodeWithSelector( + IndexingAgreementDecoder.IndexingAgreementDecoderInvalidData.selector, + "decodeCollectData", + "" + ) ); - subgraphService.collect(users.indexer, invalidPaymentType, ""); + subgraphService.collect(users.indexer, paymentType, ""); } } 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 94f11e0e5..49c034e52 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 @@ -2,7 +2,7 @@ pragma solidity ^0.8.27; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; -import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; +import { AllocationHandler } from "../../../../../contracts/libraries/AllocationHandler.sol"; import { IRewardsManager } from "@graphprotocol/interfaces/contracts/contracts/rewards/IRewardsManager.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; @@ -270,7 +270,7 @@ contract SubgraphServiceCollectIndexingTest is SubgraphServiceTest { // Attempt to collect on closed allocation should revert vm.expectRevert( - abi.encodeWithSelector(IAllocationManager.AllocationManagerAllocationClosed.selector, allocationId) + abi.encodeWithSelector(AllocationHandler.AllocationHandlerAllocationClosed.selector, allocationId) ); subgraphService.collect(users.indexer, paymentType, data); } 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 index 490befeca..2b1316d47 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -110,21 +110,26 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex IndexerState memory indexerState = _withIndexer(ctx); (, bytes16 agreementId) = _withAcceptedIndexingAgreement(ctx, indexerState); + // Ensure enough gap so that reward distribution (1% of tokens) doesn't undo the over-allocation + vm.assume(indexerState.tokens > MINIMUM_PROVISION_TOKENS * 2); + // Reduce indexer's provision to force over-allocation after collecting rewards - uint256 extraTokens = indexerState.tokens - minimumProvisionTokens; - vm.assume(extraTokens > 0); + uint256 extraTokens = indexerState.tokens - MINIMUM_PROVISION_TOKENS; _removeTokensFromProvision(indexerState, extraTokens); // Verify indexer will be over-allocated after presenting POI assertTrue(subgraphService.isOverAllocated(indexerState.addr)); + // Advance past allocation creation epoch so POI is not considered "too young" + vm.roll(block.number + EPOCH_LENGTH); + // Collect indexing rewards - this should trigger allocation closure and agreement cancellation bytes memory collectData = abi.encode(indexerState.allocationId, bytes32("poi"), bytes("metadata")); resetPrank(indexerState.addr); subgraphService.collect(indexerState.addr, IGraphPayments.PaymentTypes.IndexingRewards, collectData); // Verify the indexing agreement was properly cancelled - IndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); + IIndexingAgreement.AgreementWrapper memory agreement = subgraphService.getIndexingAgreement(agreementId); assertEq( uint8(agreement.collectorAgreement.state), uint8(IRecurringCollector.AgreementState.CanceledByServiceProvider) From 89c70100726820f3faf7ea3cf4303269ef06077e Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Tue, 10 Feb 2026 11:24:43 -0300 Subject: [PATCH 43/59] fix: add --ir-minimum flag to forge coverage command Prevents stack-too-deep compiler error when running coverage without optimizer, caused by RecurringCollector contract complexity. --- packages/horizon/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/horizon/package.json b/packages/horizon/package.json index f030d63b0..f1e5d9cae 100644 --- a/packages/horizon/package.json +++ b/packages/horizon/package.json @@ -35,7 +35,7 @@ "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test test/deployment/*.ts", "test:integration": "./scripts/integration", "test:coverage": "pnpm build && pnpm test:coverage:self", - "test:coverage:self": "forge coverage", + "test:coverage:self": "forge coverage --ir-minimum", "prepublishOnly": "pnpm run build" }, "devDependencies": { From 4450e9c2bf4c0d1dfffed42a42a2e5d05397bb39 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Tue, 10 Feb 2026 11:43:59 -0300 Subject: [PATCH 44/59] fix: resolve forge lint warnings in subgraph-service Add forge-lint disable comments for RCA/POI acronym casing in function and variable names. Remove unused Allocation and IDataServiceFees imports. Replace unsafe bytes32("poi") cast with keccak256("poi"). --- packages/subgraph-service/contracts/SubgraphService.sol | 2 ++ .../contracts/libraries/AllocationHandler.sol | 3 +++ .../contracts/libraries/IndexingAgreement.sol | 3 +++ .../contracts/libraries/IndexingAgreementDecoder.sol | 2 ++ .../contracts/libraries/IndexingAgreementDecoderRaw.sol | 2 ++ .../test/unit/subgraphService/SubgraphService.t.sol | 1 - .../unit/subgraphService/indexing-agreement/accept.t.sol | 8 +++++++- .../unit/subgraphService/indexing-agreement/collect.t.sol | 1 - .../subgraphService/indexing-agreement/integration.t.sol | 2 +- .../unit/subgraphService/indexing-agreement/shared.t.sol | 3 +++ .../unit/subgraphService/indexing-agreement/update.t.sol | 4 ++++ 11 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 189d2181a..5f06f55b8 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -432,6 +432,7 @@ contract SubgraphService is */ function acceptIndexingAgreement( address allocationId, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA calldata signedRCA ) external @@ -459,6 +460,7 @@ contract SubgraphService is */ function updateIndexingAgreement( address indexer, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU calldata signedRCAU ) external diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index c7570313d..ad470dbf4 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -75,6 +75,7 @@ library AllocationHandler { * @param _paymentsDestination The address to which the indexing rewards should be sent */ struct PresentParams { + // forge-lint: disable-next-line(mixed-case-variable) uint256 maxPOIStaleness; IEpochManager graphEpochManager; IHorizonStaking graphStaking; @@ -176,6 +177,7 @@ library AllocationHandler { * @notice Emitted when the maximum POI staleness is updated * @param maxPOIStaleness The max POI staleness in seconds */ + // forge-lint: disable-next-item(mixed-case-variable) event MaxPOIStalenessSet(uint256 maxPOIStaleness); /** @@ -317,6 +319,7 @@ library AllocationHandler { * @return allocationForceClosed True if the allocation was automatically closed due to over-allocation, false otherwise */ // solhint-disable-next-line function-max-lines + // forge-lint: disable-next-item(mixed-case-function) function presentPOI( mapping(address allocationId => IAllocation.State allocation) storage _allocations, mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index c00e75034..176bc0796 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -279,6 +279,7 @@ library IndexingAgreement { StorageManager storage self, mapping(address allocationId => IAllocation.State allocation) storage allocations, address allocationId, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA calldata signedRCA ) external returns (bytes16) { IAllocation.State memory allocation = _requireValidAllocation( @@ -365,6 +366,7 @@ library IndexingAgreement { function update( StorageManager storage self, address indexer, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU calldata signedRCAU ) external { IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, signedRCAU.rcau.agreementId); @@ -782,6 +784,7 @@ library IndexingAgreement { * @param terms The indexing agreement terms to validate * @param maxOngoingTokensPerSecond The RCA maximum tokens per second limit */ + // forge-lint: disable-next-item(mixed-case-function) function _validateTermsAgainstRCA( IndexingAgreementTermsV1 memory terms, uint256 maxOngoingTokensPerSecond diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol index def26d5b5..70c380457 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol @@ -33,6 +33,7 @@ library IndexingAgreementDecoder { * @param data The data to decode. * @return The decoded data. See {IndexingAgreement.AcceptIndexingAgreementMetadata} */ + // forge-lint: disable-next-item(mixed-case-function) function decodeRCAMetadata( bytes memory data ) public pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { @@ -51,6 +52,7 @@ library IndexingAgreementDecoder { * @param data The data to decode. * @return The decoded data. See {IndexingAgreement.UpdateIndexingAgreementMetadata} */ + // forge-lint: disable-next-item(mixed-case-function) function decodeRCAUMetadata( bytes memory data ) public pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol index 4cd9e7cc7..b51145924 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol @@ -20,6 +20,7 @@ library IndexingAgreementDecoderRaw { * @param data The data to decode * @return The decoded data */ + // forge-lint: disable-next-item(mixed-case-function) function decodeRCAMetadata( bytes calldata data ) public pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { @@ -32,6 +33,7 @@ library IndexingAgreementDecoderRaw { * @param data The data to decode * @return The decoded data */ + // forge-lint: disable-next-item(mixed-case-function) function decodeRCAUMetadata( bytes calldata data ) public pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { diff --git a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol index 3763d3c47..33c0a8dc9 100644 --- a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol @@ -8,7 +8,6 @@ import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizo import { IGraphTallyCollector } from "@graphprotocol/interfaces/contracts/horizon/IGraphTallyCollector.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { LinkedList } from "@graphprotocol/horizon/contracts/libraries/LinkedList.sol"; -import { IDataServiceFees } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceFees.sol"; import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; import { IAllocationManager } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol"; 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 index d0e86b778..f20c1827f 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -6,7 +6,6 @@ import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/ import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.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"; @@ -23,6 +22,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenPaused( address allocationId, address operator, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA calldata signedRCA ) public withSafeIndexerOrOperator(operator) { resetPrank(users.pauseGuardian); @@ -36,6 +36,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenNotAuthorized( address allocationId, address operator, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA calldata signedRCA ) public withSafeIndexerOrOperator(operator) { vm.assume(operator != signedRCA.rca.serviceProvider); @@ -53,6 +54,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg address indexer, uint256 unboundedTokens, address allocationId, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA memory signedRCA ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); @@ -76,6 +78,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg address indexer, uint256 unboundedTokens, address allocationId, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA memory signedRCA ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); @@ -241,10 +244,12 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg // Now try to accept a different agreement on the same allocation // Create a new agreement with different nonce to ensure different agreement ID IRecurringCollector.RecurringCollectionAgreement + // forge-lint: disable-next-line(mixed-case-variable) memory newRCA = _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr); newRCA.nonce = alternativeNonce; // Different nonce to ensure different agreement ID // Sign the new agreement + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA memory newSignedRCA = _recurringCollectorHelper.generateSignedRCA( newRCA, ctx.payer.signerPrivateKey @@ -264,6 +269,7 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.RecurringCollectionAgreement memory notAcceptableRCA = acceptable.rca; bytes memory invalidTermsData = bytes("invalid terms data"); notAcceptableRCA.metadata = abi.encode( 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 index 7dd47e717..1c514e2f7 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/collect.t.sol @@ -9,7 +9,6 @@ import { ProvisionManager } from "@graphprotocol/horizon/contracts/data-service/ import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAllocation.sol"; -import { Allocation } from "../../../../contracts/libraries/Allocation.sol"; import { AllocationHandler } from "../../../../contracts/libraries/AllocationHandler.sol"; import { IndexingAgreement } from "../../../../contracts/libraries/IndexingAgreement.sol"; import { IndexingAgreementDecoder } from "../../../../contracts/libraries/IndexingAgreementDecoder.sol"; 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 index 2b1316d47..7aeea3b76 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/integration.t.sol @@ -124,7 +124,7 @@ contract SubgraphServiceIndexingAgreementIntegrationTest is SubgraphServiceIndex vm.roll(block.number + EPOCH_LENGTH); // Collect indexing rewards - this should trigger allocation closure and agreement cancellation - bytes memory collectData = abi.encode(indexerState.allocationId, bytes32("poi"), bytes("metadata")); + bytes memory collectData = abi.encode(indexerState.allocationId, keccak256("poi"), bytes("metadata")); resetPrank(indexerState.addr); subgraphService.collect(indexerState.addr, IGraphPayments.PaymentTypes.IndexingRewards, collectData); 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 index ba39cb8b6..befbb8730 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -182,6 +182,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun rca = _recurringCollectorHelper.sensibleRCA(rca); + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA( rca, _ctx.payer.signerPrivateKey @@ -234,6 +235,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun return ctx; } + // forge-lint: disable-next-item(mixed-case-function) function _generateAcceptableSignedRCA( Context storage _ctx, address _indexerAddress @@ -263,6 +265,7 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun return _recurringCollectorHelper.sensibleRCA(rca); } + // forge-lint: disable-next-item(mixed-case-function) function _generateAcceptableSignedRCAU( Context storage _ctx, IRecurringCollector.RecurringCollectionAgreement memory _rca 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 index dd5af140c..a0ea5cc39 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -19,6 +19,7 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA /* solhint-disable graph/func-name-mixedcase */ function test_SubgraphService_UpdateIndexingAgreementIndexingAgreement_Revert_WhenPaused( address operator, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU calldata signedRCAU ) public withSafeIndexerOrOperator(operator) { resetPrank(users.pauseGuardian); @@ -32,6 +33,7 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAuthorized( address indexer, address notAuthorized, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU calldata signedRCAU ) public withSafeIndexerOrOperator(notAuthorized) { vm.assume(notAuthorized != indexer); @@ -48,6 +50,7 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenInvalidProvision( address indexer, uint256 unboundedTokens, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU memory signedRCAU ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); @@ -69,6 +72,7 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenIndexerNotRegistered( address indexer, uint256 unboundedTokens, + // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU memory signedRCAU ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); From c3f6f075f6d97155b50dcf6820d730210f778446 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Tue, 10 Feb 2026 14:26:22 -0300 Subject: [PATCH 45/59] fix: resolve solhint warnings in RecurringCollector and fix subgraph-service coverage - Revert strict inequality conversions (> x-1, < x+1) back to (>=, <=) and suppress gas-strict-inequalities with solhint comments instead - Suppress gas-small-strings, function-max-lines solhint warnings - Add --ir-minimum to subgraph-service forge coverage to fix stack too deep - Change getCollectionInfo param from memory to calldata - Add @author natspec tag --- .../collectors/RecurringCollector.sol | 21 ++++++++++++++++++- .../contracts/horizon/IRecurringCollector.sol | 19 ++++++++++------- .../subgraph-service/IDisputeManager.sol | 4 ++-- .../subgraph-service/ISubgraphService.sol | 1 + packages/subgraph-service/package.json | 2 +- 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index d0e4e156c..0ea6b4954 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -15,6 +15,7 @@ import { PPMMath } from "../../libraries/PPMMath.sol"; /** * @title RecurringCollector contract + * @author Edge & Node * @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 @@ -26,6 +27,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /// @notice The minimum number of seconds that must be between two collections uint32 public constant MIN_SECONDS_COLLECTION_WINDOW = 600; + /* solhint-disable gas-small-strings */ /// @notice The EIP712 typehash for the RecurringCollectionAgreement struct bytes32 public constant EIP712_RCA_TYPEHASH = keccak256( @@ -37,6 +39,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC keccak256( "RecurringCollectionAgreementUpdate(bytes16 agreementId,uint64 deadline,uint64 endsAt,uint256 maxInitialTokens,uint256 maxOngoingTokensPerSecond,uint32 minSecondsPerCollection,uint32 maxSecondsPerCollection,uint32 nonce,bytes metadata)" ); + /* solhint-enable gas-small-strings */ /// @notice Tracks agreements mapping(bytes16 agreementId => AgreementData data) public agreements; @@ -69,6 +72,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC } } + /* solhint-disable function-max-lines */ /** * @inheritdoc IRecurringCollector * @notice Accept a Recurring Collection Agreement. @@ -89,10 +93,12 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC msg.sender == signedRCA.rca.dataService, RecurringCollectorUnauthorizedCaller(msg.sender, signedRCA.rca.dataService) ); + /* solhint-disable gas-strict-inequalities */ require( signedRCA.rca.deadline >= block.timestamp, RecurringCollectorAgreementDeadlineElapsed(block.timestamp, signedRCA.rca.deadline) ); + /* solhint-enable gas-strict-inequalities */ // check that the voucher is signed by the payer (or proxy) _requireAuthorizedRCASigner(signedRCA); @@ -145,6 +151,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC return agreementId; } + /* solhint-enable function-max-lines */ /** * @inheritdoc IRecurringCollector @@ -179,6 +186,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC ); } + /* solhint-disable function-max-lines */ /** * @inheritdoc IRecurringCollector * @notice Update a Recurring Collection Agreement. @@ -188,10 +196,12 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC * for the entire period since lastCollectionAt. */ function update(SignedRCAU calldata signedRCAU) external { + /* solhint-disable gas-strict-inequalities */ require( signedRCAU.rcau.deadline >= block.timestamp, RecurringCollectorAgreementDeadlineElapsed(block.timestamp, signedRCAU.rcau.deadline) ); + /* solhint-enable gas-strict-inequalities */ AgreementData storage agreement = _getAgreementStorage(signedRCAU.rcau.agreementId); require( @@ -240,6 +250,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC agreement.maxSecondsPerCollection ); } + /* solhint-enable function-max-lines */ /// @inheritdoc IRecurringCollector function recoverRCASigner(SignedRCA calldata signedRCA) external view returns (address) { @@ -268,7 +279,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /// @inheritdoc IRecurringCollector function getCollectionInfo( - AgreementData memory agreement + AgreementData calldata agreement ) external view returns (bool isCollectable, uint256 collectionSeconds, AgreementNotCollectableReason reason) { return _getCollectionInfo(agreement); } @@ -293,6 +304,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC return abi.decode(data, (CollectParams)); } + /* solhint-disable function-max-lines */ /** * @notice Collect payment through the payments protocol. * @dev Caller must be the data service the RCA was issued to. @@ -336,10 +348,12 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC tokensToCollect = _requireValidCollect(agreement, _params.agreementId, _params.tokens, collectionSeconds); uint256 slippage = _params.tokens - tokensToCollect; + /* solhint-disable gas-strict-inequalities */ require( slippage <= _params.maxSlippage, RecurringCollectorExcessiveSlippage(_params.tokens, tokensToCollect, _params.maxSlippage) ); + /* solhint-enable gas-strict-inequalities */ } agreement.lastCollectionAt = uint64(block.timestamp); @@ -376,6 +390,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC return tokensToCollect; } + /* solhint-enable function-max-lines */ /** * @notice Requires that the collection window parameters are valid. @@ -395,6 +410,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC // Collection window needs to be at least MIN_SECONDS_COLLECTION_WINDOW require( _maxSecondsPerCollection > _minSecondsPerCollection && + // solhint-disable-next-line gas-strict-inequalities (_maxSecondsPerCollection - _minSecondsPerCollection >= MIN_SECONDS_COLLECTION_WINDOW), RecurringCollectorAgreementInvalidCollectionWindow( MIN_SECONDS_COLLECTION_WINDOW, @@ -405,6 +421,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC // Agreement needs to last at least one min collection window require( + // solhint-disable-next-line gas-strict-inequalities _endsAt - block.timestamp >= _minSecondsPerCollection + MIN_SECONDS_COLLECTION_WINDOW, RecurringCollectorAgreementInvalidDuration( _minSecondsPerCollection + MIN_SECONDS_COLLECTION_WINDOW, @@ -431,6 +448,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC block.timestamp > _agreement.endsAt; if (!canceledOrElapsed) { require( + // solhint-disable-next-line gas-strict-inequalities _collectionSeconds >= _agreement.minSecondsPerCollection, RecurringCollectorCollectionTooSoon( _agreementId, @@ -440,6 +458,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC ); } require( + // solhint-disable-next-line gas-strict-inequalities _collectionSeconds <= _agreement.maxSecondsPerCollection, RecurringCollectorCollectionTooLate( _agreementId, diff --git a/packages/interfaces/contracts/horizon/IRecurringCollector.sol b/packages/interfaces/contracts/horizon/IRecurringCollector.sol index 447655823..445c4cb0b 100644 --- a/packages/interfaces/contracts/horizon/IRecurringCollector.sol +++ b/packages/interfaces/contracts/horizon/IRecurringCollector.sol @@ -7,6 +7,7 @@ import { IAuthorizable } from "./IAuthorizable.sol"; /** * @title Interface for the {RecurringCollector} contract + * @author Edge & Node * @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 @@ -63,6 +64,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @param metadata Arbitrary metadata to extend functionality if a data service requires it * */ + // solhint-disable-next-line gas-struct-packing struct RecurringCollectionAgreement { uint64 deadline; uint64 endsAt; @@ -101,6 +103,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @param nonce The nonce for preventing replay attacks (must be current nonce + 1) * @param metadata Arbitrary metadata to extend functionality if a data service requires it */ + // solhint-disable-next-line gas-struct-packing struct RecurringCollectionAgreementUpdate { bytes16 agreementId; uint64 deadline; @@ -395,48 +398,48 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { error RecurringCollectorExcessiveSlippage(uint256 requested, uint256 actual, uint256 maxSlippage); /** - * @dev Accept an indexing agreement. + * @notice Accept an indexing agreement. * @param signedRCA The signed Recurring Collection Agreement which is to be accepted. * @return agreementId The deterministically generated agreement ID */ function accept(SignedRCA calldata signedRCA) external returns (bytes16 agreementId); /** - * @dev Cancel an indexing agreement. + * @notice 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. + * @notice 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). + * @notice 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). + * @notice 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). + * @notice 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). + * @notice 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. */ @@ -458,7 +461,7 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { * @return reason The reason why the agreement is not collectable (None if collectable) */ function getCollectionInfo( - AgreementData memory agreement + AgreementData calldata agreement ) external view returns (bool isCollectable, uint256 collectionSeconds, AgreementNotCollectableReason reason); /** diff --git a/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol b/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol index 21eb25ccc..d805d9f70 100644 --- a/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol +++ b/packages/interfaces/contracts/subgraph-service/IDisputeManager.sol @@ -122,9 +122,9 @@ interface IDisputeManager { ); /** - * @dev Emitted when an indexing fee dispute is created for `agreementId` and `indexer` + * @notice 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. + * @dev 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 diff --git a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol index 0bd98d4f5..cff466423 100644 --- a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol +++ b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol @@ -75,6 +75,7 @@ interface ISubgraphService is IDataServiceFees { * @param indexingFeesCut The indexing fees cut */ event IndexingFeesCutSet(uint256 indexingFeesCut); + // solhint-disable-previous-line gas-indexed-events /** * @notice Thrown when trying to set a curation cut that is not a valid PPM value diff --git a/packages/subgraph-service/package.json b/packages/subgraph-service/package.json index a00a28e57..65e37be6c 100644 --- a/packages/subgraph-service/package.json +++ b/packages/subgraph-service/package.json @@ -33,7 +33,7 @@ "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test test/deployment/*.ts", "test:integration": "./scripts/integration", "test:coverage": "pnpm build && pnpm test:coverage:self", - "test:coverage:self": "forge coverage", + "test:coverage:self": "forge coverage --ir-minimum", "prepublishOnly": "pnpm run build" }, "devDependencies": { From 78b778d9e169dfca2412bd016e6954f74fc14ed6 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Fri, 13 Feb 2026 18:23:28 -0300 Subject: [PATCH 46/59] fix: change agreements mapping to internal to fix stack-too-deep in coverage --- .../contracts/payments/collectors/RecurringCollector.sol | 2 +- packages/horizon/package.json | 2 +- packages/subgraph-service/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 0ea6b4954..0ab66bdd9 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -42,7 +42,7 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC /* solhint-enable gas-small-strings */ /// @notice Tracks agreements - mapping(bytes16 agreementId => AgreementData data) public agreements; + mapping(bytes16 agreementId => AgreementData data) internal agreements; /** * @notice Constructs a new instance of the RecurringCollector contract. diff --git a/packages/horizon/package.json b/packages/horizon/package.json index f1e5d9cae..f030d63b0 100644 --- a/packages/horizon/package.json +++ b/packages/horizon/package.json @@ -35,7 +35,7 @@ "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test test/deployment/*.ts", "test:integration": "./scripts/integration", "test:coverage": "pnpm build && pnpm test:coverage:self", - "test:coverage:self": "forge coverage --ir-minimum", + "test:coverage:self": "forge coverage", "prepublishOnly": "pnpm run build" }, "devDependencies": { diff --git a/packages/subgraph-service/package.json b/packages/subgraph-service/package.json index 65e37be6c..a00a28e57 100644 --- a/packages/subgraph-service/package.json +++ b/packages/subgraph-service/package.json @@ -33,7 +33,7 @@ "test:deployment": "SECURE_ACCOUNTS_DISABLE_PROVIDER=true hardhat test test/deployment/*.ts", "test:integration": "./scripts/integration", "test:coverage": "pnpm build && pnpm test:coverage:self", - "test:coverage:self": "forge coverage --ir-minimum", + "test:coverage:self": "forge coverage", "prepublishOnly": "pnpm run build" }, "devDependencies": { From 80831bc594c66fd3e9d3bbc1a20a39d33c27171e Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 20 Feb 2026 17:52:04 +0000 Subject: [PATCH 47/59] chore: disable mixed-case lint rules in forge-lint Solhint already has func-name-mixedcase and var-name-mixedcase turned off. Align forge-lint by excluding these rules in horizon and subgraph-service to avoid redundant inline suppressions. --- packages/horizon/foundry.toml | 1 + packages/subgraph-service/foundry.toml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/horizon/foundry.toml b/packages/horizon/foundry.toml index 5c800d92c..ecc629b51 100644 --- a/packages/horizon/foundry.toml +++ b/packages/horizon/foundry.toml @@ -14,3 +14,4 @@ no_match_coverage = "(^test/|/mocks/)" # Lint configuration [lint] ignore = ["contracts/mocks/imports.sol"] +exclude_lints = ["mixed-case-function", "mixed-case-variable"] diff --git a/packages/subgraph-service/foundry.toml b/packages/subgraph-service/foundry.toml index 26b73ce91..6dc1fcf17 100644 --- a/packages/subgraph-service/foundry.toml +++ b/packages/subgraph-service/foundry.toml @@ -12,3 +12,7 @@ evm_version = 'cancun' # Exclude test files from coverage reports no_match_coverage = "(^test/|/mocks/)" + +# Lint configuration +[lint] +exclude_lints = ["mixed-case-function", "mixed-case-variable"] From ff23f018581aab56bd0dd4d454afdbd4232a32d1 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:18:35 +0000 Subject: [PATCH 48/59] test: add unit tests for DataServicePausableUpgradeable Cover pause/unpause access control, setPauseGuardian, and no-change revert cases. --- .../DataServicePausableUpgradeable.t.sol | 121 ++++++++++++++++-- .../DataServiceImpPausableUpgradeable.sol | 4 + 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol b/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol index d5413ed5b..fd618a041 100644 --- a/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol +++ b/packages/horizon/test/unit/data-service/extensions/DataServicePausableUpgradeable.t.sol @@ -3,17 +3,20 @@ pragma solidity 0.8.27; import { GraphBaseTest } from "../../GraphBase.t.sol"; import { DataServiceImpPausableUpgradeable } from "../implementations/DataServiceImpPausableUpgradeable.sol"; +import { IDataServicePausable } from "@graphprotocol/interfaces/contracts/data-service/IDataServicePausable.sol"; import { UnsafeUpgrades } from "@openzeppelin/foundry-upgrades/src/Upgrades.sol"; import { PPMMath } from "./../../../../contracts/libraries/PPMMath.sol"; contract DataServicePausableUpgradeableTest is GraphBaseTest { - function test_WhenTheContractIsDeployed() external { - ( - DataServiceImpPausableUpgradeable dataService, - DataServiceImpPausableUpgradeable implementation - ) = _deployDataService(); + DataServiceImpPausableUpgradeable private dataService; + function setUp() public override { + super.setUp(); + (dataService, ) = _deployDataService(); + } + + function test_WhenTheContractIsDeployed() external view { // via proxy - ensure that the proxy was initialized correctly // these calls validate proxy storage was correctly initialized uint32 delegationRatio = dataService.getDelegationRatio(); @@ -30,13 +33,113 @@ contract DataServicePausableUpgradeableTest is GraphBaseTest { (uint64 minThawingPeriod, uint64 maxThawingPeriod) = dataService.getThawingPeriodRange(); assertEq(minThawingPeriod, type(uint64).min); assertEq(maxThawingPeriod, type(uint64).max); + } + + // -- setPauseGuardian -- + + function test_SetPauseGuardian() external { + address guardian = makeAddr("guardian"); + + vm.expectEmit(address(dataService)); + emit IDataServicePausable.PauseGuardianSet(guardian, true); + dataService.setPauseGuardian(guardian, true); + + assertTrue(dataService.pauseGuardians(guardian)); + } + + function test_SetPauseGuardian_Remove() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + vm.expectEmit(address(dataService)); + emit IDataServicePausable.PauseGuardianSet(guardian, false); + dataService.setPauseGuardian(guardian, false); + + assertFalse(dataService.pauseGuardians(guardian)); + } + + function test_RevertWhen_SetPauseGuardian_NoChange_AlreadyFalse() external { + address guardian = makeAddr("guardian"); + + // guardian defaults to false, setting to false should revert + vm.expectRevert( + abi.encodeWithSelector( + IDataServicePausable.DataServicePausablePauseGuardianNoChange.selector, + guardian, + false + ) + ); + dataService.setPauseGuardian(guardian, false); + } + + function test_RevertWhen_SetPauseGuardian_NoChange_AlreadyTrue() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + // guardian is already true, setting to true should revert + vm.expectRevert( + abi.encodeWithSelector( + IDataServicePausable.DataServicePausablePauseGuardianNoChange.selector, + guardian, + true + ) + ); + dataService.setPauseGuardian(guardian, true); + } + + // -- pause -- + + function test_Pause() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + vm.prank(guardian); + dataService.pause(); + + assertTrue(dataService.paused()); + } + + function test_RevertWhen_Pause_NotGuardian() external { + address notGuardian = makeAddr("notGuardian"); - // this ensures that implementation immutables were correctly initialized - // and they can be read via the proxy - assertEq(implementation.controller(), address(controller)); - assertEq(dataService.controller(), address(controller)); + vm.expectRevert( + abi.encodeWithSelector(IDataServicePausable.DataServicePausableNotPauseGuardian.selector, notGuardian) + ); + vm.prank(notGuardian); + dataService.pause(); } + // -- unpause -- + + function test_Unpause() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + vm.startPrank(guardian); + dataService.pause(); + dataService.unpause(); + vm.stopPrank(); + + assertFalse(dataService.paused()); + } + + function test_RevertWhen_Unpause_NotGuardian() external { + address guardian = makeAddr("guardian"); + dataService.setPauseGuardian(guardian, true); + + vm.prank(guardian); + dataService.pause(); + + address notGuardian = makeAddr("notGuardian"); + vm.expectRevert( + abi.encodeWithSelector(IDataServicePausable.DataServicePausableNotPauseGuardian.selector, notGuardian) + ); + vm.prank(notGuardian); + dataService.unpause(); + } + + // -- helpers -- + function _deployDataService() internal returns (DataServiceImpPausableUpgradeable, DataServiceImpPausableUpgradeable) diff --git a/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol b/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol index 71453fd19..8e62041cc 100644 --- a/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol +++ b/packages/horizon/test/unit/data-service/implementations/DataServiceImpPausableUpgradeable.sol @@ -31,6 +31,10 @@ contract DataServiceImpPausableUpgradeable is DataServicePausableUpgradeable { function slash(address serviceProvider, bytes calldata data) external {} + function setPauseGuardian(address _pauseGuardian, bool _allowed) external { + _setPauseGuardian(_pauseGuardian, _allowed); + } + function controller() external view returns (address) { return address(_graphController()); } From a7869e4fae64ab5d18b88e95924303a561aae330 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:24:39 +0000 Subject: [PATCH 49/59] chore: remove unused libraries and extensions Remove LibFixedMath, Denominations, DataServiceRescuable and its interface. None are imported by any source code in horizon or subgraph-service. --- .../extensions/DataServiceRescuable.sol | 84 ----- .../contracts/libraries/Denominations.sol | 25 -- .../contracts/libraries/LibFixedMath.sol | 299 ------------------ .../data-service/IDataServiceRescuable.sol | 72 ----- 4 files changed, 480 deletions(-) delete mode 100644 packages/horizon/contracts/data-service/extensions/DataServiceRescuable.sol delete mode 100644 packages/horizon/contracts/libraries/Denominations.sol delete mode 100644 packages/horizon/contracts/libraries/LibFixedMath.sol delete mode 100644 packages/interfaces/contracts/data-service/IDataServiceRescuable.sol diff --git a/packages/horizon/contracts/data-service/extensions/DataServiceRescuable.sol b/packages/horizon/contracts/data-service/extensions/DataServiceRescuable.sol deleted file mode 100644 index 9d609a087..000000000 --- a/packages/horizon/contracts/data-service/extensions/DataServiceRescuable.sol +++ /dev/null @@ -1,84 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; - -import { Address } from "@openzeppelin/contracts/utils/Address.sol"; -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { IDataServiceRescuable } from "@graphprotocol/interfaces/contracts/data-service/IDataServiceRescuable.sol"; - -import { DataService } from "../DataService.sol"; - -import { Denominations } from "../../libraries/Denominations.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; - -/** - * @title Rescuable contract - * @author Edge & Node - * @notice Allows a contract to have a function to rescue tokens sent by mistake. - * @dev The contract must implement the external rescueTokens function or similar, - * that calls this contract's _rescueTokens. - * @dev Note that this extension does not provide an external function to set - * rescuers. This should be implemented in the derived contract. - * @dev This contract inherits from {DataService} which needs to be initialized, please see - * {DataService} for detailed instructions. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -abstract contract DataServiceRescuable is DataService, IDataServiceRescuable { - /// @notice List of rescuers and their allowed status - mapping(address rescuer => bool allowed) public rescuers; - - // forge-lint: disable-next-item(mixed-case-variable) - /// @dev Gap to allow adding variables in future upgrades - /// Note that this contract is not upgradeable but might be inherited by an upgradeable contract - uint256[50] private __gap; - - // forge-lint: disable-next-item(unwrapped-modifier-logic) - /** - * @notice Checks if the caller is a rescuer. - */ - modifier onlyRescuer() { - require(rescuers[msg.sender], DataServiceRescuableNotRescuer(msg.sender)); - _; - } - - // forge-lint: disable-next-item(mixed-case-function) - /// @inheritdoc IDataServiceRescuable - function rescueGRT(address to, uint256 tokens) external virtual onlyRescuer { - _rescueTokens(to, address(_graphToken()), tokens); - } - - // forge-lint: disable-next-item(mixed-case-function) - /// @inheritdoc IDataServiceRescuable - function rescueETH(address payable to, uint256 tokens) external virtual onlyRescuer { - _rescueTokens(to, Denominations.NATIVE_TOKEN, tokens); - } - - /** - * @notice Sets a rescuer. - * @dev Internal function to be used by the derived contract to set rescuers. - * - * Emits a {RescuerSet} event. - * - * @param _rescuer Address of the rescuer - * @param _allowed Allowed status of the rescuer - */ - function _setRescuer(address _rescuer, bool _allowed) internal { - rescuers[_rescuer] = _allowed; - emit RescuerSet(_rescuer, _allowed); - } - - /** - * @notice Allows rescuing tokens sent to this contract - * @param _to Destination address to send the tokens - * @param _token Address of the token being rescued - * @param _tokens Amount of tokens to pull - */ - function _rescueTokens(address _to, address _token, uint256 _tokens) internal { - require(_tokens != 0, DataServiceRescuableCannotRescueZero()); - - if (Denominations.isNativeToken(_token)) Address.sendValue(payable(_to), _tokens); - else SafeERC20.safeTransfer(IERC20(_token), _to, _tokens); - - emit TokensRescued(msg.sender, _to, _token, _tokens); - } -} diff --git a/packages/horizon/contracts/libraries/Denominations.sol b/packages/horizon/contracts/libraries/Denominations.sol deleted file mode 100644 index abd0ac9a6..000000000 --- a/packages/horizon/contracts/libraries/Denominations.sol +++ /dev/null @@ -1,25 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity 0.8.27; - -/** - * @title Denominations library - * @author Edge & Node - * @notice Provides a list of ground denominations for those tokens that cannot be represented by an ERC20 - * For now, the only needed is the native token that could be ETH, MATIC, or other depending on the layer being operated. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -library Denominations { - /// @notice The address of the native token, i.e ETH - /// @dev This convention is taken from https://eips.ethereum.org/EIPS/eip-7528 - address internal constant NATIVE_TOKEN = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; - - /** - * @notice Checks if a token is the native token - * @param token The token address to check - * @return True if the token is the native token, false otherwise - */ - function isNativeToken(address token) internal pure returns (bool) { - return token == NATIVE_TOKEN; - } -} diff --git a/packages/horizon/contracts/libraries/LibFixedMath.sol b/packages/horizon/contracts/libraries/LibFixedMath.sol deleted file mode 100644 index f248a513d..000000000 --- a/packages/horizon/contracts/libraries/LibFixedMath.sol +++ /dev/null @@ -1,299 +0,0 @@ -/* - - Copyright 2017 Bprotocol Foundation, 2019 ZeroEx Intl. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - -*/ - -// SPDX-License-Identifier: Apache-2.0 - -pragma solidity 0.8.27 || 0.8.33; - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable function-max-lines, gas-strict-inequalities -// forge-lint: disable-start(unsafe-typecast) - -/** - * @title LibFixedMath - * @author Edge & Node - * @notice This library provides fixed-point arithmetic operations. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -library LibFixedMath { - // 1 - int256 private constant FIXED_1 = int256(0x0000000000000000000000000000000080000000000000000000000000000000); - // 2**255 - int256 private constant MIN_FIXED_VAL = type(int256).min; - // 0 - int256 private constant EXP_MAX_VAL = 0; - // -63.875 - int256 private constant EXP_MIN_VAL = -int256(0x0000000000000000000000000000001ff0000000000000000000000000000000); - - /** - * @notice Get one as a fixed-point number - * @return f The fixed-point representation of one - */ - function one() internal pure returns (int256 f) { - f = FIXED_1; - } - - /** - * @notice Returns the subtraction of two fixed point numbers, reverting on overflow - * @param a The first fixed point number - * @param b The second fixed point number to subtract - * @return c The result of a - b - */ - function sub(int256 a, int256 b) internal pure returns (int256 c) { - if (b == MIN_FIXED_VAL) { - revert("out-of-bounds"); - } - c = _add(a, -b); - } - - /** - * @notice Returns the multiplication of two fixed point numbers, reverting on overflow - * @param a The first fixed point number - * @param b The second fixed point number - * @return c The result of a * b - */ - function mul(int256 a, int256 b) internal pure returns (int256 c) { - c = _mul(a, b) / FIXED_1; - } - - /** - * @notice Performs (a * n) / d, without scaling for precision - * @param a The first fixed point number - * @param n The numerator - * @param d The denominator - * @return c The result of (a * n) / d - */ - function mulDiv(int256 a, int256 n, int256 d) internal pure returns (int256 c) { - c = _div(_mul(a, n), d); - } - - /** - * @notice Returns the unsigned integer result of multiplying a fixed-point number with an integer - * @dev Negative results are clamped to zero. Reverts if the multiplication overflows. - * @param f Fixed-point number - * @param u Unsigned integer - * @return Unsigned integer result, clamped to zero if negative - */ - function uintMul(int256 f, uint256 u) internal pure returns (uint256) { - if (int256(u) < int256(0)) { - revert("out-of-bounds"); - } - int256 c = _mul(f, int256(u)); - if (c <= 0) { - return 0; - } - return uint256(uint256(c) >> 127); - } - - /** - * @notice Convert signed `n` / `d` to a fixed-point number - * @param n Numerator - * @param d Denominator - * @return f Fixed-point representation of n/d - */ - function toFixed(int256 n, int256 d) internal pure returns (int256 f) { - f = _div(_mul(n, FIXED_1), d); - } - - /** - * @notice Convert a fixed-point number to an integer - * @param f Fixed-point number - * @return n Integer representation - */ - function toInteger(int256 f) internal pure returns (int256 n) { - return f / FIXED_1; - } - - /** - * @notice Compute the natural exponent for a fixed-point number EXP_MIN_VAL <= `x` <= 1 - * @param x Fixed-point number to compute exponent for - * @return r The natural exponent of x - */ - function exp(int256 x) internal pure returns (int256 r) { - if (x < EXP_MIN_VAL) { - // Saturate to zero below EXP_MIN_VAL. - return 0; - } - if (x == 0) { - return FIXED_1; - } - if (x > EXP_MAX_VAL) { - revert("out-of-bounds"); - } - - // Rewrite the input as a product of natural exponents and a - // single residual q, where q is a number of small magnitude. - // For example: e^-34.419 = e^(-32 - 2 - 0.25 - 0.125 - 0.044) - // = e^-32 * e^-2 * e^-0.25 * e^-0.125 * e^-0.044 - // -> q = -0.044 - - // Multiply with the taylor series for e^q - int256 y; - int256 z; - // q = x % 0.125 (the residual) - z = y = x % 0x0000000000000000000000000000000010000000000000000000000000000000; - z = (z * y) / FIXED_1; - r += z * 0x10e1b3be415a0000; // add y^02 * (20! / 02!) - z = (z * y) / FIXED_1; - r += z * 0x05a0913f6b1e0000; // add y^03 * (20! / 03!) - z = (z * y) / FIXED_1; - r += z * 0x0168244fdac78000; // add y^04 * (20! / 04!) - z = (z * y) / FIXED_1; - r += z * 0x004807432bc18000; // add y^05 * (20! / 05!) - z = (z * y) / FIXED_1; - r += z * 0x000c0135dca04000; // add y^06 * (20! / 06!) - z = (z * y) / FIXED_1; - r += z * 0x0001b707b1cdc000; // add y^07 * (20! / 07!) - z = (z * y) / FIXED_1; - r += z * 0x000036e0f639b800; // add y^08 * (20! / 08!) - z = (z * y) / FIXED_1; - r += z * 0x00000618fee9f800; // add y^09 * (20! / 09!) - z = (z * y) / FIXED_1; - r += z * 0x0000009c197dcc00; // add y^10 * (20! / 10!) - z = (z * y) / FIXED_1; - r += z * 0x0000000e30dce400; // add y^11 * (20! / 11!) - z = (z * y) / FIXED_1; - r += z * 0x000000012ebd1300; // add y^12 * (20! / 12!) - z = (z * y) / FIXED_1; - r += z * 0x0000000017499f00; // add y^13 * (20! / 13!) - z = (z * y) / FIXED_1; - r += z * 0x0000000001a9d480; // add y^14 * (20! / 14!) - z = (z * y) / FIXED_1; - r += z * 0x00000000001c6380; // add y^15 * (20! / 15!) - z = (z * y) / FIXED_1; - r += z * 0x000000000001c638; // add y^16 * (20! / 16!) - z = (z * y) / FIXED_1; - r += z * 0x0000000000001ab8; // add y^17 * (20! / 17!) - z = (z * y) / FIXED_1; - r += z * 0x000000000000017c; // add y^18 * (20! / 18!) - z = (z * y) / FIXED_1; - r += z * 0x0000000000000014; // add y^19 * (20! / 19!) - z = (z * y) / FIXED_1; - r += z * 0x0000000000000001; // add y^20 * (20! / 20!) - r = r / 0x21c3677c82b40000 + y + FIXED_1; // divide by 20! and then add y^1 / 1! + y^0 / 0! - - // Multiply with the non-residual terms. - x = -x; - // e ^ -32 - if ((x & int256(0x0000000000000000000000000000001000000000000000000000000000000000)) != 0) { - r = - (r * int256(0x00000000000000000000000000000000000000f1aaddd7742e56d32fb9f99744)) / - int256(0x0000000000000000000000000043cbaf42a000812488fc5c220ad7b97bf6e99e); // * e ^ -32 - } - // e ^ -16 - if ((x & int256(0x0000000000000000000000000000000800000000000000000000000000000000)) != 0) { - r = - (r * int256(0x00000000000000000000000000000000000afe10820813d65dfe6a33c07f738f)) / - int256(0x000000000000000000000000000005d27a9f51c31b7c2f8038212a0574779991); // * e ^ -16 - } - // e ^ -8 - if ((x & int256(0x0000000000000000000000000000000400000000000000000000000000000000)) != 0) { - r = - (r * int256(0x0000000000000000000000000000000002582ab704279e8efd15e0265855c47a)) / - int256(0x0000000000000000000000000000001b4c902e273a58678d6d3bfdb93db96d02); // * e ^ -8 - } - // e ^ -4 - if ((x & int256(0x0000000000000000000000000000000200000000000000000000000000000000)) != 0) { - r = - (r * int256(0x000000000000000000000000000000001152aaa3bf81cb9fdb76eae12d029571)) / - int256(0x00000000000000000000000000000003b1cc971a9bb5b9867477440d6d157750); // * e ^ -4 - } - // e ^ -2 - if ((x & int256(0x0000000000000000000000000000000100000000000000000000000000000000)) != 0) { - r = - (r * int256(0x000000000000000000000000000000002f16ac6c59de6f8d5d6f63c1482a7c86)) / - int256(0x000000000000000000000000000000015bf0a8b1457695355fb8ac404e7a79e3); // * e ^ -2 - } - // e ^ -1 - if ((x & int256(0x0000000000000000000000000000000080000000000000000000000000000000)) != 0) { - r = - (r * int256(0x000000000000000000000000000000004da2cbf1be5827f9eb3ad1aa9866ebb3)) / - int256(0x00000000000000000000000000000000d3094c70f034de4b96ff7d5b6f99fcd8); // * e ^ -1 - } - // e ^ -0.5 - if ((x & int256(0x0000000000000000000000000000000040000000000000000000000000000000)) != 0) { - r = - (r * int256(0x0000000000000000000000000000000063afbe7ab2082ba1a0ae5e4eb1b479dc)) / - int256(0x00000000000000000000000000000000a45af1e1f40c333b3de1db4dd55f29a7); // * e ^ -0.5 - } - // e ^ -0.25 - if ((x & int256(0x0000000000000000000000000000000020000000000000000000000000000000)) != 0) { - r = - (r * int256(0x0000000000000000000000000000000070f5a893b608861e1f58934f97aea57d)) / - int256(0x00000000000000000000000000000000910b022db7ae67ce76b441c27035c6a1); // * e ^ -0.25 - } - // e ^ -0.125 - if ((x & int256(0x0000000000000000000000000000000010000000000000000000000000000000)) != 0) { - r = - (r * int256(0x00000000000000000000000000000000783eafef1c0a8f3978c7f81824d62ebf)) / - int256(0x0000000000000000000000000000000088415abbe9a76bead8d00cf112e4d4a8); // * e ^ -0.125 - } - } - - /** - * @notice Returns the multiplication of two numbers, reverting on overflow - * @param a First number - * @param b Second number - * @return c The result of a * b - */ - function _mul(int256 a, int256 b) private pure returns (int256 c) { - if (a == 0 || b == 0) { - return 0; - } - unchecked { - c = a * b; - if (c / a != b || c / b != a) { - revert("overflow"); - } - } - } - - /** - * @notice Returns the division of two numbers, reverting on division by zero - * @param a Dividend - * @param b Divisor - * @return c The result of a / b - */ - function _div(int256 a, int256 b) private pure returns (int256 c) { - if (b == 0) { - revert("overflow"); - } - if (a == MIN_FIXED_VAL && b == -1) { - revert("overflow"); - } - unchecked { - c = a / b; - } - } - - /** - * @notice Adds two numbers, reverting on overflow - * @param a First number - * @param b Second number - * @return c The result of a + b - */ - function _add(int256 a, int256 b) private pure returns (int256 c) { - unchecked { - c = a + b; - if ((a < 0 && b < 0 && c > a) || (a > 0 && b > 0 && c < a)) { - revert("overflow"); - } - } - } -} diff --git a/packages/interfaces/contracts/data-service/IDataServiceRescuable.sol b/packages/interfaces/contracts/data-service/IDataServiceRescuable.sol deleted file mode 100644 index 4607e664c..000000000 --- a/packages/interfaces/contracts/data-service/IDataServiceRescuable.sol +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -pragma solidity ^0.8.22; - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable gas-indexed-events - -import { IDataService } from "./IDataService.sol"; - -/** - * @title Interface for the {IDataServicePausable} contract. - * @author Edge & Node - * @notice Extension for the {IDataService} contract, adds the ability to rescue - * any ERC20 token or ETH from the contract, controlled by a rescuer privileged role. - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -interface IDataServiceRescuable is IDataService { - /** - * @notice Emitted when tokens are rescued from the contract. - * @param from The address initiating the rescue - * @param to The address receiving the rescued tokens - * @param token The address of the token being rescued - * @param tokens The amount of tokens rescued - */ - event TokensRescued(address indexed from, address indexed to, address indexed token, uint256 tokens); - - /** - * @notice Emitted when a rescuer is set. - * @param account The address of the rescuer - * @param allowed Whether the rescuer is allowed to rescue tokens - */ - event RescuerSet(address indexed account, bool allowed); - - /** - * @notice Thrown when trying to rescue zero tokens. - */ - error DataServiceRescuableCannotRescueZero(); - - /** - * @notice Thrown when the caller is not a rescuer. - * @param account The address of the account that attempted the rescue - */ - error DataServiceRescuableNotRescuer(address account); - - /** - * @notice Rescues GRT tokens from the contract. - * @dev Declared as virtual to allow disabling the function via override. - * - * Requirements: - * - Cannot rescue zero tokens. - * - * Emits a {TokensRescued} event. - * - * @param to Address of the tokens recipient. - * @param tokens Amount of tokens to rescue. - */ - function rescueGRT(address to, uint256 tokens) external; - - /** - * @notice Rescues ether from the contract. - * @dev Declared as virtual to allow disabling the function via override. - * - * Requirements: - * - Cannot rescue zeroether. - * - * Emits a {TokensRescued} event. - * - * @param to Address of the tokens recipient. - * @param tokens Amount of tokens to rescue. - */ - function rescueETH(address payable to, uint256 tokens) external; -} From fc4e9e0193f3f930bde9736d2b1f6a9c72c6466f Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 20 Feb 2026 23:37:28 +0000 Subject: [PATCH 50/59] refactor: replace MathUtils.min with OpenZeppelin Math.min MathUtils only provided min() to horizon/subgraph-service code. The other two functions were unused. Switch to OZ's Math.min which is already a dependency, and delete the custom library. --- .../horizon/contracts/libraries/MathUtils.sol | 56 ------------------- .../contracts/staking/HorizonStaking.sol | 6 +- .../contracts/staking/HorizonStakingBase.sol | 4 +- .../HorizonStakingShared.t.sol | 6 +- .../contracts/DisputeManager.sol | 6 +- .../unit/disputeManager/DisputeManager.t.sol | 7 +-- 6 files changed, 13 insertions(+), 72 deletions(-) delete mode 100644 packages/horizon/contracts/libraries/MathUtils.sol diff --git a/packages/horizon/contracts/libraries/MathUtils.sol b/packages/horizon/contracts/libraries/MathUtils.sol deleted file mode 100644 index ec8cc8161..000000000 --- a/packages/horizon/contracts/libraries/MathUtils.sol +++ /dev/null @@ -1,56 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -// TODO: Re-enable and fix issues when publishing a new version -// solhint-disable gas-strict-inequalities - -pragma solidity 0.8.27 || 0.8.33; - -/** - * @title MathUtils Library - * @author Edge & Node - * @notice A collection of functions to perform math operations - * @custom:security-contact Please email security+contracts@thegraph.com if you find any - * bugs. We may have an active bug bounty program. - */ -library MathUtils { - /** - * @notice Calculates the weighted average of two values pondering each of these - * values based on configured weights - * @dev The contribution of each value N is - * weightN/(weightA + weightB). The calculation rounds up to ensure the result - * is always equal or greater than the smallest of the two values. - * @param valueA The amount for value A - * @param weightA The weight to use for value A - * @param valueB The amount for value B - * @param weightB The weight to use for value B - * @return The weighted average result - */ - function weightedAverageRoundingUp( - uint256 valueA, - uint256 weightA, - uint256 valueB, - uint256 weightB - ) internal pure returns (uint256) { - return ((valueA * weightA) + (valueB * weightB) + (weightA + weightB - 1)) / (weightA + weightB); - } - - /** - * @notice Returns the minimum of two numbers - * @param x The first number - * @param y The second number - * @return The minimum of the two numbers - */ - function min(uint256 x, uint256 y) internal pure returns (uint256) { - return x <= y ? x : y; - } - - /** - * @notice Returns the difference between two numbers or zero if negative - * @param x The first number - * @param y The second number - * @return The difference between the two numbers or zero if negative - */ - function diffOrZero(uint256 x, uint256 y) internal pure returns (uint256) { - return (x > y) ? x - y : 0; - } -} diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index d9ead5256..b73bcfbcd 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -13,7 +13,7 @@ import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGra import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; -import { MathUtils } from "../libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { PPMMath } from "../libraries/PPMMath.sol"; import { LinkedList } from "../libraries/LinkedList.sol"; @@ -362,12 +362,12 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { uint256 tokensProvisionTotal = prov.tokens + pool.tokens; require(tokensProvisionTotal != 0, HorizonStakingNoTokensToSlash()); - uint256 tokensToSlash = MathUtils.min(tokens, tokensProvisionTotal); + uint256 tokensToSlash = Math.min(tokens, tokensProvisionTotal); // Slash service provider first // - A portion goes to verifier as reward // - A portion gets burned - uint256 providerTokensSlashed = MathUtils.min(prov.tokens, tokensToSlash); + uint256 providerTokensSlashed = Math.min(prov.tokens, tokensToSlash); if (providerTokensSlashed > 0) { // Pay verifier reward - must be within the maxVerifierCut percentage uint256 maxVerifierTokens = providerTokensSlashed.mulPPM(prov.maxVerifierCut); diff --git a/packages/horizon/contracts/staking/HorizonStakingBase.sol b/packages/horizon/contracts/staking/HorizonStakingBase.sol index 23b7d9ea3..af92c1ce7 100644 --- a/packages/horizon/contracts/staking/HorizonStakingBase.sol +++ b/packages/horizon/contracts/staking/HorizonStakingBase.sol @@ -10,7 +10,7 @@ import { IHorizonStakingBase } from "@graphprotocol/interfaces/contracts/horizon import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; -import { MathUtils } from "../libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { LinkedList } from "../libraries/LinkedList.sol"; import { Multicall } from "@openzeppelin/contracts/utils/Multicall.sol"; @@ -130,7 +130,7 @@ abstract contract HorizonStakingBase is uint256 tokensAvailableDelegated = _getDelegatedTokensAvailable(serviceProvider, verifier); uint256 tokensDelegatedMax = tokensAvailableProvider * (uint256(delegationRatio)); - uint256 tokensDelegatedCapacity = MathUtils.min(tokensAvailableDelegated, tokensDelegatedMax); + uint256 tokensDelegatedCapacity = Math.min(tokensAvailableDelegated, tokensDelegatedMax); return tokensAvailableProvider + tokensDelegatedCapacity; } diff --git a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol index e86b98567..4866d02bb 100644 --- a/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol +++ b/packages/horizon/test/unit/shared/horizon-staking/HorizonStakingShared.t.sol @@ -8,7 +8,7 @@ import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizo import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { LinkedList } from "../../../../contracts/libraries/LinkedList.sol"; -import { MathUtils } from "../../../../contracts/libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { PPMMath } from "../../../../contracts/libraries/PPMMath.sol"; abstract contract HorizonStakingSharedTest is GraphBaseTest { @@ -1389,8 +1389,8 @@ abstract contract HorizonStakingSharedTest is GraphBaseTest { // Calculate expected tokens after slashing CalcValuesSlash memory calcValues; - calcValues.tokensToSlash = MathUtils.min(tokens, before.provision.tokens + before.pool.tokens); - calcValues.providerTokensSlashed = MathUtils.min(before.provision.tokens, calcValues.tokensToSlash); + calcValues.tokensToSlash = Math.min(tokens, before.provision.tokens + before.pool.tokens); + calcValues.providerTokensSlashed = Math.min(before.provision.tokens, calcValues.tokensToSlash); calcValues.delegationTokensSlashed = calcValues.tokensToSlash - calcValues.providerTokensSlashed; if (calcValues.tokensToSlash > 0) { diff --git a/packages/subgraph-service/contracts/DisputeManager.sol b/packages/subgraph-service/contracts/DisputeManager.sol index 437f28dea..3b5ec918a 100644 --- a/packages/subgraph-service/contracts/DisputeManager.sol +++ b/packages/subgraph-service/contracts/DisputeManager.sol @@ -14,7 +14,7 @@ import { IAllocation } from "@graphprotocol/interfaces/contracts/subgraph-servic import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; -import { MathUtils } from "@graphprotocol/horizon/contracts/libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { Attestation } from "./libraries/Attestation.sol"; import { OwnableUpgradeable } from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; @@ -548,8 +548,8 @@ contract DisputeManager is // - The applied cut is the minimum between the provision's maxVerifierCut and the current fishermanRewardCut. This // protects the indexer from sudden changes to the fishermanRewardCut while ensuring the slashing does not revert due // to excessive rewards being requested. - uint256 maxRewardableTokens = MathUtils.min(_tokensSlash, provision.tokens); - uint256 effectiveCut = MathUtils.min(provision.maxVerifierCut, fishermanRewardCut); + uint256 maxRewardableTokens = Math.min(_tokensSlash, provision.tokens); + uint256 effectiveCut = Math.min(provision.maxVerifierCut, fishermanRewardCut); uint256 tokensRewards = effectiveCut.mulPPM(maxRewardableTokens); subgraphService_.slash(_indexer, abi.encode(_tokensSlash, tokensRewards)); diff --git a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol index bf130537a..7662dc1c3 100644 --- a/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol +++ b/packages/subgraph-service/test/unit/disputeManager/DisputeManager.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.27; -import { MathUtils } from "@graphprotocol/horizon/contracts/libraries/MathUtils.sol"; +import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { IDisputeManager } from "@graphprotocol/interfaces/contracts/subgraph-service/IDisputeManager.sol"; import { IAttestation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/IAttestation.sol"; @@ -348,10 +348,7 @@ contract DisputeManagerTest is SubgraphServiceSharedTest { uint32 provisionMaxVerifierCut = staking .getProvision(dispute.indexer, address(subgraphService)) .maxVerifierCut; - uint256 fishermanRewardPercentage = MathUtils.min( - disputeManager.fishermanRewardCut(), - provisionMaxVerifierCut - ); + uint256 fishermanRewardPercentage = Math.min(disputeManager.fishermanRewardCut(), provisionMaxVerifierCut); fishermanReward = _tokensSlash.mulPPM(fishermanRewardPercentage); } From 597a68ba4b91377428e2d4f28ef22e90f7d82bac Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 20 Feb 2026 11:15:42 +0000 Subject: [PATCH 51/59] fix: scope forge lint to contracts/ directory Prevents forge lint from scanning into test/node_modules or other directories with incompatible dependencies or symlink loops. --- packages/horizon/package.json | 2 +- packages/issuance/package.json | 2 +- packages/subgraph-service/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/horizon/package.json b/packages/horizon/package.json index f030d63b0..175f9031b 100644 --- a/packages/horizon/package.json +++ b/packages/horizon/package.json @@ -23,7 +23,7 @@ "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:forge; pnpm lint:md; pnpm lint:json", "lint:ts": "eslint --fix --cache '**/*.{js,ts,cjs,mjs,jsx,tsx}'; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", "lint:sol": "solhint --fix --noPrompt --noPoster 'contracts/**/*.sol'; prettier -w --cache --log-level warn '**/*.sol'", - "lint:forge": "forge lint", + "lint:forge": "forge lint contracts/", "lint:md": "markdownlint --fix --ignore-path ../../.gitignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'", "lint:json": "prettier -w --cache --log-level warn '**/*.json'", "clean": "rm -rf build dist cache cache_forge typechain-types", diff --git a/packages/issuance/package.json b/packages/issuance/package.json index bed1c008a..8eca0250c 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -32,7 +32,7 @@ "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:forge; pnpm lint:md; pnpm lint:json", "lint:ts": "eslint '**/*.{js,ts,cjs,mjs,jsx,tsx}' --fix --cache; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", "lint:sol": "solhint --fix --noPrompt --noPoster 'contracts/**/*.sol'; prettier -w --cache --log-level warn 'contracts/**/*.sol'", - "lint:forge": "forge lint", + "lint:forge": "forge lint contracts/", "lint:md": "markdownlint --fix --ignore-path ../../.gitignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'", "lint:json": "prettier -w --cache --log-level warn '**/*.json'", "verify": "hardhat verify", diff --git a/packages/subgraph-service/package.json b/packages/subgraph-service/package.json index a00a28e57..cb671a6be 100644 --- a/packages/subgraph-service/package.json +++ b/packages/subgraph-service/package.json @@ -21,7 +21,7 @@ "lint": "pnpm lint:ts; pnpm lint:sol; pnpm lint:forge; pnpm lint:md; pnpm lint:json", "lint:ts": "eslint --fix --cache '**/*.{js,ts,cjs,mjs,jsx,tsx}'; prettier -w --cache --log-level warn '**/*.{js,ts,cjs,mjs,jsx,tsx}'", "lint:sol": "solhint --fix --noPrompt --noPoster 'contracts/**/*.sol'; prettier -w --cache --log-level warn '**/*.sol'", - "lint:forge": "forge lint", + "lint:forge": "forge lint contracts/", "lint:md": "markdownlint --fix --ignore-path ../../.gitignore '**/*.md'; prettier -w --cache --log-level warn '**/*.md'", "lint:json": "prettier -w --cache --log-level warn '**/*.json'", "clean": "rm -rf build dist cache cache_forge typechain-types", From 4b9b54177dfd84c62ec056886ed927b6c21f642d Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:16:39 +0000 Subject: [PATCH 52/59] fix: resolve solhint and forge-lint warnings across merged libraries - Add contract-level natspec to StakeClaims, IndexingAgreement, IndexingAgreementDecoder, IndexingAgreementDecoderRaw - Suppress gas-indexed-events on StakeClaims events and asm-keccak256 note - Move forge-lint comments before natspec blocks to fix natspec detection - Use block-style solhint-disable for function-max-lines suppression - Change struct params to calldata in AllocationHandler and IndexingAgreement - Remove orphaned natspec and unnecessary forge-lint comments --- .../data-service/libraries/StakeClaims.sol | 9 +++++++ .../contracts/SubgraphService.sol | 3 --- .../contracts/libraries/AllocationHandler.sol | 17 ++++++------ .../contracts/libraries/IndexingAgreement.sol | 27 ++++++++++--------- .../libraries/IndexingAgreementDecoder.sol | 7 +++-- .../libraries/IndexingAgreementDecoderRaw.sol | 7 +++-- .../contracts/utilities/AllocationManager.sol | 2 -- .../subgraphService/allocation/resize.t.sol | 6 +---- .../indexing-agreement/accept.t.sol | 7 ----- .../indexing-agreement/shared.t.sol | 3 --- .../indexing-agreement/update.t.sol | 4 --- 11 files changed, 43 insertions(+), 49 deletions(-) diff --git a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol index 1fbfd8a91..1d42538b5 100644 --- a/packages/horizon/contracts/data-service/libraries/StakeClaims.sol +++ b/packages/horizon/contracts/data-service/libraries/StakeClaims.sol @@ -6,6 +6,12 @@ import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHo import { ILinkedList } from "@graphprotocol/interfaces/contracts/horizon/internal/ILinkedList.sol"; import { LinkedList } from "../../libraries/LinkedList.sol"; +/** + * @title StakeClaims library + * @author Edge & Node + * @notice Manages stake claims for data service fee payments, allowing service providers + * to lock and release provisioned stake through linked-list-based claim tracking. + */ library StakeClaims { using ProvisionTracker for mapping(address => uint256); using LinkedList for ILinkedList.List; @@ -27,6 +33,7 @@ library StakeClaims { bytes32 nextClaim; } + /* solhint-disable gas-indexed-events */ /** * @notice Emitted when a stake claim is created and stake is locked. * @param serviceProvider The address of the service provider @@ -62,6 +69,7 @@ library StakeClaims { * @param tokensReleased The total amount of tokens being released */ event StakeClaimsReleased(address indexed serviceProvider, uint256 claimsCount, uint256 tokensReleased); + /* solhint-enable gas-indexed-events */ /** * @notice Thrown when attempting to get a stake claim that does not exist. @@ -209,6 +217,7 @@ library StakeClaims { address _serviceProvider, uint256 _nonce ) internal pure returns (bytes32) { + // forge-lint: disable-next-line(asm-keccak256) return keccak256(abi.encodePacked(_dataService, _serviceProvider, _nonce)); } } diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index e67d92cf6..4626c4a05 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -377,7 +377,6 @@ contract SubgraphService is _setStakeToFeesRatio(stakeToFeesRatio_); } - // forge-lint: disable-next-item(mixed-case-function) /// @inheritdoc ISubgraphService function setMaxPOIStaleness(uint256 maxPoiStaleness_) external override onlyOwner { _setMaxPoiStaleness(maxPoiStaleness_); @@ -423,7 +422,6 @@ contract SubgraphService is */ function acceptIndexingAgreement( address allocationId, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA calldata signedRCA ) external @@ -451,7 +449,6 @@ contract SubgraphService is */ function updateIndexingAgreement( address indexer, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU calldata signedRCAU ) external diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index 7316a549c..9c233b13b 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -20,6 +20,7 @@ import { LegacyAllocation } from "../libraries/LegacyAllocation.sol"; /** * @title AllocationHandler contract + * @author Edge & Node * @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). @@ -75,7 +76,6 @@ library AllocationHandler { * @param _paymentsDestination The address to which the indexing rewards should be sent */ struct PresentParams { - // forge-lint: disable-next-line(mixed-case-variable) uint256 maxPOIStaleness; IEpochManager graphEpochManager; IHorizonStaking graphStaking; @@ -146,7 +146,7 @@ library AllocationHandler { ); /** - * @dev Emitted when an indexer closes an allocation + * @notice 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 @@ -177,8 +177,8 @@ library AllocationHandler { * @notice Emitted when the maximum POI staleness is updated * @param maxPOIStaleness The max POI staleness in seconds */ - // forge-lint: disable-next-item(mixed-case-variable) event MaxPOIStalenessSet(uint256 maxPOIStaleness); + // solhint-disable-previous-line gas-indexed-events /** * @notice Emitted when an indexer presents a POI for an allocation @@ -244,7 +244,7 @@ library AllocationHandler { mapping(address allocationId => ILegacyAllocation.State allocation) storage _legacyAllocations, mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, - AllocateParams memory params + AllocateParams calldata params ) external { require(params._allocationId != address(0), AllocationHandler.AllocationHandlerInvalidZeroAllocationId()); @@ -280,6 +280,7 @@ library AllocationHandler { ); } + /* solhint-disable function-max-lines */ /** * @notice Present a POI to collect indexing rewards for an allocation * Mints indexing rewards using the {RewardsManager} and distributes them to the indexer and delegators. @@ -318,13 +319,11 @@ library AllocationHandler { * @return rewardsCollected The amount of tokens collected * @return allocationForceClosed True if the allocation was automatically closed due to over-allocation, false otherwise */ - // solhint-disable-next-line function-max-lines - // forge-lint: disable-next-item(mixed-case-function) function presentPOI( mapping(address allocationId => IAllocation.State allocation) storage _allocations, mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, mapping(bytes32 subgraphDeploymentId => uint256 tokens) storage _subgraphAllocatedTokens, - PresentParams memory params + PresentParams calldata params ) external returns (uint256 rewardsCollected, bool allocationForceClosed) { IAllocation.State memory allocation = _allocations.get(params._allocationId); require(allocation.isOpen(), AllocationHandler.AllocationHandlerAllocationClosed(params._allocationId)); @@ -413,6 +412,7 @@ library AllocationHandler { ); } } + /* solhint-enable function-max-lines */ /** * @notice Close an allocation @@ -448,6 +448,7 @@ library AllocationHandler { ); } + /* solhint-disable function-max-lines */ /** * @notice Resize an allocation * @dev Will lock or release tokens in the provision tracker depending on the new allocation size. @@ -472,7 +473,6 @@ library AllocationHandler { * @param _delegationRatio The delegation ratio to consider when locking tokens * @param _maxPOIStaleness The maximum staleness of the POI in seconds */ - // forge-lint: disable-next-item(mixed-case-variable) function resizeAllocation( mapping(address allocationId => IAllocation.State allocation) storage _allocations, mapping(address indexer => uint256 tokens) storage allocationProvisionTracker, @@ -537,6 +537,7 @@ library AllocationHandler { oldTokens ); } + /* solhint-enable function-max-lines */ /** * @notice Checks if an allocation is over-allocated diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 176bc0796..7d7bce017 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -12,6 +12,13 @@ import { Directory } from "../utilities/Directory.sol"; import { Allocation } from "./Allocation.sol"; import { IndexingAgreementDecoder } from "./IndexingAgreementDecoder.sol"; +/** + * @title IndexingAgreement library + * @author Edge & Node + * @notice Manages indexing agreement lifecycle: acceptance, updates, cancellation and fee collection. + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ library IndexingAgreement { using IndexingAgreement for StorageManager; using Allocation for IAllocation.State; @@ -256,6 +263,7 @@ library IndexingAgreement { */ error IndexingAgreementInvalidTerms(uint256 tokensPerSecond, uint256 maxOngoingTokensPerSecond); + /* solhint-disable function-max-lines */ /** * @notice Accept an indexing agreement. * @@ -274,12 +282,12 @@ library IndexingAgreement { * @param allocations The mapping of allocation IDs to their states * @param allocationId The id of the allocation * @param signedRCA The signed Recurring Collection Agreement + * @return The agreement ID assigned to the accepted indexing agreement */ function accept( StorageManager storage self, mapping(address allocationId => IAllocation.State allocation) storage allocations, address allocationId, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA calldata signedRCA ) external returns (bytes16) { IAllocation.State memory allocation = _requireValidAllocation( @@ -347,6 +355,7 @@ library IndexingAgreement { require(_directory().recurringCollector().accept(signedRCA) == agreementId, "internal: agreement ID mismatch"); return agreementId; } + /* solhint-enable function-max-lines */ /** * @notice Update an indexing agreement. @@ -366,7 +375,6 @@ library IndexingAgreement { function update( StorageManager storage self, address indexer, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU calldata signedRCAU ) external { IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, signedRCAU.rcau.agreementId); @@ -506,6 +514,7 @@ library IndexingAgreement { ); } + /* solhint-disable function-max-lines */ /** * @notice Collect Indexing fees * @dev Uses the {RecurringCollector} to collect payment from Graph Horizon payments protocol. @@ -528,7 +537,7 @@ library IndexingAgreement { function collect( StorageManager storage self, mapping(address allocationId => IAllocation.State allocation) storage allocations, - CollectParams memory params + CollectParams calldata params ) external returns (address, uint256) { IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, params.agreementId); IAllocation.State memory allocation = _requireValidAllocation( @@ -589,6 +598,7 @@ library IndexingAgreement { return (wrapper.collectorAgreement.serviceProvider, tokensCollected); } + /* solhint-enable function-max-lines */ /** * @notice Get the indexing agreement for a given agreement ID. @@ -732,15 +742,6 @@ library IndexingAgreement { return _isValid(wrapper) && wrapper.collectorAgreement.state == IRecurringCollector.AgreementState.Accepted; } - /** - * @notice Checks if the agreement is collectable - * Requirements: - * - The indexing agreement is valid - * - The underlying collector agreement is collectable - * @param wrapper The agreement wrapper containing the indexing agreement and collector agreement data - * @return True if the agreement is collectable, false otherwise - **/ - /** * @notice Checks if the agreement is valid * Requirements: @@ -784,12 +785,12 @@ library IndexingAgreement { * @param terms The indexing agreement terms to validate * @param maxOngoingTokensPerSecond The RCA maximum tokens per second limit */ - // forge-lint: disable-next-item(mixed-case-function) function _validateTermsAgainstRCA( IndexingAgreementTermsV1 memory terms, uint256 maxOngoingTokensPerSecond ) private pure { require( + // solhint-disable-next-line gas-strict-inequalities terms.tokensPerSecond <= maxOngoingTokensPerSecond, IndexingAgreementInvalidTerms(terms.tokensPerSecond, maxOngoingTokensPerSecond) ); diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol index 70c380457..3cd57b979 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol @@ -4,6 +4,11 @@ pragma solidity 0.8.33; import { IndexingAgreementDecoderRaw } from "./IndexingAgreementDecoderRaw.sol"; import { IndexingAgreement } from "./IndexingAgreement.sol"; +/** + * @title IndexingAgreementDecoder library + * @author Edge & Node + * @notice Safe decoder for indexing agreement data structures, reverting with typed errors on malformed input. + */ library IndexingAgreementDecoder { /** * @notice Thrown when the data can't be decoded as expected @@ -33,7 +38,6 @@ library IndexingAgreementDecoder { * @param data The data to decode. * @return The decoded data. See {IndexingAgreement.AcceptIndexingAgreementMetadata} */ - // forge-lint: disable-next-item(mixed-case-function) function decodeRCAMetadata( bytes memory data ) public pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { @@ -52,7 +56,6 @@ library IndexingAgreementDecoder { * @param data The data to decode. * @return The decoded data. See {IndexingAgreement.UpdateIndexingAgreementMetadata} */ - // forge-lint: disable-next-item(mixed-case-function) function decodeRCAUMetadata( bytes memory data ) public pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol index b51145924..c0adb5375 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol @@ -3,6 +3,11 @@ pragma solidity 0.8.33; import { IndexingAgreement } from "./IndexingAgreement.sol"; +/** + * @title IndexingAgreementDecoderRaw library + * @author Edge & Node + * @notice Low-level decoder for indexing agreement data structures, propagating native revert on malformed input. + */ library IndexingAgreementDecoderRaw { /** * @notice See {IndexingAgreementDecoder.decodeCollectIndexingFeeData} @@ -20,7 +25,6 @@ library IndexingAgreementDecoderRaw { * @param data The data to decode * @return The decoded data */ - // forge-lint: disable-next-item(mixed-case-function) function decodeRCAMetadata( bytes calldata data ) public pure returns (IndexingAgreement.AcceptIndexingAgreementMetadata memory) { @@ -33,7 +37,6 @@ library IndexingAgreementDecoderRaw { * @param data The data to decode * @return The decoded data */ - // forge-lint: disable-next-item(mixed-case-function) function decodeRCAUMetadata( bytes calldata data ) public pure returns (IndexingAgreement.UpdateIndexingAgreementMetadata memory) { diff --git a/packages/subgraph-service/contracts/utilities/AllocationManager.sol b/packages/subgraph-service/contracts/utilities/AllocationManager.sol index 181246c8f..32329482d 100644 --- a/packages/subgraph-service/contracts/utilities/AllocationManager.sol +++ b/packages/subgraph-service/contracts/utilities/AllocationManager.sol @@ -44,7 +44,6 @@ abstract contract AllocationManager is keccak256("AllocationIdProof(address indexer,address allocationId)"); // solhint-disable-previous-line gas-small-strings - // forge-lint: disable-next-item(mixed-case-function) /** * @notice Initializes the contract and parent contracts * @param _name The name to use for EIP712 domain separation @@ -55,7 +54,6 @@ abstract contract AllocationManager is __AllocationManager_init_unchained(); } - // forge-lint: disable-next-item(mixed-case-function) /** * @notice Initializes the contract */ 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 12bbf1b3a..7b33537d2 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/resize.t.sol @@ -85,11 +85,7 @@ contract SubgraphServiceAllocationResizeTest is SubgraphServiceTest { uint256 tokens ) public useIndexer useAllocation(tokens) { vm.expectRevert( - abi.encodeWithSelector( - AllocationHandler.AllocationHandlerAllocationSameSize.selector, - allocationId, - tokens - ) + abi.encodeWithSelector(AllocationHandler.AllocationHandlerAllocationSameSize.selector, allocationId, tokens) ); subgraphService.resizeAllocation(users.indexer, allocationId, tokens); } 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 index f20c1827f..fe4b8b805 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/accept.t.sol @@ -22,7 +22,6 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenPaused( address allocationId, address operator, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA calldata signedRCA ) public withSafeIndexerOrOperator(operator) { resetPrank(users.pauseGuardian); @@ -36,7 +35,6 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg function test_SubgraphService_AcceptIndexingAgreement_Revert_WhenNotAuthorized( address allocationId, address operator, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA calldata signedRCA ) public withSafeIndexerOrOperator(operator) { vm.assume(operator != signedRCA.rca.serviceProvider); @@ -54,7 +52,6 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg address indexer, uint256 unboundedTokens, address allocationId, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA memory signedRCA ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); @@ -78,7 +75,6 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg address indexer, uint256 unboundedTokens, address allocationId, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA memory signedRCA ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); @@ -244,12 +240,10 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg // Now try to accept a different agreement on the same allocation // Create a new agreement with different nonce to ensure different agreement ID IRecurringCollector.RecurringCollectionAgreement - // forge-lint: disable-next-line(mixed-case-variable) memory newRCA = _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr); newRCA.nonce = alternativeNonce; // Different nonce to ensure different agreement ID // Sign the new agreement - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA memory newSignedRCA = _recurringCollectorHelper.generateSignedRCA( newRCA, ctx.payer.signerPrivateKey @@ -269,7 +263,6 @@ contract SubgraphServiceIndexingAgreementAcceptTest is SubgraphServiceIndexingAg Context storage ctx = _newCtx(seed); IndexerState memory indexerState = _withIndexer(ctx); IRecurringCollector.SignedRCA memory acceptable = _generateAcceptableSignedRCA(ctx, indexerState.addr); - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.RecurringCollectionAgreement memory notAcceptableRCA = acceptable.rca; bytes memory invalidTermsData = bytes("invalid terms data"); notAcceptableRCA.metadata = abi.encode( 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 index befbb8730..ba39cb8b6 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/shared.t.sol @@ -182,7 +182,6 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun rca = _recurringCollectorHelper.sensibleRCA(rca); - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA( rca, _ctx.payer.signerPrivateKey @@ -235,7 +234,6 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun return ctx; } - // forge-lint: disable-next-item(mixed-case-function) function _generateAcceptableSignedRCA( Context storage _ctx, address _indexerAddress @@ -265,7 +263,6 @@ contract SubgraphServiceIndexingAgreementSharedTest is SubgraphServiceTest, Boun return _recurringCollectorHelper.sensibleRCA(rca); } - // forge-lint: disable-next-item(mixed-case-function) function _generateAcceptableSignedRCAU( Context storage _ctx, IRecurringCollector.RecurringCollectionAgreement memory _rca 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 index a0ea5cc39..dd5af140c 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/update.t.sol @@ -19,7 +19,6 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA /* solhint-disable graph/func-name-mixedcase */ function test_SubgraphService_UpdateIndexingAgreementIndexingAgreement_Revert_WhenPaused( address operator, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU calldata signedRCAU ) public withSafeIndexerOrOperator(operator) { resetPrank(users.pauseGuardian); @@ -33,7 +32,6 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenNotAuthorized( address indexer, address notAuthorized, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU calldata signedRCAU ) public withSafeIndexerOrOperator(notAuthorized) { vm.assume(notAuthorized != indexer); @@ -50,7 +48,6 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenInvalidProvision( address indexer, uint256 unboundedTokens, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU memory signedRCAU ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, 1, MINIMUM_PROVISION_TOKENS - 1); @@ -72,7 +69,6 @@ contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingA function test_SubgraphService_UpdateIndexingAgreement_Revert_WhenIndexerNotRegistered( address indexer, uint256 unboundedTokens, - // forge-lint: disable-next-line(mixed-case-variable) IRecurringCollector.SignedRCAU memory signedRCAU ) public withSafeIndexerOrOperator(indexer) { uint256 tokens = bound(unboundedTokens, MINIMUM_PROVISION_TOKENS, MAX_TOKENS); From fedf53927fa4f03a4530e9dd4bf76e6fd4776ba9 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Fri, 20 Feb 2026 18:21:19 +0000 Subject: [PATCH 53/59] fix: struct packing optimization and typecast lint annotations - Reorder AllocateParams struct to pack _delegationRatio (uint32) with _indexer (address) into a single 32-byte slot - Add forge-lint unsafe-typecast annotations in RecurringCollector with safety rationale for uint32/uint64 casts bounded by require checks --- .../contracts/payments/collectors/RecurringCollector.sol | 4 ++++ .../contracts/libraries/AllocationHandler.sol | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 0ab66bdd9..10c6df60f 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -452,6 +452,8 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC _collectionSeconds >= _agreement.minSecondsPerCollection, RecurringCollectorCollectionTooSoon( _agreementId, + // casting to uint32 is safe because _collectionSeconds < minSecondsPerCollection (uint32) + // forge-lint: disable-next-line(unsafe-typecast) uint32(_collectionSeconds), _agreement.minSecondsPerCollection ) @@ -462,6 +464,8 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC _collectionSeconds <= _agreement.maxSecondsPerCollection, RecurringCollectorCollectionTooLate( _agreementId, + // casting to uint64 is safe because _collectionSeconds <= block.timestamp which fits in uint64 + // forge-lint: disable-next-line(unsafe-typecast) uint64(_collectionSeconds), _agreement.maxSecondsPerCollection ) diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index 9c233b13b..d45db0ae6 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -42,11 +42,11 @@ library AllocationHandler { * @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 _delegationRatio The delegation ratio to consider when locking tokens * @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; @@ -54,11 +54,11 @@ library AllocationHandler { IRewardsManager graphRewardsManager; bytes32 _encodeAllocationProof; address _indexer; + uint32 _delegationRatio; address _allocationId; bytes32 _subgraphDeploymentId; uint256 _tokens; bytes _allocationProof; - uint32 _delegationRatio; } /** From 5b9463f265b91f8d7982c4b0f11299fe3bdd22d2 Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:29:04 +0000 Subject: [PATCH 54/59] fixup! Merge branch 'rem-horizon-cleanup-merge' into rem-baseline-merge --- .../subgraph-service/contracts/libraries/AllocationHandler.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index d45db0ae6..7d78ca6d3 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -252,7 +252,7 @@ library AllocationHandler { // Ensure allocation id is not reused // need to check both subgraph service (on allocations.create()) and legacy allocations - _legacyAllocations.revertIfExists(params.graphStaking, params._allocationId); + _legacyAllocations.revertIfExists(params._allocationId); IAllocation.State memory allocation = _allocations.create( params._indexer, From c071455f186a469c50dc91208d59fd219d173673 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Mon, 23 Feb 2026 17:14:55 -0300 Subject: [PATCH 55/59] chore: add dips audit review doc --- docs/dips-issuance-merge/AUDIT-REVIEW.md | 206 +++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/dips-issuance-merge/AUDIT-REVIEW.md diff --git a/docs/dips-issuance-merge/AUDIT-REVIEW.md b/docs/dips-issuance-merge/AUDIT-REVIEW.md new file mode 100644 index 000000000..63d36f34e --- /dev/null +++ b/docs/dips-issuance-merge/AUDIT-REVIEW.md @@ -0,0 +1,206 @@ +# Audit Review: dips-issuance-merge + +This document identifies all contract changes introduced by merging two independently audited branches and the post-merge fixup commits. It separates genuinely new code from relocated or mechanical changes. + +## Branch Topology + +``` +main + └── ma/indexing-payments-audited-reviewed (dips) + │ Commits: a7fb8758..0e469bee (18 commits) + │ Audit: TRST-* findings (all addressed) + │ + ├── tooling/build/CI commits: 0de8ddb7..1ea8d5b3 (42 commits) + │ (Hardhat 3 upgrade, Solidity 0.8.33, interface centralization, lint, etc.) + │ + ├── Merge commit: a11a5324 (issuance-audit → mde/dips-issuance-merge) + │ Parent 1: 1ea8d5b3 (dips + tooling) + │ Parent 2: 16dbd737 (tip of issuance-audit) + │ + ├── Post-merge fixups: 3c1a2f11..c3f6f075 (7 commits) + │ + ├── Second merge commit: (origin/issuance-audit → mde/dips-issuance-merge) + │ Parent 1: c3f6f075 (dips + tooling + first merge fixups) + │ Parent 2: aa082308 (tip of issuance-audit, 5 new audit-fix commits) + │ New commits: 16dbd737..aa082308 + │ 80f81756 docs: document minimumSubgraphSignal retroactive application issue + │ 22954a48 docs: improve reward documentation accuracy (TRST-R-2) + │ affc8b46 fix: remove redundant subgraphAllocatedTokens check (TRST-R-3) + │ 5e319051 refactor: rename NO_ALLOCATION to NO_ALLOCATED_TOKENS + │ aa082308 fix: reclaim pending rewards on stale allocation resize (TRST-R-1) + │ + └── (current tip) +``` + +- **issuance-audit** branch: audited separately (rewards/issuance system) +- **dips (indexing payments)** branch: audited as TRST-\* (RecurringCollector, IndexingAgreement, etc.) +- **First merge commit**: combines both into one branch, resolving conflicts +- **Post-merge commits**: lint, pragma, type extraction -- no logic changes +- **Second merge commit**: brings in 5 audit-fix commits from issuance-audit (TRST-R-1/R-2/R-3 fixes, rename, docs) + +--- + +## Section 1: Relocated Files (No Logic Changes) + +During the merge, interfaces were moved to the centralized `packages/interfaces/` package. Content is identical to the audited versions. + +### Interface Relocations + +| Old Path | New Path | +|---|---| +| `packages/horizon/contracts/interfaces/IRecurringCollector.sol` | `packages/interfaces/contracts/horizon/IRecurringCollector.sol` | +| `packages/subgraph-service/contracts/interfaces/IDisputeManager.sol` | `packages/interfaces/contracts/subgraph-service/IDisputeManager.sol` | +| `packages/subgraph-service/contracts/interfaces/ISubgraphService.sol` | `packages/interfaces/contracts/subgraph-service/ISubgraphService.sol` | +| `packages/horizon/contracts/data-service/interfaces/IDataServiceFees.sol` | `packages/interfaces/contracts/data-service/IDataServiceFees.sol` | + +Notes: +- **IRecurringCollector.sol** (480 lines): byte-for-byte identical to the audited version. New file (no pre-existing version in the issuance-audit branch). +- **IDisputeManager.sol**: `IndexingFeeDispute` enum variant, `createIndexingFeeDisputeV1`, events, and errors added to the centralized interface file. All additions are audited code with only `IndexingAgreement.` → `IIndexingAgreement.` type reference changes. +- **ISubgraphService.sol**: `setIndexingFeesCut`, `acceptIndexingAgreement`, `updateIndexingAgreement`, `cancelIndexingAgreement`, `cancelIndexingAgreementByPayer`, `getIndexingAgreement` added to the centralized interface file. Same `IndexingAgreement.` → `IIndexingAgreement.` type reference changes. +- **IDataServiceFees.sol**: `StakeClaim` struct, events, and errors removed (moved to `StakeClaims` library). Only `releaseStake` function signature remains. + +### Pragma-Only Changes + +These files had conflicts only because both branches changed the pragma. Resolved to `0.8.27 || 0.8.33`. No other changes. + +| File | +|---| +| `packages/horizon/contracts/data-service/extensions/DataServicePausable.sol` | +| `packages/horizon/contracts/data-service/extensions/DataServiceRescuable.sol` | +| `packages/horizon/contracts/libraries/Denominations.sol` | + +### Import Path + Refactor (Non-Overlapping) + +These files had changes from both parents but they did not overlap. No merge-specific code was written. + +| File | From indexing-payments | From issuance-audit | +|---|---|---| +| `DataServiceFees.sol` | Import paths to `@graphprotocol/interfaces` | Extracted private helpers to `StakeClaims` library | +| `DataServiceFeesStorage.sol` | Import `StakeClaims` instead of `IDataServiceFees` | `LinkedList.List` → `ILinkedList.List` | +| `ProvisionManager.sol` | Import paths, forge-lint comments | `onlyValidProvision` → `_requireValidProvision()`, extracted `_requireLTE()` | +| `DisputeManager.sol` | Added `IIndexingAgreement` import | Import path changes | +| `Directory.sol` | Added `IRecurringCollector` import | Import paths to `@graphprotocol/interfaces` | +| `SubgraphServiceStorage.sol` | Added `indexingFeesCut` storage variable | Inherited `ISubgraphService`, added `override` to getters | + +--- + +## Section 2: Files with Genuinely New Lines + +These files contain lines that did not exist in either audited parent. **Total: ~20 lines, zero business logic.** + +### AllocationHandler.sol (186 `++` lines from first merge, ~12 genuinely new) + +This library was introduced in the indexing-payments branch. During the merge, reward logic from `AllocationManager.sol` was relocated into this library structure. + +**Relocated from `AllocationManager.sol` (faithful port, ~179 lines):** +- `POIPresented` event with `condition` field +- `RewardsCondition`-based branching in `presentPOI` (STALE_POI, ZERO_POI, ALLOCATION_TOO_YOUNG, SUBGRAPH_DENIED) +- Three reward paths: CLAIMED (takeRewards), RECLAIMED (reclaimRewards), DEFERRED (early return) +- `_distributeIndexingRewards()` private helper (delegator/indexer split) +- `_closeAllocation` calling `reclaimRewards(CLOSE_ALLOCATION, ...)` before closing +- All adapted from contract-style (`_graphStaking()`, `address(this)`) to library-style (`params.graphStaking`, `params.dataService`) + +**Genuinely new lines (~12):** +- `// Scoped for stack management` (2 comments) +- Condensed natspec on `_closeAllocation` and `_distributeIndexingRewards` +- `return (0, false)` instead of `return 0` (tuple adaptation for force-close return signature) +- `emit AllocationHandler.POIPresented(...)` instead of `emit POIPresented(...)` (library-qualified emit) +- `uint256 _maxPOIStaleness` parameter added to `resizeAllocation()` (second merge: port of stale reclaim from TRST-R-1) +- `@param _maxPOIStaleness` natspec line +- `// forge-lint: disable-next-item(mixed-case-variable)` comment on `resizeAllocation` + +### IIndexingAgreement.sol (39 lines, ~5 genuinely new) + +New interface file extracted from `IndexingAgreement.sol` library during the merge. Types (`IndexingAgreementVersion`, `State`, `AgreementWrapper`) are identical to the audited library. + +**Genuinely new:** interface declaration boilerplate and natspec header (~5 lines). + +### SubgraphService.sol (2 `++` lines, 1 genuinely new) + +- Import path adjustment (relocated) +- `(uint256 paymentCollected, bool allocationForceClosed) = _presentPoi(` — combines tuple destructuring with the renamed function + +### AllocationManager.sol (3 `++` lines from first merge, ~3 genuinely new) + +- `See {AllocationHandler-presentPOI} for detailed reward path documentation.` (natspec cross-reference) +- `Emits a {POIPresented} event.` (natspec note) +- `maxPOIStaleness` now passed as argument to `AllocationHandler.resizeAllocation()` (second merge: enables stale reclaim port) + +--- + +## Section 3: New Contract Files + +These files are entirely new to the branch (not in main). They come from one of the two audited branches. + +### From indexing-payments branch (TRST-\* audited) + +| File | Lines | Description | +|---|---|---| +| `packages/horizon/contracts/payments/collectors/RecurringCollector.sol` | 643 | Recurring payment collector contract | +| `packages/horizon/contracts/data-service/libraries/StakeClaims.sol` | 213 | Stake claim management library | +| `packages/subgraph-service/contracts/libraries/AllocationHandler.sol` | 600 | Allocation logic library (extracted from AllocationManager) | +| `packages/subgraph-service/contracts/libraries/IndexingAgreement.sol` | 803 | Indexing agreement lifecycle library | +| `packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol` | 101 | ABI decoder for indexing agreement metadata | +| `packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol` | 65 | Raw calldata decoder for indexing agreements | + +### From issuance-audit branch (audited) + +| File | Lines | Description | +|---|---|---| +| `packages/interfaces/contracts/contracts/rewards/RewardsCondition.sol` | 54 | Rewards condition enum/types | +| `packages/interfaces/contracts/contracts/rewards/IRewardsManagerDeprecated.sol` | 40 | Deprecated rewards manager interface | +| `packages/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol` | 165 | Extracted allocation manager interface | + +### Created during merge + +| File | Lines | Description | +|---|---|---| +| `packages/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol` | 39 | Types extracted from `IndexingAgreement.sol` library | + +--- + +## Section 4: Post-Merge Commits + +7 commits after the merge (3c1a2f11..c3f6f075). All mechanical — zero behavioral changes. + +| Commit | Description | Non-test .sol files | Risk | +|---|---|---|---| +| `3c1a2f11` | Pragma widening to `0.8.27 \|\| 0.8.33`, import path centralization | StakeClaims.sol, imports.sol, RecurringCollector.sol | Mechanical | +| `26c6c445` | Extract types from `IndexingAgreement` lib to `IIndexingAgreement` interface | IndexingAgreement.sol, DisputeManager.sol, SubgraphService.sol, AllocationHandler.sol, AllocationManager.sol | Structural (type move, no logic) | +| `9742dd80` | Rename test constants to UPPER_SNAKE_CASE | 0 (test-only) | N/A | +| `fa57fbc0` | Update expected errors and assertions in tests | 0 (test-only) | N/A | +| `89c70100` | Add `--ir-minimum` flag to forge coverage | 0 (config-only) | N/A | +| `4450e9c2` | Add forge-lint disable comments for RCA/POI acronyms | SubgraphService.sol, AllocationHandler.sol, IndexingAgreement.sol, IndexingAgreementDecoder.sol, IndexingAgreementDecoderRaw.sol | Mechanical (comments only) | +| `c3f6f075` | Solhint suppression, natspec `@dev`→`@notice`, `memory`→`calldata` on `getCollectionInfo` | RecurringCollector.sol, IRecurringCollector.sol, IDisputeManager.sol, ISubgraphService.sol | Mechanical | + +The only non-comment code change across all 7 commits: `getCollectionInfo` parameter changed from `AgreementData memory` to `AgreementData calldata` (gas optimization, ABI-compatible). + +### Second Merge: origin/issuance-audit (5 audit-fix commits) + +Conflict resolution: + +| File | Resolution | +|---|---| +| `AllocationManager.sol` | Resolved to the thin wrapper version. The stale reclaim logic (TRST-R-1) was placed in `AllocationHandler.resizeAllocation()` instead of inline, passing `maxPOIStaleness` as a new parameter. | +| `resize.t.sol` | Resolved with `AllocationHandler` import for error selectors and added `IAllocation` import. Two new test functions (`StaleAllocation_ReclaimsPending`, `NotStale_PreservesPending`) auto-merged cleanly. | + +Auto-merged files (verified correct): + +| File | Description | +|---|---| +| `MockRewardsManager.sol` | `calcRewards` now returns `(_accRewardsPerAllocatedToken * _tokens) / FIXED_POINT_SCALING_FACTOR`. `reclaimRewards` destructures all 6 return values from `getAllocationData` and computes `accRewardsPending + newRewards`. | +| `SubgraphService.t.sol` | Pending rewards assertion updated to include `beforeAllocation.accRewardsPending`. | + +--- + +## Summary + +| Category | Files | New Logic Lines | +|---|---|---| +| Relocated interfaces (path moves) | 4 | 0 | +| Pragma-only conflicts | 3 | 0 | +| Non-overlapping import/refactor merges | 6 | 0 | +| Genuinely new lines (first merge resolution) | 4 | ~15 (natspec, comments, boilerplate) | +| Post-merge commits | 13 non-test .sol files across 7 commits | 0 (1 `memory`→`calldata` change) | +| Second merge (stale reclaim port) | 2 | ~5 (param plumbing, natspec, forge-lint comment) | +| **Total new business logic** | | **0** | From edf40dca63fd4d314086c5df0ff54fca13bf60d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 23 Feb 2026 17:16:13 -0300 Subject: [PATCH 56/59] feat: add back legacy allocation id collision check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../contracts/staking/HorizonStaking.sol | 24 ++++ .../staking/HorizonStakingStorage.sol | 5 +- .../GraphTallyCollector.t.sol | 2 +- .../unit/staking/legacy/isAllocation.t.sol | 107 ++++++++++++++++++ .../horizon/internal/IHorizonStakingMain.sol | 9 ++ .../horizon/internal/IHorizonStakingTypes.sol | 38 +++++++ .../contracts/libraries/AllocationHandler.sol | 2 +- .../contracts/libraries/LegacyAllocation.sol | 9 +- .../subgraphService/SubgraphService.t.sol | 39 +++++++ .../subgraphService/allocation/start.t.sol | 5 +- .../subgraphService/collect/query/query.t.sol | 2 +- 11 files changed, 234 insertions(+), 8 deletions(-) create mode 100644 packages/horizon/test/unit/staking/legacy/isAllocation.t.sol diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index b73bcfbcd..7761af704 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -1170,6 +1170,30 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { } } + /// @inheritdoc IHorizonStakingMain + function isAllocation(address allocationID) external view override returns (bool) { + return _getLegacyAllocationState(allocationID) != LegacyAllocationState.Null; + } + + /** + * @notice Return the current state of a legacy allocation + * @param _allocationID Allocation identifier + * @return LegacyAllocationState enum with the state of the allocation + */ + function _getLegacyAllocationState(address _allocationID) private view returns (LegacyAllocationState) { + LegacyAllocation storage alloc = __DEPRECATED_allocations[_allocationID]; + + if (alloc.indexer == address(0)) { + return LegacyAllocationState.Null; + } + + if (alloc.createdAtEpoch != 0 && alloc.closedAtEpoch == 0) { + return LegacyAllocationState.Active; + } + + return LegacyAllocationState.Closed; + } + /** * @notice Determines the correct callback function for `deleteItem` based on the request type. * @param _requestType The type of thaw request (Provision or Delegation). diff --git a/packages/horizon/contracts/staking/HorizonStakingStorage.sol b/packages/horizon/contracts/staking/HorizonStakingStorage.sol index aabe4d778..7f4818725 100644 --- a/packages/horizon/contracts/staking/HorizonStakingStorage.sol +++ b/packages/horizon/contracts/staking/HorizonStakingStorage.sol @@ -63,8 +63,9 @@ abstract contract HorizonStakingV1Storage { mapping(address serviceProvider => IHorizonStakingTypes.ServiceProviderInternal details) internal _serviceProviders; /// @dev Allocation details. - /// Deprecated, now applied on the subgraph data service - mapping(address allocationId => bytes32 __DEPRECATED_allocation) internal __DEPRECATED_allocations; + /// Deprecated, now applied on the subgraph data service. + /// Kept for storage compatibility and to check for allocation id collisions. + mapping(address allocationId => IHorizonStakingTypes.LegacyAllocation allocation) internal __DEPRECATED_allocations; /// @dev Subgraph allocations, tracks the tokens allocated to a subgraph deployment /// Deprecated, now applied on the SubgraphService diff --git a/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol b/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol index b8e569574..ccd242255 100644 --- a/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol +++ b/packages/horizon/test/unit/payments/graph-tally-collector/GraphTallyCollector.t.sol @@ -42,7 +42,7 @@ contract GraphTallyTest is HorizonStakingSharedTest, PaymentsEscrowSharedTest { * HELPERS */ - function _getSignerProof(uint256 _proofDeadline, uint256 _signer) internal view returns (bytes memory) { + function _getSignerProof(uint256 _proofDeadline, uint256 _signer) internal returns (bytes memory) { (, address msgSender, ) = vm.readCallers(); bytes32 messageHash = keccak256( abi.encodePacked( diff --git a/packages/horizon/test/unit/staking/legacy/isAllocation.t.sol b/packages/horizon/test/unit/staking/legacy/isAllocation.t.sol new file mode 100644 index 000000000..1f19ccc00 --- /dev/null +++ b/packages/horizon/test/unit/staking/legacy/isAllocation.t.sol @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IHorizonStakingTypes } from "@graphprotocol/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol"; +import { HorizonStakingSharedTest } from "../../shared/horizon-staking/HorizonStakingShared.t.sol"; + +contract HorizonStakingIsAllocationTest is HorizonStakingSharedTest { + /* + * TESTS + */ + + function test_IsAllocation_ReturnsFalse_WhenAllocationDoesNotExist() public { + address nonExistentAllocationId = makeAddr("nonExistentAllocation"); + assertFalse(staking.isAllocation(nonExistentAllocationId)); + } + + function test_IsAllocation_ReturnsTrue_WhenActiveAllocationExists() public { + address allocationId = makeAddr("activeAllocation"); + + // Set up an active legacy allocation in storage + _setLegacyAllocationInStaking( + allocationId, + users.indexer, + bytes32("subgraphDeploymentId"), + 1000 ether, // tokens + 1, // createdAtEpoch + 0 // closedAtEpoch (0 = still active) + ); + + assertTrue(staking.isAllocation(allocationId)); + } + + function test_IsAllocation_ReturnsTrue_WhenClosedAllocationExists() public { + address allocationId = makeAddr("closedAllocation"); + + // Set up a closed legacy allocation in storage + _setLegacyAllocationInStaking( + allocationId, + users.indexer, + bytes32("subgraphDeploymentId"), + 1000 ether, // tokens + 1, // createdAtEpoch + 10 // closedAtEpoch (non-zero = closed) + ); + + assertTrue(staking.isAllocation(allocationId)); + } + + function test_IsAllocation_ReturnsFalse_WhenIndexerIsZeroAddress() public { + address allocationId = makeAddr("zeroIndexerAllocation"); + + // Set up an allocation with zero indexer (should be considered Null) + _setLegacyAllocationInStaking( + allocationId, + address(0), // indexer is zero + bytes32("subgraphDeploymentId"), + 1000 ether, + 1, + 0 + ); + + assertFalse(staking.isAllocation(allocationId)); + } + + /* + * HELPERS + */ + + /** + * @notice Sets a legacy allocation directly in HorizonStaking storage + * @dev The __DEPRECATED_allocations mapping is at storage slot 10 in HorizonStakingStorage + * The LegacyAllocation struct has the following layout: + * - slot 0: indexer (address) + * - slot 1: subgraphDeploymentID (bytes32) + * - slot 2: tokens (uint256) + * - slot 3: createdAtEpoch (uint256) + * - slot 4: closedAtEpoch (uint256) + * - slot 5: collectedFees (uint256) + * - slot 6: __DEPRECATED_effectiveAllocation (uint256) + * - slot 7: accRewardsPerAllocatedToken (uint256) + * - slot 8: distributedRebates (uint256) + */ + function _setLegacyAllocationInStaking( + address _allocationId, + address _indexer, + bytes32 _subgraphDeploymentId, + uint256 _tokens, + uint256 _createdAtEpoch, + uint256 _closedAtEpoch + ) internal { + // Storage slot for __DEPRECATED_allocations mapping in HorizonStaking + // Use `forge inspect HorizonStaking storage-layout` to verify + uint256 allocationsSlot = 15; + bytes32 allocationBaseSlot = keccak256(abi.encode(_allocationId, allocationsSlot)); + + // Set indexer (slot 0) + vm.store(address(staking), allocationBaseSlot, bytes32(uint256(uint160(_indexer)))); + // Set subgraphDeploymentID (slot 1) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 1), _subgraphDeploymentId); + // Set tokens (slot 2) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 2), bytes32(_tokens)); + // Set createdAtEpoch (slot 3) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 3), bytes32(_createdAtEpoch)); + // Set closedAtEpoch (slot 4) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 4), bytes32(_closedAtEpoch)); + } +} diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol index ddc595409..1c87fee1e 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingMain.sol @@ -978,4 +978,13 @@ interface IHorizonStakingMain { * @return The amount of tokens withdrawn */ function forceWithdrawDelegated(address serviceProvider, address delegator) external returns (uint256); + + /** + * @notice Return if allocationID is used. + * @dev This function is used to check for allocation id collisions with legacy allocations + * that were created before the Horizon upgrade. + * @param allocationID Address used as signer by the indexer for an allocation + * @return True if allocationID already used + */ + function isAllocation(address allocationID) external view returns (bool); } diff --git a/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol b/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol index e8fff211b..22cdb5b4b 100644 --- a/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol +++ b/packages/interfaces/contracts/horizon/internal/IHorizonStakingTypes.sol @@ -200,4 +200,42 @@ interface IHorizonStakingTypes { uint256 tokensThawing; uint256 sharesThawing; } + + /** + * @notice Legacy allocation representation + * @dev Kept for storage compatibility and to check for allocation id collisions. + * @param indexer The indexer address + * @param subgraphDeploymentID The subgraph deployment ID + * @param tokens The amount of tokens allocated to the subgraph deployment + * @param createdAtEpoch The epoch when the allocation was created + * @param closedAtEpoch The epoch when the allocation was closed + * @param collectedFees The amount of collected fees for the allocation + * @param __DEPRECATED_effectiveAllocation Deprecated field + * @param accRewardsPerAllocatedToken Snapshot used for reward calculation + * @param distributedRebates The amount of collected rebates that have been rebated + */ + struct LegacyAllocation { + address indexer; + bytes32 subgraphDeploymentID; + uint256 tokens; + uint256 createdAtEpoch; + uint256 closedAtEpoch; + uint256 collectedFees; + uint256 __DEPRECATED_effectiveAllocation; + uint256 accRewardsPerAllocatedToken; + uint256 distributedRebates; + } + + /** + * @dev Possible states a legacy allocation can be. + * States: + * - Null = indexer == address(0) + * - Active = not Null && tokens > 0 + * - Closed = Active && closedAtEpoch != 0 + */ + enum LegacyAllocationState { + Null, + Active, + Closed + } } diff --git a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol index 7d78ca6d3..d45db0ae6 100644 --- a/packages/subgraph-service/contracts/libraries/AllocationHandler.sol +++ b/packages/subgraph-service/contracts/libraries/AllocationHandler.sol @@ -252,7 +252,7 @@ library AllocationHandler { // Ensure allocation id is not reused // need to check both subgraph service (on allocations.create()) and legacy allocations - _legacyAllocations.revertIfExists(params._allocationId); + _legacyAllocations.revertIfExists(params.graphStaking, params._allocationId); IAllocation.State memory allocation = _allocations.create( params._indexer, diff --git a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol index 151b15f08..746d124ee 100644 --- a/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol +++ b/packages/subgraph-service/contracts/libraries/LegacyAllocation.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later pragma solidity 0.8.33; +import { IHorizonStaking } from "@graphprotocol/interfaces/contracts/horizon/IHorizonStaking.sol"; import { ILegacyAllocation } from "@graphprotocol/interfaces/contracts/subgraph-service/internal/ILegacyAllocation.sol"; /** @@ -15,15 +16,21 @@ library LegacyAllocation { /** * @notice Revert if a legacy allocation exists - * @dev We check the migrated allocations mapping. + * @dev We check both the migrated allocations mapping and the legacy staking contract. * @param self The legacy allocation list mapping + * @param graphStaking The Horizon Staking contract * @param allocationId The allocation id */ function revertIfExists( mapping(address => ILegacyAllocation.State) storage self, + IHorizonStaking graphStaking, address allocationId ) internal view { require(!self[allocationId].exists(), ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId)); + require( + !graphStaking.isAllocation(allocationId), + ILegacyAllocation.LegacyAllocationAlreadyExists(allocationId) + ); } /** diff --git a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol index 74b0718bb..5002900f1 100644 --- a/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/SubgraphService.t.sol @@ -492,6 +492,45 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { assertEq(afterLegacyAllocation.subgraphDeploymentId, _subgraphDeploymentId); } + /** + * @notice Sets a legacy allocation directly in HorizonStaking storage + * @dev The __DEPRECATED_allocations mapping is at storage slot 15 in HorizonStaking + * Use `forge inspect HorizonStaking storage-layout` to verify + * The LegacyAllocation struct has the following layout: + * - slot 0: indexer (address) + * - slot 1: subgraphDeploymentID (bytes32) + * - slot 2: tokens (uint256) + * - slot 3: createdAtEpoch (uint256) + * - slot 4: closedAtEpoch (uint256) + * - slot 5: collectedFees (uint256) + * - slot 6: __DEPRECATED_effectiveAllocation (uint256) + * - slot 7: accRewardsPerAllocatedToken (uint256) + * - slot 8: distributedRebates (uint256) + */ + function _setLegacyAllocationInStaking( + address _allocationId, + address _indexer, + bytes32 _subgraphDeploymentId + ) internal { + // Storage slot for __DEPRECATED_allocations mapping in HorizonStaking + uint256 allocationsSlot = 15; + bytes32 allocationBaseSlot = keccak256(abi.encode(_allocationId, allocationsSlot)); + + // Set indexer (slot 0) + vm.store(address(staking), allocationBaseSlot, bytes32(uint256(uint160(_indexer)))); + // Set subgraphDeploymentID (slot 1) + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 1), _subgraphDeploymentId); + // Set tokens (slot 2) - non-zero to indicate active allocation + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 2), bytes32(uint256(1000 ether))); + // Set createdAtEpoch (slot 3) - non-zero + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 3), bytes32(uint256(1))); + // Set closedAtEpoch (slot 4) - non-zero to indicate closed + vm.store(address(staking), bytes32(uint256(allocationBaseSlot) + 4), bytes32(uint256(10))); + + // Verify the allocation is now visible via isAllocation + assertTrue(staking.isAllocation(_allocationId)); + } + /* * HELPERS */ 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 5617f4d7b..68c3c6674 100644 --- a/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/allocation/start.t.sol @@ -165,8 +165,9 @@ contract SubgraphServiceAllocationStartTest is SubgraphServiceTest { _createProvision(users.indexer, tokens, FISHERMAN_REWARD_PERCENTAGE, DISPUTE_PERIOD); _register(users.indexer, abi.encode("url", "geoHash", address(0))); - // simulate legacy allocation migration - _migrateLegacyAllocation(users.indexer, allocationId, subgraphDeployment); + // Set a legacy allocation directly in HorizonStaking storage + // This simulates an allocation that was created before Horizon and exists in the staking contract + _setLegacyAllocationInStaking(allocationId, users.indexer, subgraphDeployment); bytes memory data = _generateData(tokens); vm.expectRevert(abi.encodeWithSelector(ILegacyAllocation.LegacyAllocationAlreadyExists.selector, allocationId)); diff --git a/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol b/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol index 4915ac17f..76fae1307 100644 --- a/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/collect/query/query.t.sol @@ -21,7 +21,7 @@ contract SubgraphServiceRegisterTest is SubgraphServiceTest { * HELPERS */ - function _getSignerProof(uint256 _proofDeadline, uint256 _signer) private view returns (bytes memory) { + function _getSignerProof(uint256 _proofDeadline, uint256 _signer) private returns (bytes memory) { (, address msgSender, ) = vm.readCallers(); bytes32 messageHash = keccak256( abi.encodePacked( From b5585744f22961ec947bfa39681707835c538714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1s=20Migone?= Date: Mon, 23 Feb 2026 17:21:32 -0300 Subject: [PATCH 57/59] chore: natspec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Tomás Migone --- .../horizon/contracts/staking/HorizonStaking.sol | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/horizon/contracts/staking/HorizonStaking.sol b/packages/horizon/contracts/staking/HorizonStaking.sol index 7761af704..574111b32 100644 --- a/packages/horizon/contracts/staking/HorizonStaking.sol +++ b/packages/horizon/contracts/staking/HorizonStaking.sol @@ -496,6 +496,15 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { return _isAuthorized(serviceProvider, verifier, operator); } + /* + * LEGACY + */ + + /// @inheritdoc IHorizonStakingMain + function isAllocation(address allocationID) external view override returns (bool) { + return _getLegacyAllocationState(allocationID) != LegacyAllocationState.Null; + } + /* * PRIVATE FUNCTIONS */ @@ -1170,11 +1179,6 @@ contract HorizonStaking is HorizonStakingBase, IHorizonStakingMain { } } - /// @inheritdoc IHorizonStakingMain - function isAllocation(address allocationID) external view override returns (bool) { - return _getLegacyAllocationState(allocationID) != LegacyAllocationState.Null; - } - /** * @notice Return the current state of a legacy allocation * @param _allocationID Allocation identifier From fa6bd73692b078adeffabf8668d8ffae4888a3fd Mon Sep 17 00:00:00 2001 From: Rembrandt Kuipers <50174308+RembrandtK@users.noreply.github.com> Date: Tue, 24 Feb 2026 09:27:02 +0000 Subject: [PATCH 58/59] fixup! chore: add dips audit review doc --- docs/dips-issuance-merge/AUDIT-REVIEW.md | 121 ++++++++++++----------- 1 file changed, 62 insertions(+), 59 deletions(-) diff --git a/docs/dips-issuance-merge/AUDIT-REVIEW.md b/docs/dips-issuance-merge/AUDIT-REVIEW.md index 63d36f34e..892f61254 100644 --- a/docs/dips-issuance-merge/AUDIT-REVIEW.md +++ b/docs/dips-issuance-merge/AUDIT-REVIEW.md @@ -46,14 +46,15 @@ During the merge, interfaces were moved to the centralized `packages/interfaces/ ### Interface Relocations -| Old Path | New Path | -|---|---| -| `packages/horizon/contracts/interfaces/IRecurringCollector.sol` | `packages/interfaces/contracts/horizon/IRecurringCollector.sol` | -| `packages/subgraph-service/contracts/interfaces/IDisputeManager.sol` | `packages/interfaces/contracts/subgraph-service/IDisputeManager.sol` | -| `packages/subgraph-service/contracts/interfaces/ISubgraphService.sol` | `packages/interfaces/contracts/subgraph-service/ISubgraphService.sol` | -| `packages/horizon/contracts/data-service/interfaces/IDataServiceFees.sol` | `packages/interfaces/contracts/data-service/IDataServiceFees.sol` | +| Old Path | New Path | +| ------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| `packages/horizon/contracts/interfaces/IRecurringCollector.sol` | `packages/interfaces/contracts/horizon/IRecurringCollector.sol` | +| `packages/subgraph-service/contracts/interfaces/IDisputeManager.sol` | `packages/interfaces/contracts/subgraph-service/IDisputeManager.sol` | +| `packages/subgraph-service/contracts/interfaces/ISubgraphService.sol` | `packages/interfaces/contracts/subgraph-service/ISubgraphService.sol` | +| `packages/horizon/contracts/data-service/interfaces/IDataServiceFees.sol` | `packages/interfaces/contracts/data-service/IDataServiceFees.sol` | Notes: + - **IRecurringCollector.sol** (480 lines): byte-for-byte identical to the audited version. New file (no pre-existing version in the issuance-audit branch). - **IDisputeManager.sol**: `IndexingFeeDispute` enum variant, `createIndexingFeeDisputeV1`, events, and errors added to the centralized interface file. All additions are audited code with only `IndexingAgreement.` → `IIndexingAgreement.` type reference changes. - **ISubgraphService.sol**: `setIndexingFeesCut`, `acceptIndexingAgreement`, `updateIndexingAgreement`, `cancelIndexingAgreement`, `cancelIndexingAgreementByPayer`, `getIndexingAgreement` added to the centralized interface file. Same `IndexingAgreement.` → `IIndexingAgreement.` type reference changes. @@ -63,24 +64,24 @@ Notes: These files had conflicts only because both branches changed the pragma. Resolved to `0.8.27 || 0.8.33`. No other changes. -| File | -|---| -| `packages/horizon/contracts/data-service/extensions/DataServicePausable.sol` | +| File | +| ----------------------------------------------------------------------------- | +| `packages/horizon/contracts/data-service/extensions/DataServicePausable.sol` | | `packages/horizon/contracts/data-service/extensions/DataServiceRescuable.sol` | -| `packages/horizon/contracts/libraries/Denominations.sol` | +| `packages/horizon/contracts/libraries/Denominations.sol` | ### Import Path + Refactor (Non-Overlapping) These files had changes from both parents but they did not overlap. No merge-specific code was written. -| File | From indexing-payments | From issuance-audit | -|---|---|---| -| `DataServiceFees.sol` | Import paths to `@graphprotocol/interfaces` | Extracted private helpers to `StakeClaims` library | -| `DataServiceFeesStorage.sol` | Import `StakeClaims` instead of `IDataServiceFees` | `LinkedList.List` → `ILinkedList.List` | -| `ProvisionManager.sol` | Import paths, forge-lint comments | `onlyValidProvision` → `_requireValidProvision()`, extracted `_requireLTE()` | -| `DisputeManager.sol` | Added `IIndexingAgreement` import | Import path changes | -| `Directory.sol` | Added `IRecurringCollector` import | Import paths to `@graphprotocol/interfaces` | -| `SubgraphServiceStorage.sol` | Added `indexingFeesCut` storage variable | Inherited `ISubgraphService`, added `override` to getters | +| File | From indexing-payments | From issuance-audit | +| ---------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------- | +| `DataServiceFees.sol` | Import paths to `@graphprotocol/interfaces` | Extracted private helpers to `StakeClaims` library | +| `DataServiceFeesStorage.sol` | Import `StakeClaims` instead of `IDataServiceFees` | `LinkedList.List` → `ILinkedList.List` | +| `ProvisionManager.sol` | Import paths, forge-lint comments | `onlyValidProvision` → `_requireValidProvision()`, extracted `_requireLTE()` | +| `DisputeManager.sol` | Added `IIndexingAgreement` import | Import path changes | +| `Directory.sol` | Added `IRecurringCollector` import | Import paths to `@graphprotocol/interfaces` | +| `SubgraphServiceStorage.sol` | Added `indexingFeesCut` storage variable | Inherited `ISubgraphService`, added `override` to getters | --- @@ -93,6 +94,7 @@ These files contain lines that did not exist in either audited parent. **Total: This library was introduced in the indexing-payments branch. During the merge, reward logic from `AllocationManager.sol` was relocated into this library structure. **Relocated from `AllocationManager.sol` (faithful port, ~179 lines):** + - `POIPresented` event with `condition` field - `RewardsCondition`-based branching in `presentPOI` (STALE_POI, ZERO_POI, ALLOCATION_TOO_YOUNG, SUBGRAPH_DENIED) - Three reward paths: CLAIMED (takeRewards), RECLAIMED (reclaimRewards), DEFERRED (early return) @@ -101,6 +103,7 @@ This library was introduced in the indexing-payments branch. During the merge, r - All adapted from contract-style (`_graphStaking()`, `address(this)`) to library-style (`params.graphStaking`, `params.dataService`) **Genuinely new lines (~12):** + - `// Scoped for stack management` (2 comments) - Condensed natspec on `_closeAllocation` and `_distributeIndexingRewards` - `return (0, false)` instead of `return 0` (tuple adaptation for force-close return signature) @@ -134,28 +137,28 @@ These files are entirely new to the branch (not in main). They come from one of ### From indexing-payments branch (TRST-\* audited) -| File | Lines | Description | -|---|---|---| -| `packages/horizon/contracts/payments/collectors/RecurringCollector.sol` | 643 | Recurring payment collector contract | -| `packages/horizon/contracts/data-service/libraries/StakeClaims.sol` | 213 | Stake claim management library | -| `packages/subgraph-service/contracts/libraries/AllocationHandler.sol` | 600 | Allocation logic library (extracted from AllocationManager) | -| `packages/subgraph-service/contracts/libraries/IndexingAgreement.sol` | 803 | Indexing agreement lifecycle library | -| `packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol` | 101 | ABI decoder for indexing agreement metadata | -| `packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol` | 65 | Raw calldata decoder for indexing agreements | +| File | Lines | Description | +| ------------------------------------------------------------------------------- | ----- | ----------------------------------------------------------- | +| `packages/horizon/contracts/payments/collectors/RecurringCollector.sol` | 643 | Recurring payment collector contract | +| `packages/horizon/contracts/data-service/libraries/StakeClaims.sol` | 213 | Stake claim management library | +| `packages/subgraph-service/contracts/libraries/AllocationHandler.sol` | 600 | Allocation logic library (extracted from AllocationManager) | +| `packages/subgraph-service/contracts/libraries/IndexingAgreement.sol` | 803 | Indexing agreement lifecycle library | +| `packages/subgraph-service/contracts/libraries/IndexingAgreementDecoder.sol` | 101 | ABI decoder for indexing agreement metadata | +| `packages/subgraph-service/contracts/libraries/IndexingAgreementDecoderRaw.sol` | 65 | Raw calldata decoder for indexing agreements | ### From issuance-audit branch (audited) -| File | Lines | Description | -|---|---|---| -| `packages/interfaces/contracts/contracts/rewards/RewardsCondition.sol` | 54 | Rewards condition enum/types | -| `packages/interfaces/contracts/contracts/rewards/IRewardsManagerDeprecated.sol` | 40 | Deprecated rewards manager interface | -| `packages/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol` | 165 | Extracted allocation manager interface | +| File | Lines | Description | +| -------------------------------------------------------------------------------- | ----- | -------------------------------------- | +| `packages/interfaces/contracts/contracts/rewards/RewardsCondition.sol` | 54 | Rewards condition enum/types | +| `packages/interfaces/contracts/contracts/rewards/IRewardsManagerDeprecated.sol` | 40 | Deprecated rewards manager interface | +| `packages/interfaces/contracts/subgraph-service/internal/IAllocationManager.sol` | 165 | Extracted allocation manager interface | ### Created during merge -| File | Lines | Description | -|---|---|---| -| `packages/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol` | 39 | Types extracted from `IndexingAgreement.sol` library | +| File | Lines | Description | +| -------------------------------------------------------------------------------- | ----- | ---------------------------------------------------- | +| `packages/interfaces/contracts/subgraph-service/internal/IIndexingAgreement.sol` | 39 | Types extracted from `IndexingAgreement.sol` library | --- @@ -163,15 +166,15 @@ These files are entirely new to the branch (not in main). They come from one of 7 commits after the merge (3c1a2f11..c3f6f075). All mechanical — zero behavioral changes. -| Commit | Description | Non-test .sol files | Risk | -|---|---|---|---| -| `3c1a2f11` | Pragma widening to `0.8.27 \|\| 0.8.33`, import path centralization | StakeClaims.sol, imports.sol, RecurringCollector.sol | Mechanical | -| `26c6c445` | Extract types from `IndexingAgreement` lib to `IIndexingAgreement` interface | IndexingAgreement.sol, DisputeManager.sol, SubgraphService.sol, AllocationHandler.sol, AllocationManager.sol | Structural (type move, no logic) | -| `9742dd80` | Rename test constants to UPPER_SNAKE_CASE | 0 (test-only) | N/A | -| `fa57fbc0` | Update expected errors and assertions in tests | 0 (test-only) | N/A | -| `89c70100` | Add `--ir-minimum` flag to forge coverage | 0 (config-only) | N/A | -| `4450e9c2` | Add forge-lint disable comments for RCA/POI acronyms | SubgraphService.sol, AllocationHandler.sol, IndexingAgreement.sol, IndexingAgreementDecoder.sol, IndexingAgreementDecoderRaw.sol | Mechanical (comments only) | -| `c3f6f075` | Solhint suppression, natspec `@dev`→`@notice`, `memory`→`calldata` on `getCollectionInfo` | RecurringCollector.sol, IRecurringCollector.sol, IDisputeManager.sol, ISubgraphService.sol | Mechanical | +| Commit | Description | Non-test .sol files | Risk | +| ---------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | +| `3c1a2f11` | Pragma widening to `0.8.27 \|\| 0.8.33`, import path centralization | StakeClaims.sol, imports.sol, RecurringCollector.sol | Mechanical | +| `26c6c445` | Extract types from `IndexingAgreement` lib to `IIndexingAgreement` interface | IndexingAgreement.sol, DisputeManager.sol, SubgraphService.sol, AllocationHandler.sol, AllocationManager.sol | Structural (type move, no logic) | +| `9742dd80` | Rename test constants to UPPER_SNAKE_CASE | 0 (test-only) | N/A | +| `fa57fbc0` | Update expected errors and assertions in tests | 0 (test-only) | N/A | +| `89c70100` | Add `--ir-minimum` flag to forge coverage | 0 (config-only) | N/A | +| `4450e9c2` | Add forge-lint disable comments for RCA/POI acronyms | SubgraphService.sol, AllocationHandler.sol, IndexingAgreement.sol, IndexingAgreementDecoder.sol, IndexingAgreementDecoderRaw.sol | Mechanical (comments only) | +| `c3f6f075` | Solhint suppression, natspec `@dev`→`@notice`, `memory`→`calldata` on `getCollectionInfo` | RecurringCollector.sol, IRecurringCollector.sol, IDisputeManager.sol, ISubgraphService.sol | Mechanical | The only non-comment code change across all 7 commits: `getCollectionInfo` parameter changed from `AgreementData memory` to `AgreementData calldata` (gas optimization, ABI-compatible). @@ -179,28 +182,28 @@ The only non-comment code change across all 7 commits: `getCollectionInfo` param Conflict resolution: -| File | Resolution | -|---|---| -| `AllocationManager.sol` | Resolved to the thin wrapper version. The stale reclaim logic (TRST-R-1) was placed in `AllocationHandler.resizeAllocation()` instead of inline, passing `maxPOIStaleness` as a new parameter. | -| `resize.t.sol` | Resolved with `AllocationHandler` import for error selectors and added `IAllocation` import. Two new test functions (`StaleAllocation_ReclaimsPending`, `NotStale_PreservesPending`) auto-merged cleanly. | +| File | Resolution | +| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `AllocationManager.sol` | Resolved to the thin wrapper version. The stale reclaim logic (TRST-R-1) was placed in `AllocationHandler.resizeAllocation()` instead of inline, passing `maxPOIStaleness` as a new parameter. | +| `resize.t.sol` | Resolved with `AllocationHandler` import for error selectors and added `IAllocation` import. Two new test functions (`StaleAllocation_ReclaimsPending`, `NotStale_PreservesPending`) auto-merged cleanly. | Auto-merged files (verified correct): -| File | Description | -|---|---| +| File | Description | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `MockRewardsManager.sol` | `calcRewards` now returns `(_accRewardsPerAllocatedToken * _tokens) / FIXED_POINT_SCALING_FACTOR`. `reclaimRewards` destructures all 6 return values from `getAllocationData` and computes `accRewardsPending + newRewards`. | -| `SubgraphService.t.sol` | Pending rewards assertion updated to include `beforeAllocation.accRewardsPending`. | +| `SubgraphService.t.sol` | Pending rewards assertion updated to include `beforeAllocation.accRewardsPending`. | --- ## Summary -| Category | Files | New Logic Lines | -|---|---|---| -| Relocated interfaces (path moves) | 4 | 0 | -| Pragma-only conflicts | 3 | 0 | -| Non-overlapping import/refactor merges | 6 | 0 | -| Genuinely new lines (first merge resolution) | 4 | ~15 (natspec, comments, boilerplate) | -| Post-merge commits | 13 non-test .sol files across 7 commits | 0 (1 `memory`→`calldata` change) | -| Second merge (stale reclaim port) | 2 | ~5 (param plumbing, natspec, forge-lint comment) | -| **Total new business logic** | | **0** | +| Category | Files | New Logic Lines | +| -------------------------------------------- | --------------------------------------- | ------------------------------------------------ | +| Relocated interfaces (path moves) | 4 | 0 | +| Pragma-only conflicts | 3 | 0 | +| Non-overlapping import/refactor merges | 6 | 0 | +| Genuinely new lines (first merge resolution) | 4 | ~15 (natspec, comments, boilerplate) | +| Post-merge commits | 13 non-test .sol files across 7 commits | 0 (1 `memory`→`calldata` change) | +| Second merge (stale reclaim port) | 2 | ~5 (param plumbing, natspec, forge-lint comment) | +| **Total new business logic** | | **0** | From 180b0b133f720c3a8de84b9d623a78828a0bfe44 Mon Sep 17 00:00:00 2001 From: Miguel de Elias Date: Thu, 26 Feb 2026 09:53:37 -0300 Subject: [PATCH 59/59] chore: add indexing payments audit report --- .../audits/2025-06-Indexing-Payments.pdf | Bin 0 -> 622268 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/horizon/audits/2025-06-Indexing-Payments.pdf diff --git a/packages/horizon/audits/2025-06-Indexing-Payments.pdf b/packages/horizon/audits/2025-06-Indexing-Payments.pdf new file mode 100644 index 0000000000000000000000000000000000000000..bd5325dcac3e38d89634d356e4c0e2de2a1eb413 GIT binary patch literal 622268 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

5Lo(WE?zu5S+X`%O># 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ᗰ$S*2W7~Gul222vfR%X0udxX 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 literal 0 HcmV?d00001