diff --git a/IndexingPaymentsTodo.md b/IndexingPaymentsTodo.md new file mode 100644 index 000000000..0f0eec68c --- /dev/null +++ b/IndexingPaymentsTodo.md @@ -0,0 +1,44 @@ +# Still pending + +* Arbitration Charter: Update to support disputing IndexingFee. +* Check code coverage +* Check contract size +* Don't love cancel agreement on stop service / close stale allocation. + +# Done + +* DONE: ~~Switch cancel event in recurring collector to use Enum~~ +* DONE: ~~Switch timestamps to uint64~~ +* DONE: ~~Check that UUID-v4 fits in `bytes16`~~ +* DONE: ~~Double check cancelation policy. Who can cancel when? Right now is either party at any time. Answer: If gateway cancels allow collection till that point.~~ +* DONE: ~~If an indexer closes an allocation, what should happen to the accepeted agreement? Answer: Look into canceling agreement as part of stop service.~~ +* DONE: ~~Switch `duration` for `endsAt`? Answer: Do it.~~ +* DONE: ~~Support a way for gateway to shop an agreement around? Deadline + dedup key? So only one agreement with the dedupe key can be accepted? Answer: No. Agreements will be "signaled" as approved or rejected on the API call that sends the agreement. We'll trust (and verify) that that's the case.~~ +* DONE: ~~Test `upgrade` paths~~ +* DONE: ~~Fix upgrade.t.sol, lots of comments~~ +* DONE: ~~How do we solve for the case where an indexer has reached their max expected payout for the initial sync but haven't reached the current epoch (thus their POI is incorrect)? Answer: Signal in the event that the max amount was collected, so that fisherman understand the case.~~ +* DONE: ~~Debate epoch check protocol team. Maybe don't revert but store it in event. Pablo suggest block number instead of epoch.~~ +* DONE: ~~Should we set a different param for initial collection time max? Some subgraphs take a lot to catch up. Answer: Do nothing. Make sure that zero POIs allow to eventually sync~~ +* DONE: ~~Since an allocation is required for collecting, do we want to expect that the allocation is not stale? Do we want to add code to collect rewards as part of the collection of fees? Make sure allocation is more than one epoch old if we attempt this. Answer: Ignore stale allocation~~ +* DONE: ~~If service wants to collect more than collector allows. Collector limits but doesn't tell the service? Currently reverts. Answer: Allow for max allowed~~ +* DONE: ~~What should happen if the escrow doesn't have enough funds? Answer: Reverts~~ +* DONE: ~~Don't pay for entities on initial collection? Where did we land in terms of payment terms? Answer: pay initial~~ +* DONE: ~~Test lock stake~~ +* DONE: ~~Reduce the number of errors declared and returned~~ +* DONE: ~~Support `DisputeManager`~~ +* DONE: ~~Check upgrade conditions. Support indexing agreement upgradability, so that there is a mechanism to adjust the rates without having to cancel and start over.~~ +* DONE: ~~Maybe check that the epoch the indexer is sending is the one the transaction will be run in?~~ +* DONE: ~~Should we deal with zero entities declared as a special case?~~ +* DONE: ~~Support for agreements that end up in `RecurringCollectorCollectionTooLate` or ways to avoid getting to that state.~~ +* DONE: ~~Make `agreementId` unique globally so that we don't need the full tuple (`payer`+`indexer`+`agreementId`) as key?~~ +* DONE: ~~Maybe IRecurringCollector.cancel(address payer, address serviceProvider, bytes16 agreementId) should only take in agreementId?~~ +* DONE: ~~Unify to one error in Decoder.sol~~ +* DONE: ~~Built-in upgrade path to indexing agreements v2~~ +* DONE: ~~Missing events for accept, cancel, upgrade RCAs.~~ + +# Won't Fix + +* Add upgrade path to v2 collector terms +* Expose a function that indexers can use to calculate the tokens to be collected and other collection params? +* Place all agreement terms into one struct +* It's more like a collect + cancel since the indexer is expected to stop work then and there. When posting a POI that's < N-1 epoch. Answer: Emit signal that the collection is meant to be final. Counter: Won't do since collector can't signal back to data service that payment is maxed out. Could emit an event from the collector, but is it really worth it? Right now any collection where epoch POI < current POI is suspect. diff --git a/packages/horizon/contracts/interfaces/IRecurringCollector.sol b/packages/horizon/contracts/interfaces/IRecurringCollector.sol new file mode 100644 index 000000000..5a6badd42 --- /dev/null +++ b/packages/horizon/contracts/interfaces/IRecurringCollector.sol @@ -0,0 +1,347 @@ +// 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 { + enum AgreementState { + NotAccepted, + Accepted, + CanceledByServiceProvider, + CanceledByPayer + } + + enum CancelAgreementBy { + ServiceProvider, + Payer + } + + /// @notice A representation of a signed Recurring Collection Agreement (RCA) + struct SignedRCA { + // The RCA + RecurringCollectionAgreement rca; + // Signature - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) + bytes signature; + } + + /// @notice The Recurring Collection Agreement (RCA) + struct RecurringCollectionAgreement { + // The agreement ID of the RCA + bytes16 agreementId; + // The deadline for accepting the RCA + uint64 deadline; + // The timestamp when the agreement ends + uint64 endsAt; + // The address of the payer the RCA was issued by + address payer; + // The address of the data service the RCA was issued to + address dataService; + // The address of the service provider the RCA was issued to + address serviceProvider; + // The maximum amount of tokens that can be collected in the first collection + // on top of the amount allowed for subsequent collections + uint256 maxInitialTokens; + // The maximum amount of tokens that can be collected per second + // except for the first collection + uint256 maxOngoingTokensPerSecond; + // The minimum amount of seconds that must pass between collections + uint32 minSecondsPerCollection; + // The maximum amount of seconds that can pass between collections + uint32 maxSecondsPerCollection; + // Arbitrary metadata to extend functionality if a data service requires it + bytes metadata; + } + + /// @notice A representation of a signed Recurring Collection Agreement Upgrade (RCAU) + struct SignedRCAU { + // The RCAU + RecurringCollectionAgreementUpgrade rcau; + // Signature - 65 bytes: r (32 Bytes) || s (32 Bytes) || v (1 Byte) + bytes signature; + } + + struct RecurringCollectionAgreementUpgrade { + // The agreement ID + bytes16 agreementId; + // The deadline for upgrading + uint64 deadline; + // The timestamp when the agreement ends + uint64 endsAt; + // The maximum amount of tokens that can be collected in the first collection + // on top of the amount allowed for subsequent collections + uint256 maxInitialTokens; + // The maximum amount of tokens that can be collected per second + // except for the first collection + uint256 maxOngoingTokensPerSecond; + // The minimum amount of seconds that must pass between collections + uint32 minSecondsPerCollection; + // The maximum amount of seconds that can pass between collections + uint32 maxSecondsPerCollection; + // Arbitrary metadata to extend functionality if a data service requires it + bytes metadata; + } + + /// @notice The data for an agreement + struct AgreementData { + // The address of the data service + address dataService; + // The address of the payer + address payer; + // The address of the service provider + address serviceProvider; + // The timestamp when the agreement was accepted + uint64 acceptedAt; + // The timestamp when the agreement was last collected at + uint64 lastCollectionAt; + // The timestamp when the agreement ends + uint64 endsAt; + // The maximum amount of tokens that can be collected in the first collection + // on top of the amount allowed for subsequent collections + uint256 maxInitialTokens; + // The maximum amount of tokens that can be collected per second + // except for the first collection + uint256 maxOngoingTokensPerSecond; + // The minimum amount of seconds that must pass between collections + uint32 minSecondsPerCollection; + // The maximum amount of seconds that can pass between collections + uint32 maxSecondsPerCollection; + // The timestamp when the agreement was canceled + uint64 canceledAt; + // The state of the agreement + AgreementState state; + } + + /// @notice The params for collecting an agreement + struct CollectParams { + bytes16 agreementId; + // The collection ID + bytes32 collectionId; + // The amount of tokens to collect + uint256 tokens; + // The data service cut in PPM + uint256 dataServiceCut; + } + + /** + * @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 upgraded + * @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 upgradedAt The timestamp when the agreement was upgraded + * @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 AgreementUpgraded( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + uint64 upgradedAt, + 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 + */ + event RCACollected( + address indexed dataService, + address indexed payer, + address indexed serviceProvider, + bytes16 agreementId, + bytes32 collectionId, + uint256 tokens, + uint256 dataServiceCut + ); + + /** + * Thrown when accepting an agreement with a zero ID + */ + error RecurringCollectorAgreementIdZero(); + + /** + * 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); + + /** + * Thrown when interacting with an agreement with an elapsed deadline + * @param deadline The elapsed deadline timestamp + */ + error RecurringCollectorAgreementDeadlineElapsed(uint64 deadline); + + /** + * Thrown when the signer is invalid + */ + error RecurringCollectorInvalidSigner(); + + /** + * Thrown when the payment type is not IndexingFee + * @param invalidPaymentType The invalid payment type + */ + error RecurringCollectorInvalidPaymentType(IGraphPayments.PaymentTypes invalidPaymentType); + + /** + * 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); + + /** + * Thrown when calling collect() with invalid data + * @param invalidData The invalid data + */ + error RecurringCollectorInvalidCollectData(bytes invalidData); + + /** + * 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); + + /** + * Thrown when accepting or upgrading an agreement with invalid parameters + */ + error RecurringCollectorAgreementInvalidParameters(string message); + + /** + * Thrown when calling collect() on an elapsed agreement + * @param agreementId The agreement ID + * @param endsAt The agreement end timestamp + */ + error RecurringCollectorAgreementElapsed(bytes16 agreementId, uint64 endsAt); + + /** + * 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); + + /** + * 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 Upgrade an indexing agreement. + */ + function upgrade(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 encodeRCA(RecurringCollectionAgreement calldata rca) external view returns (bytes32); + + /** + * @dev Computes the hash of a RecurringCollectionAgreementUpgrade (RCAU). + * @param rcau The RCAU for which to compute the hash. + * @return The hash of the RCAU. + */ + function encodeRCAU(RecurringCollectionAgreementUpgrade 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 RecurringCollectionAgreementUpgrade (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. + */ + function getAgreement(bytes16 agreementId) external view returns (AgreementData memory); +} diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol new file mode 100644 index 000000000..2d0578a15 --- /dev/null +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -0,0 +1,472 @@ +// 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 { Authorizable } from "../../utilities/Authorizable.sol"; +import { GraphDirectory } from "../../utilities/GraphDirectory.sol"; +import { IRecurringCollector } from "../../interfaces/IRecurringCollector.sol"; +import { IGraphPayments } from "../../interfaces/IGraphPayments.sol"; +import { PPMMath } from "../../libraries/PPMMath.sol"; +import { MathUtils } from "../../libraries/MathUtils.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 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 RecurringCollectionAgreementUpgrade struct + bytes32 public constant EIP712_RCAU_TYPEHASH = + keccak256( + "RecurringCollectionAgreementUpgrade(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) {} + + /** + * @notice Initiate a payment collection through the payments protocol. + * See {IGraphPayments.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); + } + } + + /** + * @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(signedRCA.rca.deadline) + ); + + // check that the voucher is signed by the payer (or proxy) + _requireAuthorizedRCASigner(signedRCA); + + AgreementData storage agreement = _getForUpdateAgreement(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; + _requireValidAgreement(agreement); + + emit AgreementAccepted( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + signedRCA.rca.agreementId, + agreement.acceptedAt, + agreement.endsAt, + agreement.maxInitialTokens, + agreement.maxOngoingTokensPerSecond, + agreement.minSecondsPerCollection, + agreement.maxSecondsPerCollection + ); + } + + /** + * @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 = _getForUpdateAgreement(agreementId); + require( + agreement.state == AgreementState.Accepted, + RecurringCollectorAgreementIncorrectState(agreementId, agreement.state) + ); + require( + agreement.dataService == msg.sender, + RecurringCollectorDataServiceNotAuthorized(agreementId, msg.sender) + ); + agreement.canceledAt = uint64(block.timestamp); + agreement.state = by == CancelAgreementBy.Payer + ? AgreementState.CanceledByPayer + : AgreementState.CanceledByServiceProvider; + + emit AgreementCanceled( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + agreementId, + agreement.canceledAt, + by + ); + } + + /** + * @notice Upgrade an indexing agreement. + * See {IRecurringCollector.upgrade}. + * @dev Caller must be the data service for the agreement. + */ + function upgrade(SignedRCAU calldata signedRCAU) external { + require( + signedRCAU.rcau.deadline >= block.timestamp, + RecurringCollectorAgreementDeadlineElapsed(signedRCAU.rcau.deadline) + ); + + AgreementData storage agreement = _getForUpdateAgreement(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); + + // upgrade 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; + _requireValidAgreement(agreement); + + emit AgreementUpgraded( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + signedRCAU.rcau.agreementId, + uint64(block.timestamp), + agreement.endsAt, + agreement.maxInitialTokens, + agreement.maxOngoingTokensPerSecond, + agreement.minSecondsPerCollection, + agreement.maxSecondsPerCollection + ); + } + + /** + * @notice See {IRecurringCollector.recoverRCASigner} + */ + function recoverRCASigner(SignedRCA calldata signedRCA) external view returns (address) { + return _recoverRCASigner(signedRCA); + } + + /** + * @notice See {IRecurringCollector.recoverRCAUSigner} + */ + function recoverRCAUSigner(SignedRCAU calldata signedRCAU) external view returns (address) { + return _recoverRCAUSigner(signedRCAU); + } + + /** + * @notice See {IRecurringCollector.encodeRCA} + */ + function encodeRCA(RecurringCollectionAgreement calldata rca) external view returns (bytes32) { + return _encodeRCA(rca); + } + + /** + * @notice See {IRecurringCollector.encodeRCAU} + */ + function encodeRCAU(RecurringCollectionAgreementUpgrade calldata rcau) external view returns (bytes32) { + return _encodeRCAU(rcau); + } + + /** + * @notice See {IRecurringCollector.getAgreement} + */ + function getAgreement(bytes16 agreementId) external view returns (AgreementData memory) { + return _getAgreement(agreementId); + } + + /** + * @notice Decodes the collect data. + */ + 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 = _getForUpdateAgreement(_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) + ); + + require( + agreement.endsAt >= block.timestamp, + RecurringCollectorAgreementElapsed(_params.agreementId, agreement.endsAt) + ); + + 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 + ); + } + 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; + } + + function _requireValidAgreement(AgreementData memory _agreement) private view { + require( + _agreement.dataService != address(0) && + _agreement.payer != address(0) && + _agreement.serviceProvider != address(0), + RecurringCollectorAgreementInvalidParameters("zero address") + ); + + // Agreement needs to end in the future + require( + _agreement.endsAt > block.timestamp, + RecurringCollectorAgreementInvalidParameters("endsAt not in future") + ); + + // Collection window needs to be at least 2 hours + require( + _agreement.maxSecondsPerCollection > _agreement.minSecondsPerCollection && + (_agreement.maxSecondsPerCollection - _agreement.minSecondsPerCollection >= 7200), + RecurringCollectorAgreementInvalidParameters("too small collection window") + ); + + // Agreement needs to last at least one min collection window + require( + _agreement.endsAt - block.timestamp >= _agreement.minSecondsPerCollection + 7200, + RecurringCollectorAgreementInvalidParameters("too small agreement window") + ); + } + + /** + * @notice Requires that the collection params are valid. + */ + function _requireValidCollect( + AgreementData memory _agreement, + bytes16 _agreementId, + uint256 _tokens + ) private view returns (uint256) { + uint256 collectionSeconds = _agreement.state == AgreementState.CanceledByPayer + ? _agreement.canceledAt + : block.timestamp; + collectionSeconds -= _agreementCollectionStartAt(_agreement); + 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 MathUtils.min(_tokens, maxTokens); + } + + /** + * @notice See {IRecurringCollector.recoverRCASigner} + */ + function _recoverRCASigner(SignedRCA memory _signedRCA) private view returns (address) { + bytes32 messageHash = _encodeRCA(_signedRCA.rca); + return ECDSA.recover(messageHash, _signedRCA.signature); + } + + /** + * @notice See {IRecurringCollector.recoverRCAUSigner} + */ + function _recoverRCAUSigner(SignedRCAU memory _signedRCAU) private view returns (address) { + bytes32 messageHash = _encodeRCAU(_signedRCAU.rcau); + return ECDSA.recover(messageHash, _signedRCAU.signature); + } + + /** + * @notice See {IRecurringCollector.encodeRCA} + */ + function _encodeRCA(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 {IRecurringCollector.encodeRCAU} + */ + function _encodeRCAU(RecurringCollectionAgreementUpgrade 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. + */ + 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. + */ + 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. + */ + function _getForUpdateAgreement(bytes16 _agreementId) private view returns (AgreementData storage) { + return agreements[_agreementId]; + } + + /** + * @notice See {IRecurringCollector.getAgreement} + */ + function _getAgreement(bytes16 _agreementId) private view returns (AgreementData memory) { + return agreements[_agreementId]; + } + + 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 1cd779abe..87b4acbc2 100644 --- a/packages/horizon/package.json +++ b/packages/horizon/package.json @@ -13,9 +13,10 @@ "scripts": { "lint": "yarn lint:ts && yarn lint:sol", "lint:ts": "eslint '**/*.{js,ts}' --fix", - "lint:sol": "yarn lint:sol:prettier && yarn lint:sol:solhint", + "lint:sol": "yarn lint:sol:prettier && yarn lint:sol:solhint && yarn 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/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": "BUILD_RUN=true hardhat compile", diff --git a/packages/horizon/test/payments/recurring-collector/PaymentsEscrowMock.t.sol b/packages/horizon/test/payments/recurring-collector/PaymentsEscrowMock.t.sol new file mode 100644 index 000000000..c1cd08527 --- /dev/null +++ b/packages/horizon/test/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) 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/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol b/packages/horizon/test/payments/recurring-collector/RecurringCollectorAuthorizableTest.t.sol new file mode 100644 index 000000000..9a9ea3fff --- /dev/null +++ b/packages/horizon/test/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 "../../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/payments/recurring-collector/RecurringCollectorControllerMock.t.sol b/packages/horizon/test/payments/recurring-collector/RecurringCollectorControllerMock.t.sol new file mode 100644 index 000000000..904b31b8b --- /dev/null +++ b/packages/horizon/test/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/payments/recurring-collector/RecurringCollectorHelper.t.sol b/packages/horizon/test/payments/recurring-collector/RecurringCollectorHelper.t.sol new file mode 100644 index 000000000..b949184a9 --- /dev/null +++ b/packages/horizon/test/payments/recurring-collector/RecurringCollectorHelper.t.sol @@ -0,0 +1,141 @@ +// 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 "../../utilities/Authorizable.t.sol"; +import { Bounder } from "../../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.encodeRCA(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.RecurringCollectionAgreementUpgrade memory rcau, + uint256 signerPrivateKey + ) public view returns (IRecurringCollector.SignedRCAU memory) { + bytes32 messageHash = collector.encodeRCAU(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.RecurringCollectionAgreementUpgrade memory rcau + ) public view returns (IRecurringCollector.RecurringCollectionAgreementUpgrade 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 + 7200)); // between now and 2h + } + + 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 _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 + } + + function _sensibleMaxSecondsPerCollection( + uint32 _seed, + uint32 _minSecondsPerCollection + ) internal pure returns (uint32) { + return + uint32( + bound(_seed, _minSecondsPerCollection + 7200, 60 * 60 * 24 * 30) // between minSecondsPerCollection + 2h and 30 days + ); + } +} diff --git a/packages/horizon/test/payments/recurring-collector/accept.t.sol b/packages/horizon/test/payments/recurring-collector/accept.t.sol new file mode 100644 index 000000000..8df9bebca --- /dev/null +++ b/packages/horizon/test/payments/recurring-collector/accept.t.sol @@ -0,0 +1,50 @@ +// 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, + 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/payments/recurring-collector/cancel.t.sol b/packages/horizon/test/payments/recurring-collector/cancel.t.sol new file mode 100644 index 000000000..d6dbfa838 --- /dev/null +++ b/packages/horizon/test/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/payments/recurring-collector/collect.t.sol b/packages/horizon/test/payments/recurring-collector/collect.t.sol new file mode 100644 index 000000000..ec7881d89 --- /dev/null +++ b/packages/horizon/test/payments/recurring-collector/collect.t.sol @@ -0,0 +1,296 @@ +// 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 { + uint256 lastPaymentType = uint256(IGraphPayments.PaymentTypes.IndexingRewards); + + IGraphPayments.PaymentTypes paymentType = IGraphPayments.PaymentTypes( + bound(unboundedPaymentType, 0, lastPaymentType) + ); + vm.assume(paymentType != IGraphPayments.PaymentTypes.IndexingFee); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidPaymentType.selector, + paymentType + ); + vm.expectRevert(expectedErr); + _recurringCollector.collect(paymentType, data); + + // If I move this to the top of the function, the rest of the test does not run. Not sure why... + { + vm.expectRevert(); + IGraphPayments.PaymentTypes(lastPaymentType + 1); + } + } + + function test_Collect_Revert_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_WhenAgreementElapsed( + FuzzyTestCollect calldata fuzzy, + uint256 unboundedCollectionSeconds + ) public { + (IRecurringCollector.SignedRCA memory accepted, ) = _sensibleAuthorizeAndAccept(fuzzy.fuzzyTestAccept); + + // skip to sometime after agreement elapsed + skip(boundSkipFloor(unboundedCollectionSeconds, accepted.rca.endsAt - block.timestamp + 1)); + + IRecurringCollector.CollectParams memory collectParams = fuzzy.collectParams; + collectParams.agreementId = accepted.rca.agreementId; + + bytes memory data = _generateCollectData(collectParams); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementElapsed.selector, + collectParams.agreementId, + accepted.rca.endsAt + ); + 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 = boundSkipCeil(unboundedCollectionSeconds, 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/payments/recurring-collector/shared.t.sol b/packages/horizon/test/payments/recurring-collector/shared.t.sol new file mode 100644 index 000000000..cb6b6246f --- /dev/null +++ b/packages/horizon/test/payments/recurring-collector/shared.t.sol @@ -0,0 +1,193 @@ +// 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 "../../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 FuzzyTestUpgrade { + FuzzyTestAccept fuzzyTestAccept; + IRecurringCollector.RecurringCollectionAgreementUpgrade 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 + ) + ) + ); + 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); + } + + // Do I need this? + 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 + }); + } + + 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/payments/recurring-collector/upgrade.t.sol b/packages/horizon/test/payments/recurring-collector/upgrade.t.sol new file mode 100644 index 000000000..e8667f0c0 --- /dev/null +++ b/packages/horizon/test/payments/recurring-collector/upgrade.t.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.27; + +import { IRecurringCollector } from "../../../contracts/interfaces/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; + +contract RecurringCollectorUpgradeTest is RecurringCollectorSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + + function test_Upgrade_Revert_WhenUpgradeElapsed( + IRecurringCollector.RecurringCollectionAgreement memory rca, + IRecurringCollector.RecurringCollectionAgreementUpgrade memory rcau, + uint256 unboundedUpgradeSkip + ) public { + rca = _recurringCollectorHelper.sensibleRCA(rca); + rcau = _recurringCollectorHelper.sensibleRCAU(rcau); + rcau.agreementId = rca.agreementId; + + boundSkipCeil(unboundedUpgradeSkip, 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, + rcau.deadline + ); + vm.expectRevert(expectedErr); + vm.prank(rca.dataService); + _recurringCollector.upgrade(signedRCAU); + } + + function test_Upgrade_Revert_WhenNeverAccepted( + IRecurringCollector.RecurringCollectionAgreement memory rca, + IRecurringCollector.RecurringCollectionAgreementUpgrade 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.upgrade(signedRCAU); + } + + function test_Upgrade_Revert_WhenDataServiceNotAuthorized( + FuzzyTestUpgrade calldata fuzzyTestUpgrade, + address notDataService + ) public { + vm.assume(fuzzyTestUpgrade.fuzzyTestAccept.rca.dataService != notDataService); + (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( + fuzzyTestUpgrade.fuzzyTestAccept + ); + + IRecurringCollector.RecurringCollectionAgreementUpgrade memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpgrade.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.upgrade(signedRCAU); + } + + function test_Upgrade_Revert_WhenInvalidSigner( + FuzzyTestUpgrade calldata fuzzyTestUpgrade, + uint256 unboundedInvalidSignerKey + ) public { + (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( + fuzzyTestUpgrade.fuzzyTestAccept + ); + uint256 invalidSignerKey = boundKey(unboundedInvalidSignerKey); + vm.assume(signerKey != invalidSignerKey); + + IRecurringCollector.RecurringCollectionAgreementUpgrade memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpgrade.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.upgrade(signedRCAU); + } + + function test_Upgrade_OK(FuzzyTestUpgrade calldata fuzzyTestUpgrade) public { + (IRecurringCollector.SignedRCA memory accepted, uint256 signerKey) = _sensibleAuthorizeAndAccept( + fuzzyTestUpgrade.fuzzyTestAccept + ); + IRecurringCollector.RecurringCollectionAgreementUpgrade memory rcau = _recurringCollectorHelper.sensibleRCAU( + fuzzyTestUpgrade.rcau + ); + rcau.agreementId = accepted.rca.agreementId; + IRecurringCollector.SignedRCAU memory signedRCAU = _recurringCollectorHelper.generateSignedRCAU( + rcau, + signerKey + ); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementUpgraded( + 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.upgrade(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/utilities/Authorizable.t.sol b/packages/horizon/test/utilities/Authorizable.t.sol index c31721af8..b7abc0eff 100644 --- a/packages/horizon/test/utilities/Authorizable.t.sol +++ b/packages/horizon/test/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/utils/Bounder.t.sol b/packages/horizon/test/utils/Bounder.t.sol index 44e977f57..9b95a3425 100644 --- a/packages/horizon/test/utils/Bounder.t.sol +++ b/packages/horizon/test/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/Decoder.sol b/packages/subgraph-service/contracts/Decoder.sol new file mode 100644 index 000000000..0e842a1d7 --- /dev/null +++ b/packages/subgraph-service/contracts/Decoder.sol @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity 0.8.27; + +import { ISubgraphService } from "./interfaces/ISubgraphService.sol"; + +contract Decoder { + function decodeCollectIndexingFeeData(bytes calldata data) external pure returns (bytes16, bytes memory) { + return abi.decode(data, (bytes16, bytes)); + } + + /** + * @notice Decodes the RCA metadata. + * + * @param data The data to decode. See {ISubgraphService.AcceptIndexingAgreementMetadata} + * @return The decoded data + */ + function decodeRCAMetadata( + bytes calldata data + ) external pure returns (ISubgraphService.AcceptIndexingAgreementMetadata memory) { + return abi.decode(data, (ISubgraphService.AcceptIndexingAgreementMetadata)); + } + + /** + * @notice Decodes the RCAU metadata. + * + * @param data The data to decode. See {ISubgraphService.UpgradeIndexingAgreementMetadata} + * @return The decoded data + */ + function decodeRCAUMetadata( + bytes calldata data + ) external pure returns (ISubgraphService.UpgradeIndexingAgreementMetadata memory) { + return abi.decode(data, (ISubgraphService.UpgradeIndexingAgreementMetadata)); + } + + /** + * @notice Decodes the collect data for indexing fees V1. + * + * @param data The data to decode. + */ + function decodeCollectIndexingFeeDataV1( + bytes memory data + ) external pure returns (uint256 entities, bytes32 poi, uint256 epoch) { + return abi.decode(data, (uint256, bytes32, uint256)); + } + + /** + * @notice Decodes the data for indexing agreement terms V1. + * + * @param data The data to decode. See {ISubgraphService.IndexingAgreementTermsV1} + * @return The decoded data + */ + function decodeIndexingAgreementTermsV1( + bytes memory data + ) external pure returns (ISubgraphService.IndexingAgreementTermsV1 memory) { + return abi.decode(data, (ISubgraphService.IndexingAgreementTermsV1)); + } + + function _decodeCollectIndexingFeeData(bytes memory _data) internal view returns (bytes16, bytes memory) { + try this.decodeCollectIndexingFeeData(_data) returns (bytes16 agreementId, bytes memory data) { + return (agreementId, data); + } catch { + revert ISubgraphService.SubgraphServiceDecoderInvalidData("_decodeCollectIndexingFeeData", _data); + } + } + + function _decodeRCAMetadata( + bytes memory _data + ) internal view returns (ISubgraphService.AcceptIndexingAgreementMetadata memory) { + try this.decodeRCAMetadata(_data) returns (ISubgraphService.AcceptIndexingAgreementMetadata memory metadata) { + return metadata; + } catch { + revert ISubgraphService.SubgraphServiceDecoderInvalidData("_decodeRCAMetadata", _data); + } + } + + function _decodeRCAUMetadata( + bytes memory _data + ) internal view returns (ISubgraphService.UpgradeIndexingAgreementMetadata memory) { + try this.decodeRCAUMetadata(_data) returns (ISubgraphService.UpgradeIndexingAgreementMetadata memory metadata) { + return metadata; + } catch { + revert ISubgraphService.SubgraphServiceDecoderInvalidData("_decodeRCAUMetadata", _data); + } + } + + function _decodeCollectIndexingFeeDataV1(bytes memory _data) internal view returns (uint256, bytes32, uint256) { + try this.decodeCollectIndexingFeeDataV1(_data) returns (uint256 entities, bytes32 poi, uint256 epoch) { + return (entities, poi, epoch); + } catch { + revert ISubgraphService.SubgraphServiceDecoderInvalidData("_decodeCollectIndexingFeeDataV1", _data); + } + } + + function _decodeIndexingAgreementTermsV1( + bytes memory _data + ) internal view returns (ISubgraphService.IndexingAgreementTermsV1 memory) { + try this.decodeIndexingAgreementTermsV1(_data) returns ( + ISubgraphService.IndexingAgreementTermsV1 memory terms + ) { + return terms; + } catch { + revert ISubgraphService.SubgraphServiceDecoderInvalidData("_decodeCollectIndexingFeeData", _data); + } + } +} diff --git a/packages/subgraph-service/contracts/DisputeManager.sol b/packages/subgraph-service/contracts/DisputeManager.sol index 8d80ce1c1..09e610349 100644 --- a/packages/subgraph-service/contracts/DisputeManager.sol +++ b/packages/subgraph-service/contracts/DisputeManager.sol @@ -5,6 +5,7 @@ import { IGraphToken } from "@graphprotocol/contracts/contracts/token/IGraphToke import { IHorizonStaking } from "@graphprotocol/horizon/contracts/interfaces/IHorizonStaking.sol"; import { IDisputeManager } from "./interfaces/IDisputeManager.sol"; import { ISubgraphService } from "./interfaces/ISubgraphService.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; import { Math } from "@openzeppelin/contracts/utils/math/Math.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; @@ -129,6 +130,19 @@ contract DisputeManager is return _createIndexingDisputeWithAllocation(msg.sender, disputeDeposit, allocationId, poi); } + /// @inheritdoc IDisputeManager + function createIndexingFeeDisputeV1( + bytes16 agreementId, + bytes32 poi, + uint256 entities + ) external override returns (bytes32) { + // Get funds from fisherman + _graphToken().pullTokens(msg.sender, disputeDeposit); + + // Create a dispute + return _createIndexingFeeDisputeV1(msg.sender, disputeDeposit, agreementId, poi, entities); + } + /// @inheritdoc IDisputeManager function createQueryDispute(bytes calldata attestationData) external override returns (bytes32) { // Get funds from fisherman @@ -450,6 +464,83 @@ 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 + * @return The dispute id + */ + function _createIndexingFeeDisputeV1( + address _fisherman, + uint256 _deposit, + bytes16 _agreementId, + bytes32 _poi, + uint256 _entities + ) private returns (bytes32) { + ( + ISubgraphService.IndexingAgreementData memory indexingAgreement, + IRecurringCollector.AgreementData memory agreement + ) = _getSubgraphService().getIndexingAgreement(_agreementId); + + // Agreement must have been collected on and be a version 1 + require(agreement.lastCollectionAt > 0, DisputeManagerIndexingAgreementNotDisputable(_agreementId)); + require( + indexingAgreement.version == ISubgraphService.IndexingAgreementVersion.V1, + DisputeManagerIndexingAgreementInvalidVersion(indexingAgreement.version) + ); + + // Create a disputeId + bytes32 disputeId = keccak256( + abi.encodePacked( + "IndexingFeeDisputeWithAgreement", + _agreementId, + agreement.serviceProvider, + agreement.payer, + _poi, + _entities + ) + ); + + // Only one dispute at a time + require(!isDisputeCreated(disputeId), DisputeManagerDisputeAlreadyCreated(disputeId)); + + // The indexer must be disputable + IHorizonStaking.Provision memory provision = _graphStaking().getProvision( + agreement.serviceProvider, + address(_getSubgraphService()) + ); + require(provision.tokens != 0, DisputeManagerZeroTokens()); + + uint256 stakeSnapshot = _getStakeSnapshot(agreement.serviceProvider, provision.tokens); + disputes[disputeId] = Dispute( + agreement.serviceProvider, + _fisherman, + _deposit, + 0, // no related dispute, + DisputeType.IndexingFeeDispute, + IDisputeManager.DisputeStatus.Pending, + block.timestamp, + stakeSnapshot + ); + + emit IndexingFeeDisputeCreated( + disputeId, + agreement.serviceProvider, + _fisherman, + _deposit, + agreement.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 edf918771..4870b7cc0 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -17,12 +17,15 @@ import { DataServiceFees } from "@graphprotocol/horizon/contracts/data-service/e import { Directory } from "./utilities/Directory.sol"; import { AllocationManager } from "./utilities/AllocationManager.sol"; import { SubgraphServiceV1Storage } from "./SubgraphServiceStorage.sol"; +import { Decoder } from "./Decoder.sol"; import { TokenUtils } from "@graphprotocol/contracts/contracts/utils/TokenUtils.sol"; import { PPMMath } from "@graphprotocol/horizon/contracts/libraries/PPMMath.sol"; import { Allocation } from "./libraries/Allocation.sol"; import { LegacyAllocation } from "./libraries/LegacyAllocation.sol"; +import { IRecurringCollector } from "@graphprotocol/horizon/contracts/interfaces/IRecurringCollector.sol"; + /** * @title SubgraphService contract * @custom:security-contact Please email security+contracts@thegraph.com if you find any @@ -38,6 +41,7 @@ contract SubgraphService is Directory, AllocationManager, SubgraphServiceV1Storage, + Decoder, IRewardsIssuer, ISubgraphService { @@ -67,8 +71,12 @@ contract SubgraphService is 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(); } @@ -227,6 +235,7 @@ contract SubgraphService is _allocations.get(allocationId).indexer == indexer, SubgraphServiceAllocationNotAuthorized(indexer, allocationId) ); + _cancelAllocationIndexingAgreement(allocationId); _closeAllocation(allocationId); emit ServiceStopped(indexer, data); } @@ -259,10 +268,10 @@ contract SubgraphService is ) external override + whenNotPaused onlyAuthorizedForProvision(indexer) onlyValidProvision(indexer) onlyRegisteredIndexer(indexer) - whenNotPaused returns (uint256) { uint256 paymentCollected = 0; @@ -281,6 +290,10 @@ contract SubgraphService is SubgraphServiceAllocationNotAuthorized(indexer, allocationId) ); paymentCollected = _collectIndexingRewards(allocationId, poi, _delegationRatio); + } else if (paymentType == IGraphPayments.PaymentTypes.IndexingFee) { + (bytes16 agreementId, bytes memory iaCollectionData) = _decodeCollectIndexingFeeData(data); + + paymentCollected = _collectIndexingFees(agreementId, iaCollectionData); } else { revert SubgraphServiceInvalidPaymentType(paymentType); } @@ -306,6 +319,7 @@ contract SubgraphService is Allocation.State memory allocation = _allocations.get(allocationId); require(allocation.isStale(maxPOIStaleness), SubgraphServiceCannotForceCloseAllocation(allocationId)); require(!allocation.isAltruistic(), SubgraphServiceAllocationIsAltruistic(allocationId)); + _cancelAllocationIndexingAgreement(allocationId); _closeAllocation(allocationId); } @@ -535,4 +549,412 @@ contract SubgraphService is stakeToFeesRatio = _stakeToFeesRatio; emit StakeToFeesRatioSet(_stakeToFeesRatio); } + + /// @notice Tracks indexing agreements + mapping(bytes16 agreementId => IndexingAgreementData data) public indexingAgreements; + + /// @notice Tracks indexing agreements parameters (V1) + mapping(bytes16 agreementId => IndexingAgreementTermsV1 data) public indexingAgreementTermsV1; + + /// @notice Lookup agreement ID by allocation ID + mapping(address allocationId => bytes16 agreementId) public allocationToActiveAgreementId; + + /** + * @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 {ISubgraphService.AcceptIndexingAgreementMetadata} + * + * Emits {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) + { + require( + signedRCA.rca.dataService == address(this), + SubgraphServiceIndexingAgreementWrongDataService(signedRCA.rca.dataService) + ); + + AcceptIndexingAgreementMetadata memory metadata = _decodeRCAMetadata(signedRCA.rca.metadata); + _acceptIndexingAgreement(allocationId, signedRCA.rca, metadata); + _recurringCollector().accept(signedRCA); + + emit IndexingAgreementAccepted( + signedRCA.rca.serviceProvider, + signedRCA.rca.payer, + signedRCA.rca.agreementId, + allocationId, + metadata.subgraphDeploymentId, + metadata.version, + metadata.terms + ); + } + + function upgradeIndexingAgreement( + address indexer, + IRecurringCollector.SignedRCAU calldata signedRCAU + ) + external + whenNotPaused + onlyAuthorizedForProvision(indexer) + onlyValidProvision(indexer) + onlyRegisteredIndexer(indexer) + { + IndexingAgreementData storage indexingAgreement = indexingAgreements[signedRCAU.rcau.agreementId]; + IRecurringCollector.AgreementData memory agreement = _getCollectorAgreement(signedRCAU.rcau.agreementId); + require( + _isActiveAgreement(indexingAgreement, agreement), + SubgraphServiceIndexingAgreementNotActive(signedRCAU.rcau.agreementId) + ); + require( + agreement.serviceProvider == indexer, + SubgraphServiceIndexingAgreementNotAuthorized(signedRCAU.rcau.agreementId, indexer) + ); + + UpgradeIndexingAgreementMetadata memory metadata = _decodeRCAUMetadata(signedRCAU.rcau.metadata); + + indexingAgreement.version = metadata.version; + + require( + metadata.version == IndexingAgreementVersion.V1, + SubgraphServiceInvalidIndexingAgreementVersion(metadata.version) + ); + _setIndexingAgreementTermsV1(signedRCAU.rcau.agreementId, metadata.terms); + + _recurringCollector().upgrade(signedRCAU); + } + + /** + * @notice Cancel an indexing agreement by indexer / operator. + * See {ISubgraphService.cancelIndexingAgreement}. + * + * @dev Can only be canceled on behalf of a valid indexer. + * + * Requirements: + * - The indexer must be registered + * - The caller must be authorized by the indexer + * - The provision must be valid according to the subgraph service rules + * - The agreement must be active + * + * Emits {IndexingAgreementCanceled} event + * + * @param agreementId The id of the agreement + */ + function cancelIndexingAgreement( + address indexer, + bytes16 agreementId + ) + external + whenNotPaused + onlyAuthorizedForProvision(indexer) + onlyValidProvision(indexer) + onlyRegisteredIndexer(indexer) + { + IndexingAgreementData memory indexingAgreement = indexingAgreements[agreementId]; + IRecurringCollector.AgreementData memory agreement = _getCollectorAgreement(agreementId); + require( + _isActiveAgreement(indexingAgreement, agreement), + SubgraphServiceIndexingAgreementNotActive(agreementId) + ); + require( + agreement.serviceProvider == indexer, + SubgraphServiceIndexingAgreementNonCancelableBy(agreement.serviceProvider, indexer) + ); + _cancelIndexingAgreement( + agreementId, + indexingAgreement, + agreement, + IRecurringCollector.CancelAgreementBy.ServiceProvider + ); + } + + function _cancelAllocationIndexingAgreement(address _allocationId) internal { + bytes16 agreementId = allocationToActiveAgreementId[_allocationId]; + if (agreementId == bytes16(0)) { + return; + } + + IndexingAgreementData memory indexingAgreement = indexingAgreements[agreementId]; + IRecurringCollector.AgreementData memory agreement = _getCollectorAgreement(agreementId); + if (!_isActiveAgreement(indexingAgreement, agreement)) { + return; + } + + _cancelIndexingAgreement( + agreementId, + indexingAgreement, + agreement, + IRecurringCollector.CancelAgreementBy.ServiceProvider + ); + } + + /** + * @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 { + IndexingAgreementData memory indexingAgreement = indexingAgreements[agreementId]; + IRecurringCollector.AgreementData memory agreement = _getCollectorAgreement(agreementId); + require( + _isActiveAgreement(indexingAgreement, agreement), + SubgraphServiceIndexingAgreementNotActive(agreementId) + ); + require( + _recurringCollector().isAuthorized(agreement.payer, msg.sender), + SubgraphServiceIndexingAgreementNonCancelableBy(agreement.payer, msg.sender) + ); + _cancelIndexingAgreement( + agreementId, + indexingAgreement, + agreement, + IRecurringCollector.CancelAgreementBy.Payer + ); + } + + function getIndexingAgreement( + bytes16 agreementId + ) external view returns (IndexingAgreementData memory, IRecurringCollector.AgreementData memory) { + return (indexingAgreements[agreementId], _requireCollectorAgreement(agreementId)); + } + + /** + * @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 _data The indexing agreement collection data + * @return The amount of fees collected + */ + function _collectIndexingFees(bytes16 _agreementId, bytes memory _data) private returns (uint256) { + IndexingAgreementData memory indexingAgreement = indexingAgreements[_agreementId]; + IRecurringCollector.AgreementData memory agreement = _getCollectorAgreement(_agreementId); + require( + _isActiveAgreement(indexingAgreement, agreement), + SubgraphServiceIndexingAgreementNotActive(_agreementId) + ); + Allocation.State memory allocation = _requireValidAllocation( + indexingAgreement.allocationId, + agreement.serviceProvider + ); + + require( + indexingAgreement.version == IndexingAgreementVersion.V1, + SubgraphServiceInvalidIndexingAgreementVersion(indexingAgreement.version) + ); + (uint256 entities, bytes32 poi, uint256 poiEpoch) = _decodeCollectIndexingFeeDataV1(_data); + + uint256 tokensToCollect = (poi == bytes32(0) && entities == 0) + ? 0 + : _indexingAgreementTokensToCollect(_agreementId, agreement, entities); + + uint256 tokensCollected = _indexingAgreementCollect( + _agreementId, + bytes32(uint256(uint160(indexingAgreement.allocationId))), + tokensToCollect + ); + + _releaseAndLockStake(agreement.serviceProvider, tokensCollected); + + emit IndexingFeesCollectedV1( + agreement.serviceProvider, + agreement.payer, + _agreementId, + indexingAgreement.allocationId, + allocation.subgraphDeploymentId, + _graphEpochManager().currentEpoch(), + tokensCollected, + entities, + poi, + poiEpoch + ); + return tokensCollected; + } + + function _acceptIndexingAgreement( + address _allocationId, + IRecurringCollector.RecurringCollectionAgreement calldata _rca, + AcceptIndexingAgreementMetadata memory _agreementMetadata + ) private { + IndexingAgreementData storage indexingAgreement = indexingAgreements[_rca.agreementId]; + require( + indexingAgreement.allocationId == address(0), + SubgraphServiceIndexingAgreementAlreadyAccepted(_rca.agreementId) + ); + Allocation.State memory allocation = _requireValidAllocation(_allocationId, _rca.serviceProvider); + require( + allocation.subgraphDeploymentId == _agreementMetadata.subgraphDeploymentId, + SubgraphServiceIndexingAgreementDeploymentIdMismatch( + _agreementMetadata.subgraphDeploymentId, + _allocationId, + allocation.subgraphDeploymentId + ) + ); + + require( + allocationToActiveAgreementId[_allocationId] == bytes16(0), + SubgraphServiceAllocationAlreadyHasIndexingAgreement(_allocationId) + ); + allocationToActiveAgreementId[_allocationId] = _rca.agreementId; + + indexingAgreement.version = _agreementMetadata.version; + indexingAgreement.allocationId = _allocationId; + + require( + _agreementMetadata.version == IndexingAgreementVersion.V1, + SubgraphServiceInvalidIndexingAgreementVersion(_agreementMetadata.version) + ); + _setIndexingAgreementTermsV1(_rca.agreementId, _agreementMetadata.terms); + } + + function _setIndexingAgreementTermsV1(bytes16 _agreementId, bytes memory _data) private { + IndexingAgreementTermsV1 memory newTerms = _decodeIndexingAgreementTermsV1(_data); + indexingAgreementTermsV1[_agreementId].tokensPerSecond = newTerms.tokensPerSecond; + indexingAgreementTermsV1[_agreementId].tokensPerEntityPerSecond = newTerms.tokensPerEntityPerSecond; + } + + function _indexingAgreementCollect( + bytes16 _agreementId, + bytes32 _collectionId, + uint256 _tokensToCollect + ) private returns (uint256) { + bytes memory data = abi.encode( + IRecurringCollector.CollectParams({ + agreementId: _agreementId, + collectionId: _collectionId, + tokens: _tokensToCollect, + dataServiceCut: 0 + }) + ); + return _recurringCollector().collect(IGraphPayments.PaymentTypes.IndexingFee, data); + } + + function _releaseAndLockStake(address _indexer, uint256 _tokensCollected) private { + _releaseStake(_indexer, 0); + if (_tokensCollected > 0) { + // lock stake as economic security for fees + _lockStake( + _indexer, + _tokensCollected * stakeToFeesRatio, + block.timestamp + _disputeManager().getDisputePeriod() + ); + } + } + + function _cancelIndexingAgreement( + bytes16 _agreementId, + IndexingAgreementData memory _indexingAgreement, + IRecurringCollector.AgreementData memory _agreement, + IRecurringCollector.CancelAgreementBy _cancelBy + ) private { + delete allocationToActiveAgreementId[_indexingAgreement.allocationId]; + + _recurringCollector().cancel(_agreementId, _cancelBy); + + emit IndexingAgreementCanceled( + _agreement.serviceProvider, + _agreement.payer, + _agreementId, + _cancelBy == IRecurringCollector.CancelAgreementBy.Payer ? _agreement.payer : _agreement.serviceProvider + ); + } + + function _indexingAgreementTokensToCollect( + bytes16 _agreementId, + IRecurringCollector.AgreementData memory _agreement, + uint256 _entities + ) private view returns (uint256) { + IndexingAgreementTermsV1 memory termsV1 = indexingAgreementTermsV1[_agreementId]; + + uint256 collectionSeconds = block.timestamp; + collectionSeconds -= _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; + + // FIX-ME: this is bad because it encourages indexers to collect at max seconds allowed to maximize collection. + return collectionSeconds * (termsV1.tokensPerSecond + termsV1.tokensPerEntityPerSecond * _entities); + } + + function _requireValidAllocation( + address _allocationId, + address _indexer + ) private view returns (Allocation.State memory) { + Allocation.State memory allocation = _allocations.get(_allocationId); + require(allocation.indexer == _indexer, SubgraphServiceAllocationNotAuthorized(_indexer, _allocationId)); + require(allocation.isOpen(), AllocationManagerAllocationClosed(_allocationId)); + + return allocation; + } + + function _requireValidCollectionId(bytes32 _collectionId) private pure returns (address) { + // Check that collectionId (256 bits) is a valid address (160 bits) + require(uint256(_collectionId) <= type(uint160).max, SubgraphServiceInvalidCollectionId(_collectionId)); + return address(uint160(uint256(_collectionId))); + } + + function _isActiveAgreement( + IndexingAgreementData memory _indexingAgreement, + IRecurringCollector.AgreementData memory _agreement + ) private view returns (bool) { + return + _agreement.dataService == address(this) && + _agreement.state == IRecurringCollector.AgreementState.Accepted && + _indexingAgreement.allocationId != address(0); + } + + function _requireCollectorAgreement( + bytes16 _agreementId + ) private view returns (IRecurringCollector.AgreementData memory) { + IRecurringCollector.AgreementData memory agreement = _getCollectorAgreement(_agreementId); + require(agreement.dataService == address(this), SubgraphServiceIndexingAgreementNotActive(_agreementId)); + return agreement; + } + + function _getCollectorAgreement( + bytes16 _agreementId + ) private view returns (IRecurringCollector.AgreementData memory) { + IRecurringCollector.AgreementData memory agreement = _recurringCollector().getAgreement(_agreementId); + return agreement; + } } diff --git a/packages/subgraph-service/contracts/interfaces/IDisputeManager.sol b/packages/subgraph-service/contracts/interfaces/IDisputeManager.sol index c8754ca44..84526ecc6 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 { ISubgraphService } from "./ISubgraphService.sol"; /** * @title IDisputeManager @@ -15,7 +16,8 @@ interface IDisputeManager { enum DisputeType { Null, IndexingDispute, - QueryDispute + QueryDispute, + IndexingFeeDispute } /// @notice Status of a dispute @@ -108,6 +110,31 @@ interface IDisputeManager { uint256 stakeSnapshot ); + /** + * @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 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`. @@ -324,6 +351,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(ISubgraphService.IndexingAgreementVersion version); + /** * @notice Initialize this contract. * @param owner The owner of the contract @@ -436,6 +475,23 @@ interface IDisputeManager { */ function createIndexingDispute(address allocationId, bytes32 poi) 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 + * @return The dispute id + */ + function createIndexingFeeDisputeV1(bytes16 agreementId, bytes32 poi, uint256 entities) external returns (bytes32); + // -- Arbitrator -- /** diff --git a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol index 6ae78f5b7..cbc469029 100644 --- a/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol +++ b/packages/subgraph-service/contracts/interfaces/ISubgraphService.sol @@ -3,6 +3,7 @@ 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 { Allocation } from "../libraries/Allocation.sol"; import { LegacyAllocation } from "../libraries/LegacyAllocation.sol"; @@ -97,7 +98,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. */ @@ -294,4 +295,201 @@ interface ISubgraphService is IDataServiceFees { * @return The address of the curation contract */ function getCuration() external view returns (address); + + /// @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 IndexingAgreementData { + address allocationId; + IndexingAgreementVersion version; + } + + /** + * @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 Upgrade Indexing Agreement metadata + * @param version The indexing agreement version + * @param terms The indexing agreement terms + */ + struct UpgradeIndexingAgreementMetadata { + 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; + } + + /** + * Thrown when accepting an agreement with a zero ID + */ + error SubgraphServiceIndexingAgreementIdZero(); + + /** + * @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 SubgraphServiceDecoderInvalidData(string t, bytes data); + + /** + * @notice Thrown when an agreement is not for the subgraph data service + * @param wrongDataService The wrong data service + */ + error SubgraphServiceIndexingAgreementWrongDataService(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 SubgraphServiceIndexingAgreementDeploymentIdMismatch( + bytes32 agreementDeploymentId, + address allocationId, + bytes32 allocationDeploymentId + ); + + /** + * @notice Thrown when the agreement is already accepted + * @param agreementId The agreement ID + */ + error SubgraphServiceIndexingAgreementAlreadyAccepted(bytes16 agreementId); + + /** + * @notice Thrown when an allocation already has an active agreement + * @param allocationId The allocation ID + */ + error SubgraphServiceAllocationAlreadyHasIndexingAgreement(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 SubgraphServiceIndexingAgreementNonCancelableBy(address owner, address unauthorized); + + /** + * @notice Thrown when the agreement is not active + * @param agreementId The agreement ID + */ + error SubgraphServiceIndexingAgreementNotActive(bytes16 agreementId); + + /** + * @notice Thrown when trying to interact with an agreement with an invalid version + * @param version The invalid version + */ + error SubgraphServiceInvalidIndexingAgreementVersion(IndexingAgreementVersion version); + + /** + * @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 SubgraphServiceIndexingAgreementNotAuthorized(bytes16 agreementId, address unauthorizedIndexer); + + /** + * @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 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 poiEpoch The epoch of the proof of indexing + */ + event IndexingFeesCollectedV1( + address indexed indexer, + address indexed payer, + bytes16 indexed agreementId, + address allocationId, + bytes32 subgraphDeploymentId, + uint256 currentEpoch, + uint256 tokensCollected, + uint256 entities, + bytes32 poi, + uint256 poiEpoch + ); + + /** + * @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 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 Accept an indexing agreement. + */ + function acceptIndexingAgreement(address allocationId, IRecurringCollector.SignedRCA calldata signedRCA) external; + + /** + * @notice Upgrade an indexing agreement. + */ + function upgradeIndexingAgreement(address indexer, IRecurringCollector.SignedRCAU calldata signedRCAU) external; + + /** + * @notice Cancel an indexing agreement by indexer / operator. + */ + function cancelIndexingAgreement(address indexer, bytes16 agreementId) external; + + /** + * @notice Cancel an indexing agreement by payer / signer. + */ + function cancelIndexingAgreementByPayer(bytes16 agreementId) external; + + function getIndexingAgreement( + bytes16 agreementId + ) external view returns (IndexingAgreementData memory, IRecurringCollector.AgreementData memory); } diff --git a/packages/subgraph-service/contracts/utilities/Directory.sol b/packages/subgraph-service/contracts/utilities/Directory.sol index d068c74b3..f4300a391 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 IP Collector contract address + /// @dev Required to collect indexing payments via Graph Horizon payments protocol + IRecurringCollector private immutable IP_COLLECTOR; + /// @notice The Curation contract address /// @dev Required for curation fees distribution ICuration private immutable CURATION; @@ -68,11 +73,18 @@ abstract contract Directory { * @param graphTallyCollector The Graph Tally Collector contract address * @param curation The Curation 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); + IP_COLLECTOR = IRecurringCollector(recurringCollector); emit SubgraphServiceDirectoryInitialized(subgraphService, disputeManager, graphTallyCollector, curation); } @@ -101,6 +113,13 @@ abstract contract Directory { return GRAPH_TALLY_COLLECTOR; } + /** + * @notice Returns the IP Collector contract address + */ + function _recurringCollector() internal view returns (IRecurringCollector) { + return IP_COLLECTOR; + } + /** * @notice Returns the Curation contract address * @return The Curation contract diff --git a/packages/subgraph-service/package.json b/packages/subgraph-service/package.json index fabb9cade..34949438f 100644 --- a/packages/subgraph-service/package.json +++ b/packages/subgraph-service/package.json @@ -13,9 +13,10 @@ "scripts": { "lint": "yarn lint:ts && yarn lint:sol", "lint:ts": "eslint '**/*.{js,ts}' --fix", - "lint:sol": "yarn lint:sol:prettier && yarn lint:sol:solhint", + "lint:sol": "yarn lint:sol:prettier && yarn lint:sol:solhint && yarn 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/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": "BUILD_RUN=true hardhat compile", diff --git a/packages/subgraph-service/test/SubgraphBaseTest.t.sol b/packages/subgraph-service/test/SubgraphBaseTest.t.sol index 1a5929c60..f1dca2fad 100644 --- a/packages/subgraph-service/test/SubgraphBaseTest.t.sol +++ b/packages/subgraph-service/test/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,19 @@ 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/subgraphService/SubgraphService.t.sol b/packages/subgraph-service/test/subgraphService/SubgraphService.t.sol index 2053083c8..87833b8f5 100644 --- a/packages/subgraph-service/test/subgraphService/SubgraphService.t.sol +++ b/packages/subgraph-service/test/subgraphService/SubgraphService.t.sol @@ -202,7 +202,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); @@ -216,7 +216,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( @@ -237,40 +237,23 @@ contract SubgraphServiceTest is SubgraphServiceSharedTest { } } - function _collectPaymentDataBefore(address _indexer) private view returns (CollectPaymentData memory) { + function _collectPaymentData( + address _indexer + ) internal view returns (CollectPaymentData memory collectPaymentData) { address rewardsDestination = subgraphService.rewardsDestination(_indexer); - CollectPaymentData memory collectPaymentDataBefore; - collectPaymentDataBefore.rewardsDestinationBalance = token.balanceOf(rewardsDestination); - collectPaymentDataBefore.indexerProvisionBalance = staking.getProviderTokensAvailable( + collectPaymentData.rewardsDestinationBalance = token.balanceOf(rewardsDestination); + 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); - return collectPaymentDataBefore; - } - - function _collectPaymentDataAfter(address _indexer) private view returns (CollectPaymentData memory) { - CollectPaymentData memory collectPaymentDataAfter; - address rewardsDestination = subgraphService.rewardsDestination(_indexer); - collectPaymentDataAfter.rewardsDestinationBalance = token.balanceOf(rewardsDestination); - 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); - return collectPaymentDataAfter; + collectPaymentData.indexerBalance = token.balanceOf(_indexer); + collectPaymentData.curationBalance = token.balanceOf(address(curation)); + collectPaymentData.lockedTokens = subgraphService.feesProvisionTracker(_indexer); + return collectPaymentData; } function _handleQueryFeeCollection( diff --git a/packages/subgraph-service/test/subgraphService/collect/collect.t.sol b/packages/subgraph-service/test/subgraphService/collect/collect.t.sol deleted file mode 100644 index 33ba00ff6..000000000 --- a/packages/subgraph-service/test/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/subgraphService/indexing-agreement/accept.t.sol b/packages/subgraph-service/test/subgraphService/indexing-agreement/accept.t.sol new file mode 100644 index 000000000..8e1f15bf3 --- /dev/null +++ b/packages/subgraph-service/test/subgraphService/indexing-agreement/accept.t.sol @@ -0,0 +1,248 @@ +// 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 { Allocation } from "../../../contracts/libraries/Allocation.sol"; +import { AllocationManager } from "../../../contracts/utilities/AllocationManager.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, maxSlashingPercentage, 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, maxSlashingPercentage, 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( + ISubgraphService.SubgraphServiceIndexingAgreementWrongDataService.selector, + 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( + ISubgraphService.SubgraphServiceDecoderInvalidData.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( + AllocationManager.AllocationManagerAllocationClosed.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( + ISubgraphService.SubgraphServiceIndexingAgreementDeploymentIdMismatch.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( + ISubgraphService.SubgraphServiceIndexingAgreementAlreadyAccepted.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); + ISubgraphService.AcceptIndexingAgreementMetadata memory metadata = abi.decode( + acceptable.rca.metadata, + (ISubgraphService.AcceptIndexingAgreementMetadata) + ); + vm.expectEmit(address(subgraphService)); + emit ISubgraphService.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/subgraphService/indexing-agreement/base.t.sol b/packages/subgraph-service/test/subgraphService/indexing-agreement/base.t.sol new file mode 100644 index 000000000..23410edc9 --- /dev/null +++ b/packages/subgraph-service/test/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 = TRANSPARENT_UPGRADEABLE_PROXY_ADMIN; + 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 = 0x15c603B7eaA8eE1a272a69C4af3462F926de777F; // GraphProxyAdmin + 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/subgraphService/indexing-agreement/cancel.t.sol b/packages/subgraph-service/test/subgraphService/indexing-agreement/cancel.t.sol new file mode 100644 index 000000000..92d5c53aa --- /dev/null +++ b/packages/subgraph-service/test/subgraphService/indexing-agreement/cancel.t.sol @@ -0,0 +1,214 @@ +// 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 { 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( + ISubgraphService.SubgraphServiceIndexingAgreementNonCancelableBy.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( + ISubgraphService.SubgraphServiceIndexingAgreementNotActive.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( + ISubgraphService.SubgraphServiceIndexingAgreementNotActive.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, maxSlashingPercentage, 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, maxSlashingPercentage, 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( + ISubgraphService.SubgraphServiceIndexingAgreementNotActive.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( + ISubgraphService.SubgraphServiceIndexingAgreementNotActive.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/subgraphService/indexing-agreement/collect.t.sol b/packages/subgraph-service/test/subgraphService/indexing-agreement/collect.t.sol new file mode 100644 index 000000000..db709d872 --- /dev/null +++ b/packages/subgraph-service/test/subgraphService/indexing-agreement/collect.t.sol @@ -0,0 +1,247 @@ +// 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 { 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); + bytes memory data = abi.encode( + IRecurringCollector.CollectParams({ + agreementId: accepted.rca.agreementId, + collectionId: bytes32(uint256(uint160(indexerState.allocationId))), + tokens: 0, + dataServiceCut: 0 + }) + ); + 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 ISubgraphService.IndexingFeesCollectedV1( + indexerState.addr, + accepted.rca.payer, + accepted.rca.agreementId, + indexerState.allocationId, + indexerState.subgraphDeploymentId, + epochManager.currentEpoch(), + tokensCollected, + entities, + poi, + epochManager.currentEpoch() + ); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, epochManager.currentEpoch()) + ); + + 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 currentEpoch = epochManager.currentEpoch(); + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + resetPrank(indexer); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpoch) + ); + } + + function test_SubgraphService_CollectIndexingFees_Revert_WhenNotAuthorized( + address operator, + address indexer, + bytes16 agreementId, + uint256 entities, + bytes32 poi + ) public withSafeIndexerOrOperator(operator) { + vm.assume(operator != indexer); + uint256 currentEpoch = epochManager.currentEpoch(); + 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, currentEpoch) + ); + } + + 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 currentEpoch = epochManager.currentEpoch(); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, maxSlashingPercentage, 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, currentEpoch) + ); + } + + 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 currentEpoch = epochManager.currentEpoch(); + mint(indexer, tokens); + resetPrank(indexer); + _createProvision(indexer, tokens, maxSlashingPercentage, disputePeriod); + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexer, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpoch) + ); + } + + 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 currentEpoch = epochManager.currentEpoch(); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexingAgreementNotActive.selector, + agreementId + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(agreementId, entities, poi, currentEpoch) + ); + } + + 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 currentEpoch = epochManager.currentEpoch(); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexingAgreementNotActive.selector, + accepted.rca.agreementId + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, currentEpoch) + ); + } + + 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 currentEpoch = epochManager.currentEpoch(); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexingAgreementNotActive.selector, + accepted.rca.agreementId + ); + vm.expectRevert(expectedErr); + subgraphService.collect( + indexerState.addr, + IGraphPayments.PaymentTypes.IndexingFee, + _encodeCollectDataV1(accepted.rca.agreementId, entities, poi, currentEpoch) + ); + } + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/subgraph-service/test/subgraphService/indexing-agreement/integration.t.sol b/packages/subgraph-service/test/subgraphService/indexing-agreement/integration.t.sol new file mode 100644 index 000000000..4efc03b41 --- /dev/null +++ b/packages/subgraph-service/test/subgraphService/indexing-agreement/integration.t.sol @@ -0,0 +1,133 @@ +// 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 { ISubgraphService } from "../../../contracts/interfaces/ISubgraphService.sol"; + +import { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementCancelTest 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, + ISubgraphService.IndexingAgreementTermsV1({ + tokensPerSecond: agreementTokensPerSecond, + tokensPerEntityPerSecond: 0 // no payment for entities + }) + ); + + _setupPayerWithEscrow(rca.payer, ctx.payer.signerPrivateKey, indexerState.addr, expectedTotalTokensCollected); + + resetPrank(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.currentEpoch()) + ); + 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/subgraphService/indexing-agreement/shared.t.sol b/packages/subgraph-service/test/subgraphService/indexing-agreement/shared.t.sol new file mode 100644 index 000000000..252a72456 --- /dev/null +++ b/packages/subgraph-service/test/subgraphService/indexing-agreement/shared.t.sol @@ -0,0 +1,359 @@ +// 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 { ISubgraphService } from "../../../contracts/interfaces/ISubgraphService.sol"; + +import { Bounder } from "@graphprotocol/horizon/test/utils/Bounder.t.sol"; +import { RecurringCollectorHelper } from "@graphprotocol/horizon/test/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.RecurringCollectionAgreementUpgrade rcau; + ISubgraphService.IndexingAgreementTermsV1 termsV1; + PayerSeed payer; + } + + struct IndexerSeed { + address addr; + string label; + uint256 unboundedProvisionTokens; + uint256 unboundedAllocationPrivateKey; + bytes32 subgraphDeploymentId; + } + + struct PayerSeed { + uint256 unboundedSignerPrivateKey; + } + + Context internal _context; + + address internal constant TRANSPARENT_UPGRADEABLE_PROXY_ADMIN = 0xE1C5264f10fad5d1912e5Ba2446a26F5EfdB7482; + + 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 ISubgraphService.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, maxSlashingPercentage, 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; + + ISubgraphService.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 ISubgraphService.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); + ISubgraphService.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( + _generateAcceptableRecurringCollectionAgreementUpgrade(_ctx, _rca), + _ctx.payer.signerPrivateKey + ); + } + + function _generateAcceptableRecurringCollectionAgreementUpgrade( + Context storage _ctx, + IRecurringCollector.RecurringCollectionAgreement memory _rca + ) internal view returns (IRecurringCollector.RecurringCollectionAgreementUpgrade memory) { + IRecurringCollector.RecurringCollectionAgreementUpgrade memory rcau = _ctx.ctxInternal.seed.rcau; + rcau.agreementId = _rca.agreementId; + rcau.metadata = _encodeUpgradeIndexingAgreementMetadataV1( + _newUpgradeIndexingAgreementMetadataV1( + _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(TRANSPARENT_UPGRADEABLE_PROXY_ADMIN) && + _candidate != address(proxyAdmin); + } + + function _newAcceptIndexingAgreementMetadataV1( + bytes32 _subgraphDeploymentId + ) internal pure returns (ISubgraphService.AcceptIndexingAgreementMetadata memory) { + return + ISubgraphService.AcceptIndexingAgreementMetadata({ + subgraphDeploymentId: _subgraphDeploymentId, + version: ISubgraphService.IndexingAgreementVersion.V1, + terms: abi.encode( + ISubgraphService.IndexingAgreementTermsV1({ tokensPerSecond: 0, tokensPerEntityPerSecond: 0 }) + ) + }); + } + + function _newUpgradeIndexingAgreementMetadataV1( + uint256 _tokensPerSecond, + uint256 _tokensPerEntityPerSecond + ) internal pure returns (ISubgraphService.UpgradeIndexingAgreementMetadata memory) { + return + ISubgraphService.UpgradeIndexingAgreementMetadata({ + version: ISubgraphService.IndexingAgreementVersion.V1, + terms: abi.encode( + ISubgraphService.IndexingAgreementTermsV1({ + tokensPerSecond: _tokensPerSecond, + tokensPerEntityPerSecond: _tokensPerEntityPerSecond + }) + ) + }); + } + + function _encodeCollectDataV1( + bytes16 _agreementId, + uint256 _entities, + bytes32 _poi, + uint256 _epoch + ) internal pure returns (bytes memory) { + return abi.encode(_agreementId, abi.encode(_entities, _poi, _epoch)); + } + + function _encodeAcceptIndexingAgreementMetadataV1( + bytes32 _subgraphDeploymentId, + ISubgraphService.IndexingAgreementTermsV1 memory _terms + ) internal pure returns (bytes memory) { + return + abi.encode( + ISubgraphService.AcceptIndexingAgreementMetadata({ + subgraphDeploymentId: _subgraphDeploymentId, + version: ISubgraphService.IndexingAgreementVersion.V1, + terms: abi.encode(_terms) + }) + ); + } + + function _encodeUpgradeIndexingAgreementMetadataV1( + ISubgraphService.UpgradeIndexingAgreementMetadata memory _t + ) internal pure returns (bytes memory) { + return abi.encode(_t); + } +} diff --git a/packages/subgraph-service/test/subgraphService/indexing-agreement/upgrade.t.sol b/packages/subgraph-service/test/subgraphService/indexing-agreement/upgrade.t.sol new file mode 100644 index 000000000..1ceafdb8b --- /dev/null +++ b/packages/subgraph-service/test/subgraphService/indexing-agreement/upgrade.t.sol @@ -0,0 +1,153 @@ +// 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 { SubgraphServiceIndexingAgreementSharedTest } from "./shared.t.sol"; + +contract SubgraphServiceIndexingAgreementUpgradeTest is SubgraphServiceIndexingAgreementSharedTest { + /* + * TESTS + */ + + /* solhint-disable graph/func-name-mixedcase */ + function test_SubgraphService_UpgradeIndexingAgreementIndexingAgreement_Revert_WhenPaused( + address operator, + IRecurringCollector.SignedRCAU calldata signedRCAU + ) public withSafeIndexerOrOperator(operator) { + resetPrank(users.pauseGuardian); + subgraphService.pause(); + + resetPrank(operator); + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + subgraphService.upgradeIndexingAgreement(operator, signedRCAU); + } + + function test_SubgraphService_UpgradeIndexingAgreement_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.upgradeIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpgradeIndexingAgreement_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, maxSlashingPercentage, disputePeriod); + + bytes memory expectedErr = abi.encodeWithSelector( + ProvisionManager.ProvisionManagerInvalidValue.selector, + "tokens", + tokens, + minimumProvisionTokens, + maximumProvisionTokens + ); + vm.expectRevert(expectedErr); + subgraphService.upgradeIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpgradeIndexingAgreement_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, maxSlashingPercentage, disputePeriod); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexerNotRegistered.selector, + indexer + ); + vm.expectRevert(expectedErr); + subgraphService.upgradeIndexingAgreement(indexer, signedRCAU); + } + + function test_SubgraphService_UpgradeIndexingAgreement_Revert_WhenNotAccepted(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCAU memory acceptableUpgrade = _generateAcceptableSignedRCAU( + ctx, + _generateAcceptableRecurringCollectionAgreement(ctx, indexerState.addr) + ); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexingAgreementNotActive.selector, + acceptableUpgrade.rcau.agreementId + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.upgradeIndexingAgreement(indexerState.addr, acceptableUpgrade); + } + + function test_SubgraphService_UpgradeIndexingAgreement_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 acceptableUpgrade = _generateAcceptableSignedRCAU(ctx, accepted.rca); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceIndexingAgreementNotAuthorized.selector, + acceptableUpgrade.rcau.agreementId, + indexerStateB.addr + ); + vm.expectRevert(expectedErr); + resetPrank(indexerStateB.addr); + subgraphService.upgradeIndexingAgreement(indexerStateB.addr, acceptableUpgrade); + } + + function test_SubgraphService_UpgradeIndexingAgreement_Revert_WhenInvalidMetadata(Seed memory seed) public { + Context storage ctx = _newCtx(seed); + IndexerState memory indexerState = _withIndexer(ctx); + IRecurringCollector.SignedRCA memory accepted = _withAcceptedIndexingAgreement(ctx, indexerState); + IRecurringCollector.RecurringCollectionAgreementUpgrade + memory acceptableUpgrade = _generateAcceptableRecurringCollectionAgreementUpgrade(ctx, accepted.rca); + acceptableUpgrade.metadata = bytes("invalid"); + IRecurringCollector.SignedRCAU memory unacceptableUpgrade = _recurringCollectorHelper.generateSignedRCAU( + acceptableUpgrade, + ctx.payer.signerPrivateKey + ); + + bytes memory expectedErr = abi.encodeWithSelector( + ISubgraphService.SubgraphServiceDecoderInvalidData.selector, + "_decodeRCAUMetadata", + unacceptableUpgrade.rcau.metadata + ); + vm.expectRevert(expectedErr); + resetPrank(indexerState.addr); + subgraphService.upgradeIndexingAgreement(indexerState.addr, unacceptableUpgrade); + } + + function test_SubgraphService_UpgradeIndexingAgreement_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 acceptableUpgrade = _generateAcceptableSignedRCAU(ctx, accepted.rca); + + resetPrank(indexerState.addr); + subgraphService.upgradeIndexingAgreement(indexerState.addr, acceptableUpgrade); + } + /* solhint-enable graph/func-name-mixedcase */ +}