diff --git a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol index 10c6df60f..f1ebc0d65 100644 --- a/packages/horizon/contracts/payments/collectors/RecurringCollector.sol +++ b/packages/horizon/contracts/payments/collectors/RecurringCollector.sol @@ -9,6 +9,7 @@ import { Authorizable } from "../../utilities/Authorizable.sol"; import { GraphDirectory } from "../../utilities/GraphDirectory.sol"; // solhint-disable-next-line no-unused-import import { IPaymentsCollector } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsCollector.sol"; // for @inheritdoc +import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; import { PPMMath } from "../../libraries/PPMMath.sol"; @@ -72,7 +73,6 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC } } - /* solhint-disable function-max-lines */ /** * @inheritdoc IRecurringCollector * @notice Accept a Recurring Collection Agreement. @@ -80,19 +80,6 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC * @dev Caller must be the data service the RCA was issued to. */ function accept(SignedRCA calldata signedRCA) external returns (bytes16) { - bytes16 agreementId = _generateAgreementId( - signedRCA.rca.payer, - signedRCA.rca.dataService, - signedRCA.rca.serviceProvider, - signedRCA.rca.deadline, - signedRCA.rca.nonce - ); - - require(agreementId != bytes16(0), RecurringCollectorAgreementIdZero()); - require( - msg.sender == signedRCA.rca.dataService, - RecurringCollectorUnauthorizedCaller(msg.sender, signedRCA.rca.dataService) - ); /* solhint-disable gas-strict-inequalities */ require( signedRCA.rca.deadline >= block.timestamp, @@ -103,19 +90,55 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC // check that the voucher is signed by the payer (or proxy) _requireAuthorizedRCASigner(signedRCA); + return _validateAndStoreAgreement(signedRCA.rca); + } + + /** + * @inheritdoc IRecurringCollector + * @notice Accept an RCA where the payer is a contract that authorizes via callback. + * See {IRecurringCollector.acceptUnsigned}. + * @dev Caller must be the data service the RCA was issued to. + */ + function acceptUnsigned(RecurringCollectionAgreement calldata rca) external returns (bytes16) { + // Verify payer is actually a contract + require(0 < rca.payer.code.length, RecurringCollectorApproverNotContract(rca.payer)); + + // Verify the contract confirms this specific agreement + bytes32 agreementHash = _hashRCA(rca); require( - signedRCA.rca.dataService != address(0) && - signedRCA.rca.payer != address(0) && - signedRCA.rca.serviceProvider != address(0), - RecurringCollectorAgreementAddressNotSet() + IContractApprover(rca.payer).isAuthorizedAgreement(agreementHash) == + IContractApprover.isAuthorizedAgreement.selector, + RecurringCollectorInvalidSigner() ); - _requireValidCollectionWindowParams( - signedRCA.rca.endsAt, - signedRCA.rca.minSecondsPerCollection, - signedRCA.rca.maxSecondsPerCollection + return _validateAndStoreAgreement(rca); + } + + /** + * @notice Validates RCA fields and stores the agreement. Shared by accept() and acceptUnsigned(). + * @param _rca The Recurring Collection Agreement to validate and store + * @return agreementId The deterministically generated agreement ID + */ + /* solhint-disable function-max-lines */ + function _validateAndStoreAgreement(RecurringCollectionAgreement memory _rca) private returns (bytes16) { + bytes16 agreementId = _generateAgreementId( + _rca.payer, + _rca.dataService, + _rca.serviceProvider, + _rca.deadline, + _rca.nonce + ); + + require(agreementId != bytes16(0), RecurringCollectorAgreementIdZero()); + require(msg.sender == _rca.dataService, RecurringCollectorUnauthorizedCaller(msg.sender, _rca.dataService)); + + require( + _rca.dataService != address(0) && _rca.payer != address(0) && _rca.serviceProvider != address(0), + RecurringCollectorAgreementAddressNotSet() ); + _requireValidCollectionWindowParams(_rca.endsAt, _rca.minSecondsPerCollection, _rca.maxSecondsPerCollection); + AgreementData storage agreement = _getAgreementStorage(agreementId); // check that the agreement is not already accepted require( @@ -126,14 +149,14 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC // 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; + agreement.dataService = _rca.dataService; + agreement.payer = _rca.payer; + agreement.serviceProvider = _rca.serviceProvider; + agreement.endsAt = _rca.endsAt; + agreement.maxInitialTokens = _rca.maxInitialTokens; + agreement.maxOngoingTokensPerSecond = _rca.maxOngoingTokensPerSecond; + agreement.minSecondsPerCollection = _rca.minSecondsPerCollection; + agreement.maxSecondsPerCollection = _rca.maxSecondsPerCollection; agreement.updateNonce = 0; emit AgreementAccepted( @@ -250,6 +273,65 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC agreement.maxSecondsPerCollection ); } + + /** + * @inheritdoc IRecurringCollector + * @notice Update a Recurring Collection Agreement where the payer is a contract. + * See {IRecurringCollector.updateUnsigned}. + * @dev Caller must be the data service for the agreement. + * @dev Note: Updated pricing terms apply immediately and will affect the next collection + * for the entire period since lastCollectionAt. + */ + function updateUnsigned(RecurringCollectionAgreementUpdate calldata rcau) external { + AgreementData storage agreement = _getAgreementStorage(rcau.agreementId); + require( + agreement.state == AgreementState.Accepted, + RecurringCollectorAgreementIncorrectState(rcau.agreementId, agreement.state) + ); + require( + agreement.dataService == msg.sender, + RecurringCollectorDataServiceNotAuthorized(rcau.agreementId, msg.sender) + ); + + // Contract callback instead of ECDSA signature + require(0 < agreement.payer.code.length, RecurringCollectorApproverNotContract(agreement.payer)); + bytes32 updateHash = _hashRCAU(rcau); + require( + IContractApprover(agreement.payer).isAuthorizedAgreement(updateHash) == + IContractApprover.isAuthorizedAgreement.selector, + RecurringCollectorInvalidSigner() + ); + + // validate nonce to prevent replay attacks + uint32 expectedNonce = agreement.updateNonce + 1; + require( + rcau.nonce == expectedNonce, + RecurringCollectorInvalidUpdateNonce(rcau.agreementId, expectedNonce, rcau.nonce) + ); + + _requireValidCollectionWindowParams(rcau.endsAt, rcau.minSecondsPerCollection, rcau.maxSecondsPerCollection); + + // update the agreement + agreement.endsAt = rcau.endsAt; + agreement.maxInitialTokens = rcau.maxInitialTokens; + agreement.maxOngoingTokensPerSecond = rcau.maxOngoingTokensPerSecond; + agreement.minSecondsPerCollection = rcau.minSecondsPerCollection; + agreement.maxSecondsPerCollection = rcau.maxSecondsPerCollection; + agreement.updateNonce = rcau.nonce; + + emit AgreementUpdated( + agreement.dataService, + agreement.payer, + agreement.serviceProvider, + rcau.agreementId, + uint64(block.timestamp), + agreement.endsAt, + agreement.maxInitialTokens, + agreement.maxOngoingTokensPerSecond, + agreement.minSecondsPerCollection, + agreement.maxSecondsPerCollection + ); + } /* solhint-enable function-max-lines */ /// @inheritdoc IRecurringCollector @@ -284,6 +366,11 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC return _getCollectionInfo(agreement); } + /// @inheritdoc IRecurringCollector + function getMaxNextClaim(bytes16 agreementId) external view returns (uint256) { + return _getMaxNextClaim(agreements[agreementId]); + } + /// @inheritdoc IRecurringCollector function generateAgreementId( address payer, @@ -645,6 +732,45 @@ contract RecurringCollector is EIP712, GraphDirectory, Authorizable, IRecurringC return _agreement.lastCollectionAt > 0 ? _agreement.lastCollectionAt : _agreement.acceptedAt; } + /** + * @notice Compute the maximum tokens collectable in the next collection (worst case). + * @dev For active agreements uses endsAt as the collection end (worst case), + * not block.timestamp (current). Returns 0 for non-collectable states. + * @param _a The agreement data + * @return The maximum tokens that could be collected + */ + function _getMaxNextClaim(AgreementData memory _a) private pure returns (uint256) { + // CanceledByServiceProvider = immediately non-collectable + if (_a.state == AgreementState.CanceledByServiceProvider) return 0; + // Only Accepted and CanceledByPayer are collectable + if (_a.state != AgreementState.Accepted && _a.state != AgreementState.CanceledByPayer) return 0; + + // Collection starts from last collection (or acceptance if never collected) + uint256 collectionStart = 0 < _a.lastCollectionAt ? _a.lastCollectionAt : _a.acceptedAt; + + // Determine the latest possible collection end + uint256 collectionEnd; + if (_a.state == AgreementState.CanceledByPayer) { + // Payer cancel freezes the window at min(canceledAt, endsAt) + collectionEnd = _a.canceledAt < _a.endsAt ? _a.canceledAt : _a.endsAt; + } else { + // Active: collection window capped at endsAt + collectionEnd = _a.endsAt; + } + + // No collection possible if window is empty + // solhint-disable-next-line gas-strict-inequalities + if (collectionEnd <= collectionStart) return 0; + + // Max seconds is capped by maxSecondsPerCollection (enforced by _requireValidCollect) + uint256 windowSeconds = collectionEnd - collectionStart; + uint256 maxSeconds = windowSeconds < _a.maxSecondsPerCollection ? windowSeconds : _a.maxSecondsPerCollection; + + uint256 maxClaim = _a.maxOngoingTokensPerSecond * maxSeconds; + if (_a.lastCollectionAt == 0) maxClaim += _a.maxInitialTokens; + return maxClaim; + } + /** * @notice Internal function to generate deterministic agreement ID * @param _payer The address of the payer diff --git a/packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol b/packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol new file mode 100644 index 000000000..c8d4347cf --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/MockContractApprover.t.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; + +/// @notice Mock contract approver for testing acceptUnsigned and updateUnsigned. +/// Can be configured to return valid selector, wrong value, or revert. +contract MockContractApprover is IContractApprover { + mapping(bytes32 => bool) public authorizedHashes; + bool public shouldRevert; + bytes4 public overrideReturnValue; + bool public useOverride; + + function authorize(bytes32 agreementHash) external { + authorizedHashes[agreementHash] = true; + } + + function setShouldRevert(bool _shouldRevert) external { + shouldRevert = _shouldRevert; + } + + function setOverrideReturnValue(bytes4 _value) external { + overrideReturnValue = _value; + useOverride = true; + } + + function isAuthorizedAgreement(bytes32 agreementHash) external view override returns (bytes4) { + if (shouldRevert) { + revert("MockContractApprover: forced revert"); + } + if (useOverride) { + return overrideReturnValue; + } + require(authorizedHashes[agreementHash], "MockContractApprover: not authorized"); + return IContractApprover.isAuthorizedAgreement.selector; + } +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/acceptUnsigned.t.sol b/packages/horizon/test/unit/payments/recurring-collector/acceptUnsigned.t.sol new file mode 100644 index 000000000..84a5b2871 --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/acceptUnsigned.t.sol @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; +import { MockContractApprover } from "./MockContractApprover.t.sol"; + +contract RecurringCollectorAcceptUnsignedTest is RecurringCollectorSharedTest { + function _newApprover() internal returns (MockContractApprover) { + return new MockContractApprover(); + } + + function _makeSimpleRCA(address payer) internal returns (IRecurringCollector.RecurringCollectionAgreement memory) { + return + _recurringCollectorHelper.sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 365 days), + payer: payer, + dataService: makeAddr("ds"), + serviceProvider: makeAddr("sp"), + maxInitialTokens: 100 ether, + maxOngoingTokensPerSecond: 1 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 3600, + nonce: 1, + metadata: "" + }) + ); + } + + /* solhint-disable graph/func-name-mixedcase */ + + function test_AcceptUnsigned(FuzzyTestAccept calldata fuzzyTestAccept) public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + fuzzyTestAccept.rca + ); + rca.payer = address(approver); + + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + approver.authorize(agreementHash); + + _setupValidProvision(rca.serviceProvider, rca.dataService); + + bytes16 expectedId = _recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementAccepted( + rca.dataService, + rca.payer, + rca.serviceProvider, + expectedId, + uint64(block.timestamp), + rca.endsAt, + rca.maxInitialTokens, + rca.maxOngoingTokensPerSecond, + rca.minSecondsPerCollection, + rca.maxSecondsPerCollection + ); + + vm.prank(rca.dataService); + bytes16 agreementId = _recurringCollector.acceptUnsigned(rca); + + assertEq(agreementId, expectedId); + + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(agreementId); + assertEq(uint8(agreement.state), uint8(IRecurringCollector.AgreementState.Accepted)); + assertEq(agreement.payer, address(approver)); + assertEq(agreement.serviceProvider, rca.serviceProvider); + assertEq(agreement.dataService, rca.dataService); + } + + function test_AcceptUnsigned_Revert_WhenPayerNotContract() public { + address eoa = makeAddr("eoa"); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(eoa); + + vm.expectRevert( + abi.encodeWithSelector(IRecurringCollector.RecurringCollectorApproverNotContract.selector, eoa) + ); + vm.prank(rca.dataService); + _recurringCollector.acceptUnsigned(rca); + } + + function test_AcceptUnsigned_Revert_WhenHashNotAuthorized() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + // Don't authorize the hash + vm.expectRevert(); + vm.prank(rca.dataService); + _recurringCollector.acceptUnsigned(rca); + } + + function test_AcceptUnsigned_Revert_WhenWrongMagicValue() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + approver.setOverrideReturnValue(bytes4(0xdeadbeef)); + + vm.expectRevert(abi.encodeWithSelector(IRecurringCollector.RecurringCollectorInvalidSigner.selector)); + vm.prank(rca.dataService); + _recurringCollector.acceptUnsigned(rca); + } + + function test_AcceptUnsigned_Revert_WhenNotDataService() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + approver.authorize(agreementHash); + + address notDataService = makeAddr("notDataService"); + vm.expectRevert( + abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorUnauthorizedCaller.selector, + notDataService, + rca.dataService + ) + ); + vm.prank(notDataService); + _recurringCollector.acceptUnsigned(rca); + } + + function test_AcceptUnsigned_Revert_WhenAlreadyAccepted(FuzzyTestAccept calldata fuzzyTestAccept) public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + fuzzyTestAccept.rca + ); + rca.payer = address(approver); + + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + approver.authorize(agreementHash); + + _setupValidProvision(rca.serviceProvider, rca.dataService); + + vm.prank(rca.dataService); + bytes16 agreementId = _recurringCollector.acceptUnsigned(rca); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + agreementId, + IRecurringCollector.AgreementState.Accepted + ); + vm.expectRevert(expectedErr); + vm.prank(rca.dataService); + _recurringCollector.acceptUnsigned(rca); + } + + function test_AcceptUnsigned_Revert_WhenApproverReverts() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + approver.setShouldRevert(true); + + vm.expectRevert("MockContractApprover: forced revert"); + vm.prank(rca.dataService); + _recurringCollector.acceptUnsigned(rca); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/horizon/test/unit/payments/recurring-collector/updateUnsigned.t.sol b/packages/horizon/test/unit/payments/recurring-collector/updateUnsigned.t.sol new file mode 100644 index 000000000..8da27679c --- /dev/null +++ b/packages/horizon/test/unit/payments/recurring-collector/updateUnsigned.t.sol @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { RecurringCollectorSharedTest } from "./shared.t.sol"; +import { MockContractApprover } from "./MockContractApprover.t.sol"; + +contract RecurringCollectorUpdateUnsignedTest is RecurringCollectorSharedTest { + function _newApprover() internal returns (MockContractApprover) { + return new MockContractApprover(); + } + + /// @notice Helper to accept an agreement via the unsigned path and return the ID + function _acceptUnsigned( + MockContractApprover approver, + IRecurringCollector.RecurringCollectionAgreement memory rca + ) internal returns (bytes16) { + bytes32 agreementHash = _recurringCollector.hashRCA(rca); + approver.authorize(agreementHash); + + _setupValidProvision(rca.serviceProvider, rca.dataService); + + vm.prank(rca.dataService); + return _recurringCollector.acceptUnsigned(rca); + } + + function _makeSimpleRCA(address payer) internal returns (IRecurringCollector.RecurringCollectionAgreement memory) { + return + _recurringCollectorHelper.sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 365 days), + payer: payer, + dataService: makeAddr("ds"), + serviceProvider: makeAddr("sp"), + maxInitialTokens: 100 ether, + maxOngoingTokensPerSecond: 1 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 3600, + nonce: 1, + metadata: "" + }) + ); + } + + function _makeSimpleRCAU( + bytes16 agreementId, + uint32 nonce + ) internal view returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory) { + return + _recurringCollectorHelper.sensibleRCAU( + IRecurringCollector.RecurringCollectionAgreementUpdate({ + agreementId: agreementId, + deadline: 0, + endsAt: uint64(block.timestamp + 730 days), + maxInitialTokens: 200 ether, + maxOngoingTokensPerSecond: 2 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 7200, + nonce: nonce, + metadata: "" + }) + ); + } + + /* solhint-disable graph/func-name-mixedcase */ + + function test_UpdateUnsigned() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + bytes16 agreementId = _acceptUnsigned(approver, rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(agreementId, 1); + + // Authorize the update hash + bytes32 updateHash = _recurringCollector.hashRCAU(rcau); + approver.authorize(updateHash); + + vm.expectEmit(address(_recurringCollector)); + emit IRecurringCollector.AgreementUpdated( + rca.dataService, + rca.payer, + rca.serviceProvider, + agreementId, + uint64(block.timestamp), + rcau.endsAt, + rcau.maxInitialTokens, + rcau.maxOngoingTokensPerSecond, + rcau.minSecondsPerCollection, + rcau.maxSecondsPerCollection + ); + + vm.prank(rca.dataService); + _recurringCollector.updateUnsigned(rcau); + + IRecurringCollector.AgreementData memory agreement = _recurringCollector.getAgreement(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); + assertEq(rcau.nonce, agreement.updateNonce); + } + + function test_UpdateUnsigned_Revert_WhenPayerNotContract() public { + // Use the signed accept path to create an agreement with an EOA payer, + // then attempt updateUnsigned which should fail because payer isn't a contract + uint256 signerKey = 0xA11CE; + address payer = vm.addr(signerKey); + IRecurringCollector.RecurringCollectionAgreement memory rca = _recurringCollectorHelper.sensibleRCA( + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: uint64(block.timestamp + 365 days), + payer: payer, + dataService: makeAddr("ds"), + serviceProvider: makeAddr("sp"), + maxInitialTokens: 100 ether, + maxOngoingTokensPerSecond: 1 ether, + minSecondsPerCollection: 600, + maxSecondsPerCollection: 3600, + nonce: 1, + metadata: "" + }) + ); + + // Accept via signed path + _recurringCollectorHelper.authorizeSignerWithChecks(payer, signerKey); + IRecurringCollector.SignedRCA memory signedRCA = _recurringCollectorHelper.generateSignedRCA(rca, signerKey); + _setupValidProvision(rca.serviceProvider, rca.dataService); + vm.prank(rca.dataService); + bytes16 agreementId = _recurringCollector.accept(signedRCA); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(agreementId, 1); + + vm.expectRevert( + abi.encodeWithSelector(IRecurringCollector.RecurringCollectorApproverNotContract.selector, payer) + ); + vm.prank(rca.dataService); + _recurringCollector.updateUnsigned(rcau); + } + + function test_UpdateUnsigned_Revert_WhenHashNotAuthorized() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + bytes16 agreementId = _acceptUnsigned(approver, rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(agreementId, 1); + + // Don't authorize the update hash + vm.expectRevert("MockContractApprover: not authorized"); + vm.prank(rca.dataService); + _recurringCollector.updateUnsigned(rcau); + } + + function test_UpdateUnsigned_Revert_WhenWrongMagicValue() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + bytes16 agreementId = _acceptUnsigned(approver, rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(agreementId, 1); + + approver.setOverrideReturnValue(bytes4(0xdeadbeef)); + + vm.expectRevert(abi.encodeWithSelector(IRecurringCollector.RecurringCollectorInvalidSigner.selector)); + vm.prank(rca.dataService); + _recurringCollector.updateUnsigned(rcau); + } + + function test_UpdateUnsigned_Revert_WhenNotDataService() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + bytes16 agreementId = _acceptUnsigned(approver, rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(agreementId, 1); + + bytes32 updateHash = _recurringCollector.hashRCAU(rcau); + approver.authorize(updateHash); + + address notDataService = makeAddr("notDataService"); + vm.expectRevert( + abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorDataServiceNotAuthorized.selector, + agreementId, + notDataService + ) + ); + vm.prank(notDataService); + _recurringCollector.updateUnsigned(rcau); + } + + function test_UpdateUnsigned_Revert_WhenNotAccepted() public { + // Don't accept — just try to update a non-existent agreement + bytes16 fakeId = bytes16(keccak256("fake")); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(fakeId, 1); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorAgreementIncorrectState.selector, + fakeId, + IRecurringCollector.AgreementState.NotAccepted + ); + vm.expectRevert(expectedErr); + vm.prank(makeAddr("ds")); + _recurringCollector.updateUnsigned(rcau); + } + + function test_UpdateUnsigned_Revert_WhenInvalidNonce() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + bytes16 agreementId = _acceptUnsigned(approver, rca); + + // Use wrong nonce (0 instead of 1) + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(agreementId, 0); + + bytes32 updateHash = _recurringCollector.hashRCAU(rcau); + approver.authorize(updateHash); + + bytes memory expectedErr = abi.encodeWithSelector( + IRecurringCollector.RecurringCollectorInvalidUpdateNonce.selector, + agreementId, + 1, // expected + 0 // provided + ); + vm.expectRevert(expectedErr); + vm.prank(rca.dataService); + _recurringCollector.updateUnsigned(rcau); + } + + function test_UpdateUnsigned_Revert_WhenApproverReverts() public { + MockContractApprover approver = _newApprover(); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeSimpleRCA(address(approver)); + + bytes16 agreementId = _acceptUnsigned(approver, rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeSimpleRCAU(agreementId, 1); + + approver.setShouldRevert(true); + + vm.expectRevert("MockContractApprover: forced revert"); + vm.prank(rca.dataService); + _recurringCollector.updateUnsigned(rcau); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/interfaces/contracts/horizon/IContractApprover.sol b/packages/interfaces/contracts/horizon/IContractApprover.sol new file mode 100644 index 000000000..0c0048b8b --- /dev/null +++ b/packages/interfaces/contracts/horizon/IContractApprover.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +pragma solidity ^0.8.22; + +/** + * @title Interface for contracts that can act as authorized agreement approvers + * @author Edge & Node + * @notice Enables contracts to authorize RCA agreements and updates on-chain via + * {RecurringCollector.acceptUnsigned} and {RecurringCollector.updateUnsigned}, + * replacing ECDSA signatures with a callback. + * + * Uses the magic-value pattern: return the function selector on success. + * + * The same callback is used for both accept (RCA hash) and update (RCAU hash). + * Hash namespaces do not collide because RCA and RCAU use different EIP712 type hashes. + * + * No per-payer authorization step is needed — the contract's code is the authorization. + * The trust chain is: governance grants operator role → operator registers + * (validates and pre-funds) → isAuthorizedAgreement confirms → RC accepts/updates. + * + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +interface IContractApprover { + /** + * @notice Confirms this contract authorized the given agreement or update + * @dev Called by {RecurringCollector.acceptUnsigned} with an RCA hash or by + * {RecurringCollector.updateUnsigned} with an RCAU hash to verify authorization. + * @param agreementHash The EIP712 hash of the RCA or RCAU struct + * @return magic `IContractApprover.isAuthorizedAgreement.selector` if authorized + */ + function isAuthorizedAgreement(bytes32 agreementHash) external view returns (bytes4); +} diff --git a/packages/interfaces/contracts/horizon/IRecurringCollector.sol b/packages/interfaces/contracts/horizon/IRecurringCollector.sol index 445c4cb0b..47de9e79d 100644 --- a/packages/interfaces/contracts/horizon/IRecurringCollector.sol +++ b/packages/interfaces/contracts/horizon/IRecurringCollector.sol @@ -397,6 +397,12 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ error RecurringCollectorExcessiveSlippage(uint256 requested, uint256 actual, uint256 maxSlippage); + /** + * @notice Thrown when the contract approver is not a contract + * @param approver The address that is not a contract + */ + error RecurringCollectorApproverNotContract(address approver); + /** * @notice Accept an indexing agreement. * @param signedRCA The signed Recurring Collection Agreement which is to be accepted. @@ -404,6 +410,16 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ function accept(SignedRCA calldata signedRCA) external returns (bytes16 agreementId); + /** + * @notice Accept an RCA where the payer is a contract that authorizes via callback. + * @dev Caller must be the data service the RCA was issued to. + * The payer must be a contract implementing {IContractApprover.isAuthorizedAgreement} + * and must return the magic value for the RCA's EIP712 hash. + * @param rca The Recurring Collection Agreement to accept + * @return agreementId The deterministically generated agreement ID + */ + function acceptUnsigned(RecurringCollectionAgreement calldata rca) external returns (bytes16 agreementId); + /** * @notice Cancel an indexing agreement. * @param agreementId The agreement's ID. @@ -417,6 +433,16 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ function update(SignedRCAU calldata signedRCAU) external; + /** + * @notice Update an agreement where the payer is a contract that authorizes via callback. + * @dev Caller must be the data service for the agreement. + * The payer (stored in the agreement) must be a contract implementing + * {IContractApprover.isAuthorizedAgreement} and must return the magic value + * for the RCAU's EIP712 hash. + * @param rcau The Recurring Collection Agreement Update to apply + */ + function updateUnsigned(RecurringCollectionAgreementUpdate calldata rcau) external; + /** * @notice Computes the hash of a RecurringCollectionAgreement (RCA). * @param rca The RCA for which to compute the hash. @@ -452,6 +478,16 @@ interface IRecurringCollector is IAuthorizable, IPaymentsCollector { */ function getAgreement(bytes16 agreementId) external view returns (AgreementData memory); + /** + * @notice Get the maximum tokens collectable in the next collection for an agreement. + * @dev Computes the worst-case (maximum possible) claim amount based on current on-chain + * agreement state. For active agreements, uses `endsAt` as the upper bound (not block.timestamp). + * Returns 0 for NotAccepted, CanceledByServiceProvider, or fully expired agreements. + * @param agreementId The ID of the agreement + * @return The maximum tokens that could be collected in the next collection + */ + function getMaxNextClaim(bytes16 agreementId) external view returns (uint256); + /** * @notice Get collection info for an agreement * @param agreement The agreement data diff --git a/packages/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol b/packages/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol new file mode 100644 index 000000000..ac3378b28 --- /dev/null +++ b/packages/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol @@ -0,0 +1,324 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.22; + +import { IRecurringCollector } from "../../horizon/IRecurringCollector.sol"; + +/** + * @title Interface for the {IndexingAgreementManager} contract + * @author Edge & Node + * @notice Manages escrow funding for RCAs (Recurring Collection Agreements) using + * issuance-allocated tokens. Tracks the maximum possible next claim for each managed + * RCA per indexer and ensures PaymentsEscrow is always funded to cover those maximums. + * + * One escrow per (IndexingAgreementManager, RecurringCollector, indexer) covering all RCAs for + * that indexer managed by this contract. + * + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +interface IIndexingAgreementManager { + // -- Structs -- + + /** + * @notice Tracked state for a managed agreement + * @param indexer The service provider (indexer) for this agreement + * @param deadline The RCA deadline for acceptance (used to detect expired offers) + * @param exists Whether this agreement is actively tracked + * @param dataService The data service address for this agreement + * @param pendingUpdateNonce The RCAU nonce for the pending update (0 means no pending) + * @param maxNextClaim The current maximum tokens claimable in the next collection + * @param pendingUpdateMaxNextClaim Max next claim for an offered-but-not-yet-applied update + * @param agreementHash The RCA hash stored for cleanup of authorizedHashes on deletion + * @param pendingUpdateHash The RCAU hash stored for cleanup of authorizedHashes on deletion + */ + struct AgreementInfo { + address indexer; + uint64 deadline; + bool exists; + address dataService; + uint32 pendingUpdateNonce; + uint256 maxNextClaim; + uint256 pendingUpdateMaxNextClaim; + bytes32 agreementHash; + bytes32 pendingUpdateHash; + } + + // -- Events -- + // solhint-disable gas-indexed-events + + /** + * @notice Emitted when an agreement is offered for escrow management + * @param agreementId The deterministic agreement ID + * @param indexer The indexer (service provider) for this agreement + * @param maxNextClaim The calculated maximum next claim amount + */ + event AgreementOffered(bytes16 indexed agreementId, address indexed indexer, uint256 maxNextClaim); + + /** + * @notice Emitted when an agreement offer is revoked before acceptance + * @param agreementId The agreement ID + * @param indexer The indexer whose required escrow was reduced + */ + event OfferRevoked(bytes16 indexed agreementId, address indexed indexer); + + /** + * @notice Emitted when an agreement is canceled via the data service + * @param agreementId The agreement ID + * @param indexer The indexer for this agreement + */ + event AgreementCanceled(bytes16 indexed agreementId, address indexed indexer); + + /** + * @notice Emitted when an agreement is removed from escrow management + * @param agreementId The agreement ID being removed + * @param indexer The indexer whose required escrow was reduced + */ + event AgreementRemoved(bytes16 indexed agreementId, address indexed indexer); + + /** + * @notice Emitted when an agreement's max next claim is recalculated + * @param agreementId The agreement ID + * @param oldMaxNextClaim The previous max next claim + * @param newMaxNextClaim The updated max next claim + */ + event AgreementReconciled(bytes16 indexed agreementId, uint256 oldMaxNextClaim, uint256 newMaxNextClaim); + + /** + * @notice Emitted when a pending agreement update is offered + * @param agreementId The agreement ID + * @param pendingMaxNextClaim The max next claim for the pending update + * @param updateNonce The RCAU nonce for the pending update + */ + event AgreementUpdateOffered(bytes16 indexed agreementId, uint256 pendingMaxNextClaim, uint32 updateNonce); + + /** + * @notice Emitted when escrow is funded for an indexer + * @param indexer The indexer whose escrow was funded + * @param requiredEscrow The total required escrow for the indexer + * @param currentBalance The escrow balance after funding + * @param deposited The amount deposited in this transaction + */ + event EscrowFunded(address indexed indexer, uint256 requiredEscrow, uint256 currentBalance, uint256 deposited); + + /** + * @notice Emitted when escrow tokens are thawed for withdrawal + * @param indexer The indexer whose escrow is being thawed + * @param tokens The amount of tokens being thawed + */ + event EscrowThawed(address indexed indexer, uint256 tokens); + + /** + * @notice Emitted when thawed escrow tokens are withdrawn + * @param indexer The indexer whose escrow was withdrawn + */ + event EscrowWithdrawn(address indexed indexer); + + // solhint-enable gas-indexed-events + + // -- Errors -- + + /** + * @notice Thrown when trying to offer an agreement that is already offered + * @param agreementId The agreement ID + */ + error IndexingAgreementManagerAgreementAlreadyOffered(bytes16 agreementId); + + /** + * @notice Thrown when trying to operate on an agreement that is not offered + * @param agreementId The agreement ID + */ + error IndexingAgreementManagerAgreementNotOffered(bytes16 agreementId); + + /** + * @notice Thrown when the RCA payer is not this contract + * @param payer The payer address in the RCA + * @param expected The expected payer (this contract) + */ + error IndexingAgreementManagerPayerMismatch(address payer, address expected); + + /** + * @notice Thrown when trying to remove an agreement that is still claimable + * @param agreementId The agreement ID + * @param maxNextClaim The remaining max next claim + */ + error IndexingAgreementManagerAgreementStillClaimable(bytes16 agreementId, uint256 maxNextClaim); + + /** + * @notice Thrown when trying to revoke an agreement that is already accepted + * @param agreementId The agreement ID + */ + error IndexingAgreementManagerAgreementAlreadyAccepted(bytes16 agreementId); + + /** + * @notice Thrown when trying to cancel an agreement that has not been accepted yet + * @param agreementId The agreement ID + */ + error IndexingAgreementManagerAgreementNotAccepted(bytes16 agreementId); + + /** + * @notice Thrown when an agreement hash is not authorized + * @param agreementHash The hash that was not authorized + */ + error IndexingAgreementManagerAgreementNotAuthorized(bytes32 agreementHash); + + /** + * @notice Thrown when the data service address has no deployed code + * @param dataService The address that was expected to be a contract + */ + error IndexingAgreementManagerInvalidDataService(address dataService); + + /** + * @notice Thrown when maintain is called but the indexer still has agreements + * @param indexer The indexer address + */ + error IndexingAgreementManagerStillHasAgreements(address indexer); + + /** + * @notice Thrown when an RCA has a zero-address service provider or data service + * @param field The name of the invalid field + */ + error IndexingAgreementManagerInvalidRCAField(string field); + + // -- Core Functions -- + + /** + * @notice Offer an RCA for escrow management. Must be called before + * {SubgraphService.acceptUnsignedIndexingAgreement}. + * @dev Calculates max next claim from RCA parameters, stores the authorized hash + * for the {IContractApprover} callback, and funds the escrow. + * @param rca The Recurring Collection Agreement parameters + * @return agreementId The deterministic agreement ID + */ + function offerAgreement( + IRecurringCollector.RecurringCollectionAgreement calldata rca + ) external returns (bytes16 agreementId); + + /** + * @notice Offer a pending agreement update for escrow management. Must be called + * before {SubgraphService.updateUnsignedIndexingAgreement}. + * @dev Stores the authorized RCAU hash for the {IContractApprover} callback and + * adds the pending update's max next claim to the required escrow. Treats the + * pending update as a separate escrow entry alongside the current agreement. + * If a previous pending update exists, it is replaced. + * @param rcau The Recurring Collection Agreement Update parameters + * @return agreementId The agreement ID from the RCAU + */ + function offerAgreementUpdate( + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau + ) external returns (bytes16 agreementId); + + /** + * @notice Revoke an un-accepted agreement offer. Only for agreements not yet + * accepted in RecurringCollector. + * @dev Requires OPERATOR_ROLE. Clears the agreement tracking and authorized hashes, + * freeing the reserved escrow. Any pending update is also cleared. + * @param agreementId The agreement ID to revoke + */ + function revokeOffer(bytes16 agreementId) external; + + /** + * @notice Cancel an accepted agreement by routing through the data service. + * @dev Requires OPERATOR_ROLE. Reads agreement state from RecurringCollector: + * - NotAccepted: reverts (use {revokeOffer} instead) + * - Accepted: cancels via the data service, then reconciles and funds escrow + * - Already canceled: idempotent — reconciles and funds escrow without re-canceling + * After cancellation, call {removeAgreement} once the collection window closes. + * @param agreementId The agreement ID to cancel + */ + function cancelAgreement(bytes16 agreementId) external; + + /** + * @notice Remove a fully expired agreement from tracking. + * @dev Permissionless. Only succeeds when the agreement's max next claim is 0 (no more + * collections possible). This covers: CanceledByServiceProvider (immediate), + * CanceledByPayer (after window expires), active agreements past endsAt, and + * NotAccepted offers past their deadline. + * @param agreementId The agreement ID to remove + */ + function removeAgreement(bytes16 agreementId) external; + + /** + * @notice Reconcile a single agreement. Re-reads agreement state from + * RecurringCollector, recalculates max next claim, and tops up escrow. + * @dev Permissionless. This is the primary reconciliation function — gas-predictable, + * per-agreement. Skips if agreement is not yet accepted in RecurringCollector. + * Should be called after collections, cancellations, or agreement updates. + * @param agreementId The agreement ID to reconcile + */ + function reconcileAgreement(bytes16 agreementId) external; + + /** + * @notice Reconcile all agreements for an indexer (convenience function). + * @dev Permissionless. Iterates all tracked agreements for the indexer — O(n) gas, + * may hit gas limits with many agreements. Prefer reconcileAgreement for individual + * updates, or reconcileBatch for controlled batching. + * @param indexer The indexer to reconcile + */ + function reconcile(address indexer) external; + + /** + * @notice Reconcile a batch of agreements (operator-controlled batching). + * @dev Permissionless. Allows callers to control gas usage by choosing which + * agreements to reconcile in a single transaction. + * @param agreementIds The agreement IDs to reconcile + */ + function reconcileBatch(bytes16[] calldata agreementIds) external; + + /** + * @notice Maintain escrow for an indexer with no remaining agreements. + * @dev Permissionless. Two-phase operation: + * - If a previous thaw has completed: withdraws tokens back to this contract + * - If escrow balance remains: initiates a thaw for the available balance + * Only operates when the indexer has zero tracked agreements. Guards against + * reducing an in-progress thaw. + * @param indexer The indexer to maintain + */ + function maintain(address indexer) external; + + // -- View Functions -- + + /** + * @notice Get the total required escrow for an indexer + * @param indexer The indexer address + * @return The sum of max next claims for all managed agreements for this indexer + */ + function getRequiredEscrow(address indexer) external view returns (uint256); + + /** + * @notice Get the current escrow deficit for an indexer + * @dev Returns 0 if escrow is fully funded or over-funded. + * @param indexer The indexer address + * @return The deficit amount (required - current balance), or 0 if no deficit + */ + function getDeficit(address indexer) external view returns (uint256); + + /** + * @notice Get the max next claim for a specific agreement + * @param agreementId The agreement ID + * @return The current max next claim stored for this agreement + */ + function getAgreementMaxNextClaim(bytes16 agreementId) external view returns (uint256); + + /** + * @notice Get the full tracked state for a specific agreement + * @param agreementId The agreement ID + * @return The agreement info struct (all fields zero if not tracked) + */ + function getAgreementInfo(bytes16 agreementId) external view returns (AgreementInfo memory); + + /** + * @notice Get the number of managed agreements for an indexer + * @param indexer The indexer address + * @return The count of tracked agreements + */ + function getIndexerAgreementCount(address indexer) external view returns (uint256); + + /** + * @notice Get all managed agreement IDs for an indexer + * @dev Returns the full set of tracked agreement IDs. May be expensive for indexers + * with many agreements — prefer {getIndexerAgreementCount} for on-chain use. + * @param indexer The indexer address + * @return The array of agreement IDs + */ + function getIndexerAgreements(address indexer) external view returns (bytes16[] memory); +} diff --git a/packages/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol b/packages/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol index b43bc948a..90a311556 100644 --- a/packages/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol +++ b/packages/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol @@ -15,6 +15,9 @@ interface IIssuanceTarget { */ event IssuanceAllocatorSet(address indexed oldIssuanceAllocator, address indexed newIssuanceAllocator); + /// @notice Emitted before the issuance allocation changes + event BeforeIssuanceAllocationChange(); + /** * @notice Called by the issuance allocator before the target's issuance allocation changes * @dev The target should ensure that all issuance related calculations are up-to-date diff --git a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol index f169dce42..1a7bd7195 100644 --- a/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol +++ b/packages/interfaces/contracts/subgraph-service/ISubgraphService.sol @@ -3,6 +3,7 @@ pragma solidity ^0.8.22; import { IDataServiceFees } from "../data-service/IDataServiceFees.sol"; import { IGraphPayments } from "../horizon/IGraphPayments.sol"; + import { IRecurringCollector } from "../horizon/IRecurringCollector.sol"; import { IAllocation } from "./internal/IAllocation.sol"; @@ -276,6 +277,18 @@ interface ISubgraphService is IDataServiceFees { IRecurringCollector.SignedRCA calldata signedRCA ) external returns (bytes16); + /** + * @notice Accept an indexing agreement where the payer is a contract. + * @dev The payer must implement {IContractApprover} and authorize the RCA hash. + * @param allocationId The id of the allocation + * @param rca The recurring collection agreement parameters + * @return agreementId The ID of the accepted indexing agreement + */ + function acceptUnsignedIndexingAgreement( + address allocationId, + IRecurringCollector.RecurringCollectionAgreement calldata rca + ) external returns (bytes16); + /** * @notice Update an indexing agreement. * @param indexer The address of the indexer @@ -283,6 +296,17 @@ interface ISubgraphService is IDataServiceFees { */ function updateIndexingAgreement(address indexer, IRecurringCollector.SignedRCAU calldata signedRCAU) external; + /** + * @notice Update an indexing agreement where the payer is a contract. + * @dev The payer must implement {IContractApprover} and authorize the RCAU hash. + * @param indexer The address of the indexer + * @param rcau The recurring collector agreement update to apply + */ + function updateUnsignedIndexingAgreement( + address indexer, + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau + ) external; + /** * @notice Cancel an indexing agreement by indexer / operator. * @param indexer The address of the indexer diff --git a/packages/issuance/README.md b/packages/issuance/README.md index 0209e2d97..125768090 100644 --- a/packages/issuance/README.md +++ b/packages/issuance/README.md @@ -11,6 +11,7 @@ The issuance contracts handle token issuance mechanisms for The Graph protocol. - **[IssuanceAllocator](contracts/allocate/IssuanceAllocator.md)** - Central distribution hub for token issuance, allocating tokens to different protocol components based on configured rates - **[RewardsEligibilityOracle](contracts/eligibility/RewardsEligibilityOracle.md)** - Oracle-based eligibility system for indexer rewards with time-based expiration - **DirectAllocation** - Simple target contract implementation for receiving and distributing allocated tokens (deployed as PilotAllocation and other instances) +- **[IndexingAgreementManager](contracts/allocate/IndexingAgreementManager.md)** - Funds PaymentsEscrow deposits for RCAs using issuance tokens, tracking max-next-claim per agreement per indexer ## Development diff --git a/packages/issuance/contracts/allocate/DirectAllocation.sol b/packages/issuance/contracts/allocate/DirectAllocation.sol index 4c048acf2..2b66e7fac 100644 --- a/packages/issuance/contracts/allocate/DirectAllocation.sol +++ b/packages/issuance/contracts/allocate/DirectAllocation.sol @@ -38,9 +38,6 @@ contract DirectAllocation is BaseUpgradeable, IIssuanceTarget, ISendTokens { event TokensSent(address indexed to, uint256 indexed amount); // Do not need to index amount, ignoring gas-indexed-events warning. - /// @notice Emitted before the issuance allocation changes - event BeforeIssuanceAllocationChange(); - // -- Constructor -- /** @@ -90,7 +87,7 @@ contract DirectAllocation is BaseUpgradeable, IIssuanceTarget, ISendTokens { * @inheritdoc IIssuanceTarget */ function beforeIssuanceAllocationChange() external virtual override { - emit BeforeIssuanceAllocationChange(); + emit IIssuanceTarget.BeforeIssuanceAllocationChange(); } /** diff --git a/packages/issuance/contracts/allocate/IndexingAgreementManager.md b/packages/issuance/contracts/allocate/IndexingAgreementManager.md new file mode 100644 index 000000000..cc7f7a465 --- /dev/null +++ b/packages/issuance/contracts/allocate/IndexingAgreementManager.md @@ -0,0 +1,326 @@ +# IndexingAgreementManager + +The IndexingAgreementManager is a smart contract that funds PaymentsEscrow deposits for Recurring Collection Agreements (RCAs) using tokens received from the IssuanceAllocator. It tracks the maximum possible next claim for each managed RCA and keeps the escrow balance for each indexer equal to the sum of those maximums. + +## Overview + +When the protocol needs to pay indexers via RCAs, someone must be the "payer" who deposits tokens into PaymentsEscrow. The IndexingAgreementManager fills this role for protocol-funded agreements: it receives minted GRT from IssuanceAllocator and uses it to maintain escrow balances sufficient to cover worst-case collection amounts. + +### Problem + +RCA-based payments require escrow pre-funding. The payer must deposit enough tokens to cover the maximum that could be collected in the next collection window. Without automation, this requires manual deposits and monitoring. + +### Solution + +IndexingAgreementManager automates this by: + +1. Receiving issuance tokens (via `IIssuanceTarget`, like DirectAllocation) +2. Acting as the RCA payer (via `IContractApprover` callback) +3. Tracking per-agreement max-next-claim and funding escrow to cover totals + +## Architecture + +### Contract Interfaces + +IndexingAgreementManager implements three interfaces: + +| Interface | Purpose | +| --------------------------- | ----------------------------------------------------------------------------- | +| `IIssuanceTarget` | Receives minted GRT from IssuanceAllocator | +| `IContractApprover` | Authorizes RCA acceptance and updates via callback (replaces ECDSA signature) | +| `IIndexingAgreementManager` | Core escrow management functions | + +### Escrow Structure + +One escrow account per (IndexingAgreementManager, RecurringCollector, indexer) tuple covers **all** managed RCAs for that indexer. This means multiple agreements for the same indexer share a single escrow balance. + +``` +PaymentsEscrow.escrowAccounts[IndexingAgreementManager][RecurringCollector][indexer] + >= sum(maxNextClaim + pendingUpdateMaxNextClaim for all active agreements for that indexer) +``` + +### Roles + +- **GOVERNOR_ROLE**: Can set the issuance allocator reference (IIssuanceTarget requirement) +- **OPERATOR_ROLE**: Can offer agreements/updates, revoke offers, and cancel agreements +- **PAUSE_ROLE**: Can pause contract operations (inherited from BaseUpgradeable) +- **Permissionless**: `reconcile`, `reconcileAgreement`, `reconcileBatch`, `removeAgreement`, and `maintain` can be called by anyone + +### Storage (ERC-7201) + +```solidity +struct IndexingAgreementManagerStorage { + mapping(bytes32 => bytes16) authorizedHashes; // Hash → agreementId (for IContractApprover callback) + mapping(bytes16 => AgreementInfo) agreements; // Per-agreement tracking + mapping(address => uint256) requiredEscrow; // Sum of maxNextClaims per indexer + mapping(address => EnumerableSet.Bytes32Set) indexerAgreementIds; // Agreement set per indexer + mapping(address => bool) thawing; // Thaw-in-progress flag per indexer +} +``` + +### Hash Authorization + +The `authorizedHashes` mapping stores `hash → agreementId` rather than `hash → bool`. When `isAuthorizedAgreement(hash)` is called, it checks both that the hash maps to a valid agreementId **and** that the corresponding agreement still exists. This means: + +- Hashes are automatically invalidated when agreements are deleted (via `revokeOffer` or `removeAgreement`) +- No explicit hash cleanup is needed +- Each hash is tied to a specific agreement, preventing hash reuse across agreements + +## Max Next Claim + +The max-next-claim calculation is delegated to `RecurringCollector.getMaxNextClaim(agreementId)` for accepted agreements. This provides a single source of truth and avoids divergence between IndexingAgreementManager's estimate and the actual collection logic. + +For **pre-accepted** (NotAccepted) agreements, IndexingAgreementManager uses a conservative estimate calculated at offer time: + +``` +maxNextClaim = maxOngoingTokensPerSecond * maxSecondsPerCollection + maxInitialTokens +``` + +### State-Dependent Results + +| Agreement State | maxNextClaim | +| --------------------------- | -------------------------------------------------------------- | +| NotAccepted (pre-offered) | Uses stored estimate from `offerAgreement` | +| NotAccepted (past deadline) | 0 (expired offer, removable) | +| Accepted, never collected | Calculated by RecurringCollector (includes initial + ongoing) | +| Accepted, after collect | Calculated by RecurringCollector (ongoing only) | +| CanceledByPayer | Calculated by RecurringCollector (window frozen at canceledAt) | +| CanceledByServiceProvider | 0 | +| Fully expired | 0 | + +## Lifecycle + +### 1. Offer Agreement + +``` +Operator calls offerAgreement(rca) +``` + +- Validates `rca.payer == address(this)` (IndexingAgreementManager is the payer) +- Computes `agreementId` deterministically from RCA fields +- Calculates initial `maxNextClaim = maxOngoingTokensPerSecond * maxSecondsPerCollection + maxInitialTokens` +- Stores the agreement hash for the `IContractApprover` callback (hash → agreementId) +- Cancels any in-progress thaw for this indexer (new agreement needs funded escrow) +- Funds the escrow (deposits deficit from available token balance) + +### 2. Accept Agreement + +``` +Indexer operator calls SubgraphService.acceptUnsignedIndexingAgreement(allocationId, rca) +``` + +- SubgraphService calls `RecurringCollector.acceptUnsigned(rca)` +- RecurringCollector calls `agreementManager.isAuthorizedAgreement(hash)` to verify (via `rca.payer`) +- IndexingAgreementManager confirms (returns magic value) because the hash was stored in step 1 +- Agreement is now accepted in RecurringCollector + +**Ordering**: `offerAgreement` **must** be called before `acceptUnsignedIndexingAgreement`. The two-step flow ensures escrow is funded before the agreement becomes active. + +### 3. Offer Agreement Update + +``` +Operator calls offerAgreementUpdate(rcau) +``` + +- Validates the agreement exists (was previously offered) +- Calculates `pendingMaxNextClaim = maxOngoingTokensPerSecond * maxSecondsPerCollection + maxInitialTokens` from the RCAU parameters +- If replacing a previous pending update, removes the old pending from `requiredEscrow` +- Stores the RCAU hash for the `IContractApprover` callback (hash → agreementId) +- Adds `pendingMaxNextClaim` to `requiredEscrow` (conservative: both current and pending are funded) +- Funds the escrow + +### 4. Accept Agreement Update + +``` +Indexer operator calls SubgraphService.updateUnsignedIndexingAgreement(indexer, rcau) +``` + +- SubgraphService calls `RecurringCollector.updateUnsigned(rcau)` +- RecurringCollector calls `agreementManager.isAuthorizedAgreement(hash)` to verify +- IndexingAgreementManager confirms because the hash was stored in step 3 +- Agreement terms are updated in RecurringCollector + +**After acceptance**: call `reconcile` to clear the pending update and recalculate the escrow requirement based on the new terms. + +### 5. Collect + +``` +SubgraphService.collect() -> RecurringCollector -> PaymentsEscrow.collect() +``` + +- Escrow balance decreases by `tokensCollected` +- No callback to IndexingAgreementManager (PaymentsEscrow has no post-collect hook) +- After collection, `reconcile` should be called to recalculate and top up + +### 6. Reconcile + +``` +Anyone calls reconcileAgreement(agreementId), reconcileBatch(ids), or reconcile(indexer) +``` + +**reconcileAgreement** (primary — gas-predictable, per-agreement): + +- Re-reads agreement state from RecurringCollector +- Clears pending updates if they have been applied on-chain (checks `updateNonce`) +- Recalculates `maxNextClaim` via `RecurringCollector.getMaxNextClaim(agreementId)` +- Updates `requiredEscrow` (sum adjustment) +- Deposits deficit from available token balance +- Skips NotAccepted agreements (preserves pre-offered estimate) + +**reconcileBatch** (controlled batching): + +- Two-pass design: first reconciles all agreements, then funds each unique indexer once +- Reconciles a caller-selected list of agreement IDs in a single transaction +- Skips non-existent agreements silently + +**reconcile(indexer)** (convenience — O(n) gas): + +- Iterates all tracked agreements for the indexer +- May hit gas limits with many agreements; prefer `reconcileAgreement` or `reconcileBatch` + +Reconciliation is important after: + +- A collection occurs (reduces the required escrow after initial tokens are claimed) +- An agreement is canceled +- An RCAU (agreement update) is applied (clears pending update, recalculates from new terms) +- Time passes and the remaining collection window shrinks + +### 7. Revoke Offer + +``` +Operator calls revokeOffer(agreementId) +``` + +- Only for un-accepted agreements (NotAccepted state in RecurringCollector) +- Removes the agreement from tracking and reduces `requiredEscrow` +- Also clears any pending update for the agreement +- Authorized hashes are automatically invalidated (hash → deleted agreementId) + +Use when an offer should be withdrawn before the indexer accepts it. + +### 8. Cancel Agreement + +``` +Operator calls cancelAgreement(agreementId) +``` + +State-dependent behavior: + +| Agreement State | Behavior | +| ------------------------- | ------------------------------------------------------------------------------- | +| NotAccepted | Reverts with `IndexingAgreementManagerAgreementNotAccepted` (use `revokeOffer`) | +| Accepted | Cancels via data service, then reconciles and funds escrow | +| CanceledByPayer | Idempotent: reconciles and funds escrow (already canceled) | +| CanceledByServiceProvider | Idempotent: reconciles and funds escrow (already canceled) | + +For Accepted agreements: + +- Validates the data service has deployed code (`IndexingAgreementManagerInvalidDataService` if not) +- Routes cancellation through `ISubgraphService(dataService).cancelIndexingAgreementByPayer(agreementId)` +- The data service's `cancelByPayer` checks that `msg.sender == payer` (IndexingAgreementManager) +- After cancellation, automatically reconciles and funds escrow +- Once the collection window closes, call `removeAgreement` to clean up + +For already-canceled agreements, calling `cancelAgreement` is idempotent — it skips the data service call and just reconciles/funds, which is useful for updating escrow tracking. + +### 9. Remove Agreement + +``` +Anyone calls removeAgreement(agreementId) +``` + +- Only succeeds when the current max next claim is 0 (no more claims possible) +- For accepted agreements: delegates to `RecurringCollector.getMaxNextClaim(agreementId)` +- For NotAccepted agreements: removable only if the offer deadline has passed +- Removes tracking data (including any pending update) and reduces `requiredEscrow` +- Permissionless: anyone can remove expired agreements to keep state clean + +This covers: + +| State | Removable when | +| ------------------------- | ------------------------------------- | +| CanceledByServiceProvider | Immediately (maxNextClaim = 0) | +| CanceledByPayer | After collection window expires | +| Accepted past endsAt | After final collection window expires | +| NotAccepted (expired) | After `rca.deadline` passes | + +### 10. Maintain (Thaw/Withdraw) + +``` +Anyone calls maintain(indexer) +``` + +Two-phase operation for recovering excess escrow when an indexer has no remaining agreements: + +**Phase 1 — Thaw**: If there is available balance in PaymentsEscrow, initiates a thaw for the full amount. Sets the `thawing` flag. + +**Phase 2 — Withdraw**: If a previous thaw has completed (thawing period elapsed), withdraws tokens back to IndexingAgreementManager. Then checks for any remaining balance and starts a new thaw if needed. + +Guards: + +- Reverts if the indexer still has tracked agreements (`IndexingAgreementManagerStillHasAgreements`) +- If a thaw is in progress but not yet complete, returns early (no-op) +- If a new agreement is offered for an indexer during thawing, `offerAgreement` calls `cancelThaw` and resets the flag + +## Post-Collection Economics + +After a collection of `tokensCollected` from agreement A for indexer I: + +| Scenario | Required After Reconcile | Balance After Collect | Deficit to Fund | +| ------------------ | --------------------------- | -------------------------- | ---------------------------------- | +| First collection | Required - maxInitialTokens | Required - tokensCollected | tokensCollected - maxInitialTokens | +| Ongoing collection | Required (unchanged) | Required - tokensCollected | tokensCollected | + +For ongoing collections, the deficit equals `tokensCollected`. The issuance rate should be calibrated to generate enough tokens between collections to cover this. + +## Funding Behavior + +The `_fundEscrow` function deposits the minimum of the deficit and the available token balance: + +``` +toDeposit = min(deficit, GRAPH_TOKEN.balanceOf(address(this))) +``` + +This means: + +- **Never reverts** due to insufficient tokens +- Deposits what's available, even if it's less than the full deficit +- `getDeficit(indexer)` view function exposes shortfall for monitoring +- Issuance from IssuanceAllocator should keep the balance topped up between collections + +## Security Considerations + +- **Operator trust**: Only `OPERATOR_ROLE` can offer agreements/updates, revoke offers, and cancel agreements — controlling which RCAs the contract pays for +- **Permissionless reconcile/remove/maintain**: Anyone can call these to ensure the system stays current (no griefing risk since they only improve state accuracy or recover excess escrow) +- **Pre-offer ordering**: The hash must be stored before acceptance, preventing unauthorized agreements +- **Hash-to-agreement binding**: Authorized hashes map to specific agreementIds and are automatically invalidated when the agreement is deleted +- **Conservative estimates**: Pre-offered maxNextClaim overestimates (includes both initial and ongoing), ensuring sufficient funding +- **Pending update double-funding**: Both current and pending maxNextClaim are funded simultaneously, ensuring coverage regardless of which terms are active +- **Thaw guard**: The `thawing` flag prevents initiating a new thaw for less than an in-progress thaw. New agreements cancel any in-progress thaw via `cancelThaw` +- **Expired offer handling**: Offers that pass their RCA deadline without acceptance become removable, preventing permanent escrow lock-up +- **Pause**: When paused, `offerAgreement`, `offerAgreementUpdate`, `revokeOffer`, and `cancelAgreement` are blocked but reconcile/remove/maintain remain available + +## Deployment + +### Prerequisites + +- GraphToken deployed +- PaymentsEscrow deployed +- RecurringCollector deployed +- IssuanceAllocator deployed and configured + +### Deployment Sequence + +1. Deploy IndexingAgreementManager implementation with constructor args (graphToken, paymentsEscrow, recurringCollector) +2. Deploy TransparentUpgradeableProxy with implementation and initialization data +3. Initialize with governor address +4. Grant `OPERATOR_ROLE` to the operator account that will offer agreements +5. Configure IssuanceAllocator to allocate tokens to IndexingAgreementManager (as an allocator-minting target) + +### Verification + +- Verify `PAYMENTS_ESCROW` and `RECURRING_COLLECTOR` immutables are set correctly +- Verify governor has `GOVERNOR_ROLE` +- Verify operator has `OPERATOR_ROLE` +- Verify IndexingAgreementManager supports `IIssuanceTarget`, `IContractApprover`, and `IIndexingAgreementManager` interfaces +- Verify GRT token approvals work correctly with PaymentsEscrow diff --git a/packages/issuance/contracts/allocate/IndexingAgreementManager.sol b/packages/issuance/contracts/allocate/IndexingAgreementManager.sol new file mode 100644 index 000000000..68dbdaf1a --- /dev/null +++ b/packages/issuance/contracts/allocate/IndexingAgreementManager.sol @@ -0,0 +1,508 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity 0.8.33; + +import { EnumerableSet } from "@openzeppelin/contracts/utils/structs/EnumerableSet.sol"; + +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; +import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { ISubgraphService } from "@graphprotocol/interfaces/contracts/subgraph-service/ISubgraphService.sol"; + +import { BaseUpgradeable } from "../common/BaseUpgradeable.sol"; + +// solhint-disable-next-line no-unused-import +import { ERC165Upgradeable } from "@openzeppelin/contracts-upgradeable/utils/introspection/ERC165Upgradeable.sol"; // Used by @inheritdoc + +/** + * @title IndexingAgreementManager + * @author Edge & Node + * @notice Manages escrow funding for RCAs (Recurring Collection Agreements) using + * issuance-allocated tokens. This contract: + * + * 1. Receives minted GRT from IssuanceAllocator (implements IIssuanceTarget) + * 2. Authorizes RCA acceptance via contract callback (implements IContractApprover) + * 3. Tracks max-next-claim per agreement, funds PaymentsEscrow to cover maximums + * + * One escrow per (this contract, RecurringCollector, indexer) covers all managed + * RCAs for that indexer. Other participants can independently use RCAs via the + * standard ECDSA-signed flow. + * + * @custom:security-contact Please email security+contracts@thegraph.com if you find any + * bugs. We may have an active bug bounty program. + */ +contract IndexingAgreementManager is BaseUpgradeable, IIssuanceTarget, IContractApprover, IIndexingAgreementManager { + using EnumerableSet for EnumerableSet.Bytes32Set; + + // -- Immutables -- + + /// @notice The PaymentsEscrow contract + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + IPaymentsEscrow public immutable PAYMENTS_ESCROW; + + /// @notice The RecurringCollector contract + /// @custom:oz-upgrades-unsafe-allow state-variable-immutable + IRecurringCollector public immutable RECURRING_COLLECTOR; + + // -- Storage (ERC-7201) -- + + /// @custom:storage-location erc7201:graphprotocol.issuance.storage.IndexingAgreementManager + struct IndexingAgreementManagerStorage { + /// @notice Authorized agreement hashes — maps hash to agreementId (bytes16(0) = not authorized) + mapping(bytes32 agreementHash => bytes16) authorizedHashes; + /// @notice Per-agreement tracking data + mapping(bytes16 agreementId => AgreementInfo) agreements; + /// @notice Sum of maxNextClaim for all agreements per indexer + mapping(address indexer => uint256) requiredEscrow; + /// @notice Set of agreement IDs per indexer (stored as bytes32 for EnumerableSet) + mapping(address indexer => EnumerableSet.Bytes32Set) indexerAgreementIds; + /// @notice Whether a thaw has been initiated for an indexer's escrow + mapping(address indexer => bool) thawing; + } + + // solhint-disable-next-line gas-named-return-values + // keccak256(abi.encode(uint256(keccak256("graphprotocol.issuance.storage.IndexingAgreementManager")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant INDEXING_AGREEMENT_MANAGER_STORAGE_LOCATION = + 0x479ba94faf2fd6cabf7893623bfa7a552c10e95e15de10bc58f1e58f2bb8fb00; + + // -- Constructor -- + + /** + * @notice Constructor for the IndexingAgreementManager contract + * @param graphToken Address of the Graph Token contract + * @param paymentsEscrow Address of the PaymentsEscrow contract + * @param recurringCollector Address of the RecurringCollector contract + * @custom:oz-upgrades-unsafe-allow constructor + */ + constructor(address graphToken, address paymentsEscrow, address recurringCollector) BaseUpgradeable(graphToken) { + PAYMENTS_ESCROW = IPaymentsEscrow(paymentsEscrow); + RECURRING_COLLECTOR = IRecurringCollector(recurringCollector); + } + + // -- Initialization -- + + /** + * @notice Initialize the IndexingAgreementManager contract + * @param governor Address that will have the GOVERNOR_ROLE + */ + function initialize(address governor) external virtual initializer { + __BaseUpgradeable_init(governor); + } + + // -- ERC165 -- + + /// @inheritdoc ERC165Upgradeable + function supportsInterface(bytes4 interfaceId) public view virtual override returns (bool) { + return + interfaceId == type(IIssuanceTarget).interfaceId || + interfaceId == type(IContractApprover).interfaceId || + interfaceId == type(IIndexingAgreementManager).interfaceId || + super.supportsInterface(interfaceId); + } + + // -- IIssuanceTarget -- + + /// @inheritdoc IIssuanceTarget + function beforeIssuanceAllocationChange() external virtual override { + emit IIssuanceTarget.BeforeIssuanceAllocationChange(); + } + + /// @inheritdoc IIssuanceTarget + /// @dev No-op: IndexingAgreementManager receives tokens via transfer, does not need the allocator address. + function setIssuanceAllocator(address /* issuanceAllocator */) external virtual override onlyRole(GOVERNOR_ROLE) {} + + // -- IContractApprover -- + + /// @inheritdoc IContractApprover + function isAuthorizedAgreement(bytes32 agreementHash) external view override returns (bytes4) { + IndexingAgreementManagerStorage storage $ = _getStorage(); + bytes16 agreementId = $.authorizedHashes[agreementHash]; + require( + agreementId != bytes16(0) && $.agreements[agreementId].exists, + IndexingAgreementManagerAgreementNotAuthorized(agreementHash) + ); + return IContractApprover.isAuthorizedAgreement.selector; + } + + // -- IIndexingAgreementManager: Core Functions -- + + /// @inheritdoc IIndexingAgreementManager + function offerAgreement( + IRecurringCollector.RecurringCollectionAgreement calldata rca + ) external onlyRole(OPERATOR_ROLE) whenNotPaused returns (bytes16 agreementId) { + require(rca.payer == address(this), IndexingAgreementManagerPayerMismatch(rca.payer, address(this))); + require(rca.serviceProvider != address(0), IndexingAgreementManagerInvalidRCAField("serviceProvider")); + require(rca.dataService != address(0), IndexingAgreementManagerInvalidRCAField("dataService")); + + IndexingAgreementManagerStorage storage $ = _getStorage(); + + agreementId = RECURRING_COLLECTOR.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + + require(!$.agreements[agreementId].exists, IndexingAgreementManagerAgreementAlreadyOffered(agreementId)); + + // Cancel any in-progress thaw for this indexer (new agreement needs funded escrow) + if ($.thawing[rca.serviceProvider]) { + PAYMENTS_ESCROW.cancelThaw(address(RECURRING_COLLECTOR), rca.serviceProvider); + $.thawing[rca.serviceProvider] = false; + } + + // Calculate max next claim from RCA parameters (pre-acceptance, so use initial + ongoing) + uint256 maxNextClaim = rca.maxOngoingTokensPerSecond * rca.maxSecondsPerCollection + rca.maxInitialTokens; + + // Authorize the agreement hash for the IContractApprover callback + bytes32 agreementHash = RECURRING_COLLECTOR.hashRCA(rca); + $.authorizedHashes[agreementHash] = agreementId; + + // Store agreement tracking data + $.agreements[agreementId] = AgreementInfo({ + indexer: rca.serviceProvider, + deadline: rca.deadline, + exists: true, + dataService: rca.dataService, + pendingUpdateNonce: 0, + maxNextClaim: maxNextClaim, + pendingUpdateMaxNextClaim: 0, + agreementHash: agreementHash, + pendingUpdateHash: bytes32(0) + }); + $.indexerAgreementIds[rca.serviceProvider].add(bytes32(agreementId)); + $.requiredEscrow[rca.serviceProvider] += maxNextClaim; + + // Fund the escrow + _fundEscrow($, rca.serviceProvider); + + emit AgreementOffered(agreementId, rca.serviceProvider, maxNextClaim); + } + + /// @inheritdoc IIndexingAgreementManager + function offerAgreementUpdate( + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau + ) external onlyRole(OPERATOR_ROLE) whenNotPaused returns (bytes16 agreementId) { + agreementId = rcau.agreementId; + IndexingAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.exists, IndexingAgreementManagerAgreementNotOffered(agreementId)); + + // Calculate pending max next claim from RCAU parameters (conservative: includes initial + ongoing) + uint256 pendingMaxNextClaim = rcau.maxOngoingTokensPerSecond * rcau.maxSecondsPerCollection + + rcau.maxInitialTokens; + + // If replacing an existing pending update, remove old pending from requiredEscrow and clean up hash + if (info.pendingUpdateHash != bytes32(0)) { + $.requiredEscrow[info.indexer] -= info.pendingUpdateMaxNextClaim; + delete $.authorizedHashes[info.pendingUpdateHash]; + } + + // Authorize the RCAU hash for the IContractApprover callback + bytes32 updateHash = RECURRING_COLLECTOR.hashRCAU(rcau); + $.authorizedHashes[updateHash] = agreementId; + + // Store pending update tracking + info.pendingUpdateMaxNextClaim = pendingMaxNextClaim; + info.pendingUpdateNonce = rcau.nonce; + info.pendingUpdateHash = updateHash; + $.requiredEscrow[info.indexer] += pendingMaxNextClaim; + + // Fund the escrow + _fundEscrow($, info.indexer); + + emit AgreementUpdateOffered(agreementId, pendingMaxNextClaim, rcau.nonce); + } + + /// @inheritdoc IIndexingAgreementManager + function revokeOffer(bytes16 agreementId) external onlyRole(OPERATOR_ROLE) whenNotPaused { + IndexingAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.exists, IndexingAgreementManagerAgreementNotOffered(agreementId)); + + // Only revoke un-accepted agreements — accepted ones must be canceled via cancelAgreement + IRecurringCollector.AgreementData memory agreement = RECURRING_COLLECTOR.getAgreement(agreementId); + require( + agreement.state == IRecurringCollector.AgreementState.NotAccepted, + IndexingAgreementManagerAgreementAlreadyAccepted(agreementId) + ); + + address indexer = info.indexer; + uint256 totalToRemove = info.maxNextClaim + info.pendingUpdateMaxNextClaim; + + // Clean up authorized hashes + delete $.authorizedHashes[info.agreementHash]; + if (info.pendingUpdateHash != bytes32(0)) { + delete $.authorizedHashes[info.pendingUpdateHash]; + } + + // Clean up storage + $.requiredEscrow[indexer] -= totalToRemove; + $.indexerAgreementIds[indexer].remove(bytes32(agreementId)); + delete $.agreements[agreementId]; + + emit OfferRevoked(agreementId, indexer); + } + + /// @inheritdoc IIndexingAgreementManager + function cancelAgreement(bytes16 agreementId) external onlyRole(OPERATOR_ROLE) whenNotPaused { + IndexingAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.exists, IndexingAgreementManagerAgreementNotOffered(agreementId)); + + IRecurringCollector.AgreementData memory agreement = RECURRING_COLLECTOR.getAgreement(agreementId); + + // Not accepted — use revokeOffer instead + require( + agreement.state != IRecurringCollector.AgreementState.NotAccepted, + IndexingAgreementManagerAgreementNotAccepted(agreementId) + ); + + // If still active, route cancellation through the data service + if (agreement.state == IRecurringCollector.AgreementState.Accepted) { + address ds = info.dataService; + require(ds.code.length != 0, IndexingAgreementManagerInvalidDataService(ds)); + ISubgraphService(ds).cancelIndexingAgreementByPayer(agreementId); + emit AgreementCanceled(agreementId, info.indexer); + } + // else: already canceled (CanceledByPayer or CanceledByServiceProvider) — skip cancel call, just reconcile + + // Reconcile to update escrow requirements after cancellation + _reconcileAgreement($, agreementId); + _fundEscrow($, info.indexer); + } + + /// @inheritdoc IIndexingAgreementManager + function removeAgreement(bytes16 agreementId) external { + IndexingAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.exists, IndexingAgreementManagerAgreementNotOffered(agreementId)); + + // Re-read from RecurringCollector to get current state + IRecurringCollector.AgreementData memory agreement = RECURRING_COLLECTOR.getAgreement(agreementId); + + // Calculate current max next claim - must be 0 to remove + uint256 currentMaxClaim; + if (agreement.state == IRecurringCollector.AgreementState.NotAccepted) { + // Not yet accepted — removable only if offer deadline has passed + // solhint-disable-next-line gas-strict-inequalities + if (block.timestamp <= info.deadline) { + currentMaxClaim = info.maxNextClaim; + } + // else: deadline passed, currentMaxClaim stays 0 (expired offer) + } else { + currentMaxClaim = RECURRING_COLLECTOR.getMaxNextClaim(agreementId); + } + require(currentMaxClaim == 0, IndexingAgreementManagerAgreementStillClaimable(agreementId, currentMaxClaim)); + + address indexer = info.indexer; + uint256 totalToRemove = info.maxNextClaim + info.pendingUpdateMaxNextClaim; + + // Clean up authorized hashes + delete $.authorizedHashes[info.agreementHash]; + if (info.pendingUpdateHash != bytes32(0)) { + delete $.authorizedHashes[info.pendingUpdateHash]; + } + + // Clean up storage + $.requiredEscrow[indexer] -= totalToRemove; + $.indexerAgreementIds[indexer].remove(bytes32(agreementId)); + delete $.agreements[agreementId]; + + emit AgreementRemoved(agreementId, indexer); + } + + /// @inheritdoc IIndexingAgreementManager + function reconcileAgreement(bytes16 agreementId) external { + IndexingAgreementManagerStorage storage $ = _getStorage(); + AgreementInfo storage info = $.agreements[agreementId]; + require(info.exists, IndexingAgreementManagerAgreementNotOffered(agreementId)); + + _reconcileAgreement($, agreementId); + _fundEscrow($, info.indexer); + } + + /// @inheritdoc IIndexingAgreementManager + function reconcile(address indexer) external { + IndexingAgreementManagerStorage storage $ = _getStorage(); + EnumerableSet.Bytes32Set storage agreementIds = $.indexerAgreementIds[indexer]; + uint256 count = agreementIds.length(); + + for (uint256 i = 0; i < count; ++i) { + bytes16 agreementId = bytes16(agreementIds.at(i)); + _reconcileAgreement($, agreementId); + } + + _fundEscrow($, indexer); + } + + /// @inheritdoc IIndexingAgreementManager + function reconcileBatch(bytes16[] calldata agreementIds) external { + IndexingAgreementManagerStorage storage $ = _getStorage(); + + // Phase 1: reconcile all agreements + for (uint256 i = 0; i < agreementIds.length; ++i) { + if (!$.agreements[agreementIds[i]].exists) continue; + _reconcileAgreement($, agreementIds[i]); + } + + // Phase 2: fund escrow per unique indexer. + // The lastFunded check is a gas optimization that skips consecutive duplicates. + // Non-consecutive duplicates may call _fundEscrow twice for the same indexer, + // which is idempotent (the second call finds no deficit) — just extra gas. + // Callers can sort agreementIds by indexer to maximize dedup benefit. + address lastFunded; + for (uint256 i = 0; i < agreementIds.length; ++i) { + address idx = $.agreements[agreementIds[i]].indexer; + if (idx == address(0) || idx == lastFunded) continue; + _fundEscrow($, idx); + lastFunded = idx; + } + } + + /// @inheritdoc IIndexingAgreementManager + function maintain(address indexer) external { + IndexingAgreementManagerStorage storage $ = _getStorage(); + require($.indexerAgreementIds[indexer].length() == 0, IndexingAgreementManagerStillHasAgreements(indexer)); + + // If a previous thaw has been initiated, try to complete withdrawal + if ($.thawing[indexer]) { + // solhint-disable-next-line no-empty-blocks + try PAYMENTS_ESCROW.withdraw(address(RECURRING_COLLECTOR), indexer) { + $.thawing[indexer] = false; + emit EscrowWithdrawn(indexer); + } catch { + // Thaw not yet complete, nothing more to do + return; + } + } + + // Thaw any remaining available balance + uint256 available = PAYMENTS_ESCROW.getBalance(address(this), address(RECURRING_COLLECTOR), indexer); + if (0 < available) { + PAYMENTS_ESCROW.thaw(address(RECURRING_COLLECTOR), indexer, available); + $.thawing[indexer] = true; + emit EscrowThawed(indexer, available); + } + } + + // -- IIndexingAgreementManager: View Functions -- + + /// @inheritdoc IIndexingAgreementManager + function getRequiredEscrow(address indexer) external view returns (uint256) { + return _getStorage().requiredEscrow[indexer]; + } + + /// @inheritdoc IIndexingAgreementManager + function getDeficit(address indexer) external view returns (uint256) { + IndexingAgreementManagerStorage storage $ = _getStorage(); + uint256 required = $.requiredEscrow[indexer]; + uint256 currentBalance = PAYMENTS_ESCROW.getBalance(address(this), address(RECURRING_COLLECTOR), indexer); + if (currentBalance < required) { + return required - currentBalance; + } + return 0; + } + + /// @inheritdoc IIndexingAgreementManager + function getAgreementMaxNextClaim(bytes16 agreementId) external view returns (uint256) { + return _getStorage().agreements[agreementId].maxNextClaim; + } + + /// @inheritdoc IIndexingAgreementManager + function getAgreementInfo(bytes16 agreementId) external view returns (AgreementInfo memory) { + return _getStorage().agreements[agreementId]; + } + + /// @inheritdoc IIndexingAgreementManager + function getIndexerAgreementCount(address indexer) external view returns (uint256) { + return _getStorage().indexerAgreementIds[indexer].length(); + } + + /// @inheritdoc IIndexingAgreementManager + function getIndexerAgreements(address indexer) external view returns (bytes16[] memory) { + IndexingAgreementManagerStorage storage $ = _getStorage(); + EnumerableSet.Bytes32Set storage ids = $.indexerAgreementIds[indexer]; + uint256 count = ids.length(); + bytes16[] memory result = new bytes16[](count); + for (uint256 i = 0; i < count; ++i) { + result[i] = bytes16(ids.at(i)); + } + return result; + } + + // -- Internal Functions -- + + /** + * @notice Reconcile a single agreement's max next claim against on-chain state + * @param agreementId The agreement ID to reconcile + */ + // solhint-disable-next-line use-natspec + function _reconcileAgreement(IndexingAgreementManagerStorage storage $, bytes16 agreementId) private { + AgreementInfo storage info = $.agreements[agreementId]; + if (!info.exists) return; + + IRecurringCollector.AgreementData memory agreement = RECURRING_COLLECTOR.getAgreement(agreementId); + + // If not yet accepted in RC, keep the pre-offer estimate + if (agreement.state == IRecurringCollector.AgreementState.NotAccepted) { + return; + } + + // Clear pending update if it has been applied (updateNonce advanced past pending) + // solhint-disable-next-line gas-strict-inequalities + if (info.pendingUpdateHash != bytes32(0) && info.pendingUpdateNonce <= agreement.updateNonce) { + $.requiredEscrow[info.indexer] -= info.pendingUpdateMaxNextClaim; + delete $.authorizedHashes[info.pendingUpdateHash]; + info.pendingUpdateMaxNextClaim = 0; + info.pendingUpdateNonce = 0; + info.pendingUpdateHash = bytes32(0); + } + + uint256 oldMaxClaim = info.maxNextClaim; + uint256 newMaxClaim = RECURRING_COLLECTOR.getMaxNextClaim(agreementId); + + if (oldMaxClaim != newMaxClaim) { + info.maxNextClaim = newMaxClaim; + $.requiredEscrow[info.indexer] = $.requiredEscrow[info.indexer] - oldMaxClaim + newMaxClaim; + emit AgreementReconciled(agreementId, oldMaxClaim, newMaxClaim); + } + } + + /** + * @notice Fund the escrow for an indexer if there is a deficit + * @dev Uses per-call approve (not infinite allowance). Safe because PaymentsEscrow + * is a trusted protocol contract that transfers exactly the approved amount. + * @param indexer The indexer to fund escrow for + */ + // solhint-disable-next-line use-natspec + function _fundEscrow(IndexingAgreementManagerStorage storage $, address indexer) private { + uint256 currentBalance = PAYMENTS_ESCROW.getBalance(address(this), address(RECURRING_COLLECTOR), indexer); + uint256 required = $.requiredEscrow[indexer]; + + if (currentBalance < required) { + uint256 deficit = required - currentBalance; + uint256 available = GRAPH_TOKEN.balanceOf(address(this)); + uint256 toDeposit = deficit < available ? deficit : available; + if (0 < toDeposit) { + GRAPH_TOKEN.approve(address(PAYMENTS_ESCROW), toDeposit); + PAYMENTS_ESCROW.deposit(address(RECURRING_COLLECTOR), indexer, toDeposit); + emit EscrowFunded(indexer, required, currentBalance + toDeposit, toDeposit); + } + } + } + + /** + * @notice Get the ERC-7201 namespaced storage + */ + // solhint-disable-next-line use-natspec + function _getStorage() private pure returns (IndexingAgreementManagerStorage storage $) { + // solhint-disable-next-line no-inline-assembly + assembly { + $.slot := INDEXING_AGREEMENT_MANAGER_STORAGE_LOCATION + } + } +} diff --git a/packages/issuance/foundry.toml b/packages/issuance/foundry.toml index 01f7bff94..48a52eccc 100644 --- a/packages/issuance/foundry.toml +++ b/packages/issuance/foundry.toml @@ -3,9 +3,11 @@ src = 'contracts' out = 'forge-artifacts' libs = ["node_modules"] auto_detect_remappings = false +test = 'test' remappings = [ "@openzeppelin/=node_modules/@openzeppelin/", "@graphprotocol/=node_modules/@graphprotocol/", + "forge-std/=node_modules/forge-std/src/", ] cache_path = 'cache_forge' fs_permissions = [{ access = "read", path = "./" }] diff --git a/packages/issuance/package.json b/packages/issuance/package.json index 8eca0250c..6b283fd01 100644 --- a/packages/issuance/package.json +++ b/packages/issuance/package.json @@ -58,6 +58,7 @@ "@typechain/ethers-v6": "^0.5.0", "@types/node": "^20.17.50", "dotenv": "catalog:", + "forge-std": "catalog:", "eslint": "catalog:", "ethers": "catalog:", "glob": "catalog:", diff --git a/packages/issuance/test/unit/agreement-manager/approver.t.sol b/packages/issuance/test/unit/agreement-manager/approver.t.sol new file mode 100644 index 000000000..25f42217c --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/approver.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IIssuanceTarget } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIssuanceTarget.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerApproverTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + // -- IContractApprover Tests -- + + function test_IsAuthorizedAgreement_ReturnsSelector() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + _offerAgreement(rca); + + bytes32 agreementHash = recurringCollector.hashRCA(rca); + bytes4 result = agreementManager.isAuthorizedAgreement(agreementHash); + assertEq(result, IContractApprover.isAuthorizedAgreement.selector); + } + + function test_IsAuthorizedAgreement_Revert_WhenNotAuthorized() public { + bytes32 fakeHash = keccak256("fake agreement"); + + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + fakeHash + ) + ); + agreementManager.isAuthorizedAgreement(fakeHash); + } + + function test_IsAuthorizedAgreement_DifferentHashesAreIndependent() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + // Only offer rca1 + _offerAgreement(rca1); + + // rca1 hash should be authorized + bytes32 hash1 = recurringCollector.hashRCA(rca1); + assertEq(agreementManager.isAuthorizedAgreement(hash1), IContractApprover.isAuthorizedAgreement.selector); + + // rca2 hash should NOT be authorized + bytes32 hash2 = recurringCollector.hashRCA(rca2); + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + hash2 + ) + ); + agreementManager.isAuthorizedAgreement(hash2); + } + + // -- ERC165 Tests -- + + function test_SupportsInterface_IIssuanceTarget() public view { + assertTrue(agreementManager.supportsInterface(type(IIssuanceTarget).interfaceId)); + } + + function test_SupportsInterface_IContractApprover() public view { + assertTrue(agreementManager.supportsInterface(type(IContractApprover).interfaceId)); + } + + function test_SupportsInterface_IIndexingAgreementManager() public view { + assertTrue(agreementManager.supportsInterface(type(IIndexingAgreementManager).interfaceId)); + } + + // -- IIssuanceTarget Tests -- + + function test_BeforeIssuanceAllocationChange_DoesNotRevert() public { + agreementManager.beforeIssuanceAllocationChange(); + } + + function test_SetIssuanceAllocator_OnlyGovernor() public { + address nonGovernor = makeAddr("nonGovernor"); + vm.expectRevert(); + vm.prank(nonGovernor); + agreementManager.setIssuanceAllocator(makeAddr("allocator")); + } + + function test_SetIssuanceAllocator_Governor() public { + vm.prank(governor); + agreementManager.setIssuanceAllocator(makeAddr("allocator")); + } + + // -- View Function Tests -- + + function test_GetDeficit_ZeroWhenFullyFunded() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + _offerAgreement(rca); + + // Fully funded (offerAgreement mints enough tokens) + assertEq(agreementManager.getDeficit(indexer), 0); + } + + function test_GetDeficit_ReturnsDeficitWhenUnderfunded() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + uint256 maxClaim = 1 ether * 3600 + 100 ether; + uint256 available = 500 ether; + + token.mint(address(agreementManager), available); + vm.prank(operator); + agreementManager.offerAgreement(rca); + + assertEq(agreementManager.getDeficit(indexer), maxClaim - available); + } + + function test_GetRequiredEscrow_ZeroForUnknownIndexer() public { + assertEq(agreementManager.getRequiredEscrow(makeAddr("unknown")), 0); + } + + function test_GetAgreementMaxNextClaim_ZeroForUnknown() public view { + assertEq(agreementManager.getAgreementMaxNextClaim(bytes16(keccak256("unknown"))), 0); + } + + function test_GetIndexerAgreementCount_ZeroForUnknown() public { + assertEq(agreementManager.getIndexerAgreementCount(makeAddr("unknown")), 0); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol b/packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol new file mode 100644 index 000000000..6fbe1f40b --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/cancelAgreement.t.sol @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerCancelAgreementTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_CancelAgreement_Accepted() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Simulate acceptance + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.AgreementCanceled(agreementId, indexer); + + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + + // Verify the mock was called + assertTrue(mockSubgraphService.canceled(agreementId)); + assertEq(mockSubgraphService.cancelCallCount(agreementId), 1); + } + + function test_CancelAgreement_ReconcileAfterCancel() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + uint256 originalRequired = agreementManager.getRequiredEscrow(indexer); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + assertEq(originalRequired, maxClaim); + + // Accept, then cancel by SP (maxNextClaim -> 0) + _setAgreementCanceledBySP(agreementId, rca); + + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + + // After cancelAgreement (which now reconciles), required escrow should decrease + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + } + + function test_CancelAgreement_Idempotent_CanceledByPayer() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as CanceledByPayer (already canceled) + _setAgreementCanceledByPayer(agreementId, rca, uint64(block.timestamp), uint64(block.timestamp + 1 hours), 0); + + // Should succeed — idempotent, skips the external cancel call + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + + // Should NOT have called SubgraphService + assertEq(mockSubgraphService.cancelCallCount(agreementId), 0); + } + + function test_CancelAgreement_Idempotent_CanceledByServiceProvider() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as CanceledByServiceProvider + _setAgreementCanceledBySP(agreementId, rca); + + // Should succeed — idempotent, reconciles to update escrow + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + + // Should NOT have called SubgraphService + assertEq(mockSubgraphService.cancelCallCount(agreementId), 0); + + // Required escrow should drop to 0 (CanceledBySP has maxNextClaim=0) + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + } + + function test_CancelAgreement_Revert_WhenNotAccepted() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Agreement is NotAccepted — should revert + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAccepted.selector, + agreementId + ) + ); + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + } + + function test_CancelAgreement_Revert_WhenNotOffered() public { + bytes16 fakeId = bytes16(keccak256("fake")); + + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotOffered.selector, + fakeId + ) + ); + vm.prank(operator); + agreementManager.cancelAgreement(fakeId); + } + + function test_CancelAgreement_Revert_WhenNotOperator() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + _offerAgreement(rca); + bytes16 agreementId = recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + + address nonOperator = makeAddr("nonOperator"); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOperator, OPERATOR_ROLE) + ); + vm.prank(nonOperator); + agreementManager.cancelAgreement(agreementId); + } + + function test_CancelAgreement_Revert_WhenPaused() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + vm.startPrank(governor); + agreementManager.grantRole(keccak256("PAUSE_ROLE"), governor); + agreementManager.pause(); + vm.stopPrank(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + } + + function test_CancelAgreement_EmitsEvent() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.AgreementCanceled(agreementId, indexer); + + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/edgeCases.t.sol b/packages/issuance/test/unit/agreement-manager/edgeCases.t.sol new file mode 100644 index 000000000..e6b7e362f --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/edgeCases.t.sol @@ -0,0 +1,1075 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { Vm } from "forge-std/Vm.sol"; + +import { IContractApprover } from "@graphprotocol/interfaces/contracts/horizon/IContractApprover.sol"; +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +/// @notice Edge case and boundary condition tests for IndexingAgreementManager. +contract IndexingAgreementManagerEdgeCasesTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + // ==================== Hash Cleanup Tests ==================== + + function test_RevokeOffer_CleansUpAgreementHash() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + bytes32 rcaHash = recurringCollector.hashRCA(rca); + + // Hash is authorized + assertEq(agreementManager.isAuthorizedAgreement(rcaHash), IContractApprover.isAuthorizedAgreement.selector); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + // Hash is cleaned up (not just stale — actually deleted) + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + rcaHash + ) + ); + agreementManager.isAuthorizedAgreement(rcaHash); + } + + function test_RevokeOffer_CleansUpPendingUpdateHash() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + bytes32 updateHash = recurringCollector.hashRCAU(rcau); + // Update hash is authorized + assertEq(agreementManager.isAuthorizedAgreement(updateHash), IContractApprover.isAuthorizedAgreement.selector); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + // Both hashes cleaned up + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + updateHash + ) + ); + agreementManager.isAuthorizedAgreement(updateHash); + } + + function test_Remove_CleansUpAgreementHash() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + bytes32 rcaHash = recurringCollector.hashRCA(rca); + + // SP cancels — removable + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + // Hash is cleaned up + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + rcaHash + ) + ); + agreementManager.isAuthorizedAgreement(rcaHash); + } + + function test_Remove_CleansUpPendingUpdateHash() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + bytes32 updateHash = recurringCollector.hashRCAU(rcau); + + // SP cancels — removable + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + // Pending update hash also cleaned up + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + updateHash + ) + ); + agreementManager.isAuthorizedAgreement(updateHash); + } + + function test_Reconcile_CleansUpAppliedPendingUpdateHash() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + bytes32 updateHash = recurringCollector.hashRCAU(rcau); + assertEq(agreementManager.isAuthorizedAgreement(updateHash), IContractApprover.isAuthorizedAgreement.selector); + + // Simulate: agreement accepted with updateNonce >= pending (update was applied) + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: uint64(block.timestamp), + lastCollectionAt: 0, + endsAt: uint64(block.timestamp + 730 days), + maxInitialTokens: 200 ether, + maxOngoingTokensPerSecond: 2 ether, + minSecondsPerCollection: 60, + maxSecondsPerCollection: 7200, + updateNonce: 1, // >= pendingUpdateNonce + canceledAt: 0, + state: IRecurringCollector.AgreementState.Accepted + }) + ); + + agreementManager.reconcileAgreement(agreementId); + + // Pending update hash should be cleaned up after reconcile clears the applied update + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + updateHash + ) + ); + agreementManager.isAuthorizedAgreement(updateHash); + } + + function test_OfferUpdate_CleansUpReplacedPendingHash() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // First pending update + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau1); + + bytes32 hash1 = recurringCollector.hashRCAU(rcau1); + assertEq(agreementManager.isAuthorizedAgreement(hash1), IContractApprover.isAuthorizedAgreement.selector); + + // Second pending update replaces first + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = _makeRCAU( + agreementId, + 50 ether, + 0.5 ether, + 60, + 1800, + uint64(block.timestamp + 180 days), + 2 + ); + _offerAgreementUpdate(rcau2); + + // First update hash should be cleaned up + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + hash1 + ) + ); + agreementManager.isAuthorizedAgreement(hash1); + + // Second update hash should be authorized + bytes32 hash2 = recurringCollector.hashRCAU(rcau2); + assertEq(agreementManager.isAuthorizedAgreement(hash2), IContractApprover.isAuthorizedAgreement.selector); + } + + function test_GetAgreementInfo_IncludesHashes() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + bytes32 rcaHash = recurringCollector.hashRCA(rca); + + IIndexingAgreementManager.AgreementInfo memory info = agreementManager.getAgreementInfo(agreementId); + assertEq(info.agreementHash, rcaHash); + assertEq(info.pendingUpdateHash, bytes32(0)); + + // Offer an update + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + bytes32 updateHash = recurringCollector.hashRCAU(rcau); + info = agreementManager.getAgreementInfo(agreementId); + assertEq(info.agreementHash, rcaHash); + assertEq(info.pendingUpdateHash, updateHash); + } + + // ==================== Zero-Value Parameter Tests ==================== + + function test_Offer_ZeroMaxInitialTokens() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 0, // zero initial tokens + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // maxNextClaim = 1e18 * 3600 + 0 = 3600e18 + uint256 expectedMaxClaim = 1 ether * 3600; + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), expectedMaxClaim); + assertEq(agreementManager.getRequiredEscrow(indexer), expectedMaxClaim); + } + + function test_Offer_ZeroOngoingTokensPerSecond() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 0, // zero ongoing rate + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // maxNextClaim = 0 * 3600 + 100e18 = 100e18 + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 100 ether); + assertEq(agreementManager.getRequiredEscrow(indexer), 100 ether); + } + + function test_Offer_AllZeroValues() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 0, // zero initial + 0, // zero ongoing + 0, // zero min seconds + 0, // zero max seconds + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // maxNextClaim = 0 * 0 + 0 = 0 + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + } + + // ==================== Deadline Boundary Tests ==================== + + function test_Remove_AtExactDeadline_NotAccepted() public { + uint64 deadline = uint64(block.timestamp + 1 hours); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + // Override deadline (default from _makeRCA is block.timestamp + 1 hours, same as this) + + bytes16 agreementId = _offerAgreement(rca); + + // Warp to exactly the deadline + vm.warp(deadline); + + // At deadline (block.timestamp == deadline), the condition is `block.timestamp <= info.deadline` + // so this should still be claimable + uint256 maxClaim = 1 ether * 3600 + 100 ether; + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementStillClaimable.selector, + agreementId, + maxClaim + ) + ); + agreementManager.removeAgreement(agreementId); + } + + function test_Remove_OneSecondAfterDeadline_NotAccepted() public { + uint64 deadline = uint64(block.timestamp + 1 hours); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Warp to one second past deadline + vm.warp(deadline + 1); + + // Now removable (block.timestamp > deadline) + agreementManager.removeAgreement(agreementId); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + } + + // ==================== Reconcile Edge Cases ==================== + + function test_Reconcile_WhenCollectionEndEqualsCollectionStart() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + uint64 now_ = uint64(block.timestamp); + // Set as accepted with lastCollectionAt == endsAt (fully consumed) + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: now_, + lastCollectionAt: rca.endsAt, + endsAt: rca.endsAt, + maxInitialTokens: rca.maxInitialTokens, + maxOngoingTokensPerSecond: rca.maxOngoingTokensPerSecond, + minSecondsPerCollection: rca.minSecondsPerCollection, + maxSecondsPerCollection: rca.maxSecondsPerCollection, + updateNonce: 0, + canceledAt: 0, + state: IRecurringCollector.AgreementState.Accepted + }) + ); + + agreementManager.reconcileAgreement(agreementId); + + // getMaxNextClaim returns 0 when collectionEnd <= collectionStart + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + } + + // ==================== Cancel Edge Cases ==================== + + function test_CancelAgreement_Revert_WhenDataServiceReverts() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as accepted + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + // Configure the mock SubgraphService to revert + mockSubgraphService.setRevert(true, "SubgraphService: cannot cancel"); + + vm.expectRevert("SubgraphService: cannot cancel"); + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + } + + // ==================== Offer With Zero Balance Tests ==================== + + function test_Offer_ZeroTokenBalance_PartialFunding() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + // Don't fund the contract — zero token balance + vm.prank(operator); + bytes16 agreementId = agreementManager.offerAgreement(rca); + + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Agreement is tracked even though escrow couldn't be funded + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), maxClaim); + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim); + + // Escrow has zero balance + assertEq(paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer), 0); + + // Full deficit + assertEq(agreementManager.getDeficit(indexer), maxClaim); + } + + // ==================== ReconcileBatch Edge Cases ==================== + + function test_ReconcileBatch_InterleavedDuplicateIndexers() public { + // Create agreements for two different indexers, interleaved + address indexer2 = makeAddr("indexer2"); + + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.serviceProvider = indexer2; + rca2.nonce = 2; + + IRecurringCollector.RecurringCollectionAgreement memory rca3 = _makeRCA( + 50 ether, + 0.5 ether, + 60, + 1800, + uint64(block.timestamp + 365 days) + ); + rca3.nonce = 3; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + bytes16 id3 = _offerAgreement(rca3); + + // Accept all, then SP-cancel all + _setAgreementCanceledBySP(id1, rca1); + _setAgreementCanceledBySP(id2, rca2); + _setAgreementCanceledBySP(id3, rca3); + + // Interleaved order: indexer, indexer2, indexer + // The lastFunded optimization won't catch the second indexer occurrence + bytes16[] memory ids = new bytes16[](3); + ids[0] = id1; + ids[1] = id2; + ids[2] = id3; + + // Should succeed without error — _fundEscrow is idempotent + agreementManager.reconcileBatch(ids); + + // All reconciled to 0 + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(indexer2), 0); + } + + function test_ReconcileBatch_EmptyArray() public { + // Empty batch should succeed with no effect + bytes16[] memory ids = new bytes16[](0); + agreementManager.reconcileBatch(ids); + } + + function test_ReconcileBatch_NonExistentAgreements() public { + // Batch with non-existent IDs should skip silently + bytes16[] memory ids = new bytes16[](2); + ids[0] = bytes16(keccak256("nonexistent1")); + ids[1] = bytes16(keccak256("nonexistent2")); + + agreementManager.reconcileBatch(ids); + } + + // ==================== Maintain Edge Cases ==================== + + function test_Maintain_FullThawWithdrawCycle() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Remove the agreement + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + // First maintain: initiates thaw + agreementManager.maintain(indexer); + + // Warp past mock's thawing period (1 day) + vm.warp(block.timestamp + 1 days + 1); + + // Second maintain: withdraws thawed tokens, then no more to thaw + agreementManager.maintain(indexer); + + // Third maintain: should be a no-op (nothing to thaw or withdraw) + agreementManager.maintain(indexer); + } + + // ==================== Multiple Pending Update Replacements ==================== + + // ==================== Zero-Value Pending Update Hash Cleanup ==================== + + function test_OfferUpdate_ZeroValuePendingUpdate_HashCleanedOnReplace() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + + // Offer a zero-value pending update (both initial and ongoing are 0) + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _makeRCAU( + agreementId, + 0, // zero initial + 0, // zero ongoing + 60, + 3600, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau1); + + bytes32 zeroHash = recurringCollector.hashRCAU(rcau1); + // Zero-value hash should still be authorized + assertEq(agreementManager.isAuthorizedAgreement(zeroHash), IContractApprover.isAuthorizedAgreement.selector); + // requiredEscrow should be unchanged (original + 0) + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim); + + // Replace with a non-zero update — the old zero-value hash must be cleaned up + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 2 + ); + _offerAgreementUpdate(rcau2); + + // Old zero-value hash should be cleaned up + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + zeroHash + ) + ); + agreementManager.isAuthorizedAgreement(zeroHash); + + // New hash should be authorized + bytes32 newHash = recurringCollector.hashRCAU(rcau2); + assertEq(agreementManager.isAuthorizedAgreement(newHash), IContractApprover.isAuthorizedAgreement.selector); + + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim); + } + + function test_Reconcile_ZeroValuePendingUpdate_ClearedWhenApplied() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Offer a zero-value pending update + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 0, + 0, + 60, + 3600, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + bytes32 zeroHash = recurringCollector.hashRCAU(rcau); + assertEq(agreementManager.isAuthorizedAgreement(zeroHash), IContractApprover.isAuthorizedAgreement.selector); + + // Simulate: agreement accepted with update applied (updateNonce >= pending nonce) + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: uint64(block.timestamp), + lastCollectionAt: 0, + endsAt: uint64(block.timestamp + 730 days), + maxInitialTokens: 0, + maxOngoingTokensPerSecond: 0, + minSecondsPerCollection: 60, + maxSecondsPerCollection: 3600, + updateNonce: 1, + canceledAt: 0, + state: IRecurringCollector.AgreementState.Accepted + }) + ); + + agreementManager.reconcileAgreement(agreementId); + + // Zero-value pending hash should be cleaned up + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + zeroHash + ) + ); + agreementManager.isAuthorizedAgreement(zeroHash); + + // Pending fields should be cleared + IIndexingAgreementManager.AgreementInfo memory info = agreementManager.getAgreementInfo(agreementId); + assertEq(info.pendingUpdateMaxNextClaim, 0); + assertEq(info.pendingUpdateNonce, 0); + assertEq(info.pendingUpdateHash, bytes32(0)); + } + + // ==================== Re-offer After Remove ==================== + + function test_ReofferAfterRemove_FullLifecycle() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + // 1. Offer + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + + // 2. SP cancels and remove + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + + // 3. Re-offer the same agreement (same parameters, same agreementId) + bytes16 reofferedId = _offerAgreement(rca); + assertEq(reofferedId, agreementId); + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + + // 4. Verify the re-offered agreement is fully functional + IIndexingAgreementManager.AgreementInfo memory info = agreementManager.getAgreementInfo(reofferedId); + assertTrue(info.exists); + assertEq(info.indexer, indexer); + assertEq(info.maxNextClaim, maxClaim); + + // Hash is authorized again + bytes32 rcaHash = recurringCollector.hashRCA(rca); + assertEq(agreementManager.isAuthorizedAgreement(rcaHash), IContractApprover.isAuthorizedAgreement.selector); + } + + function test_ReofferAfterRemove_WithDifferentNonce() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + bytes16 id1 = _offerAgreement(rca1); + + // Remove + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + // Re-offer with different nonce (different agreementId) + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id2 = _offerAgreement(rca2); + assertTrue(id1 != id2); + + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim2); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + } + + // ==================== Input Validation ==================== + + function test_Offer_Revert_ZeroServiceProvider() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca.serviceProvider = address(0); + + token.mint(address(agreementManager), 1_000_000 ether); + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerInvalidRCAField.selector, + "serviceProvider" + ) + ); + vm.prank(operator); + agreementManager.offerAgreement(rca); + } + + function test_Offer_Revert_ZeroDataService() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca.dataService = address(0); + + token.mint(address(agreementManager), 1_000_000 ether); + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerInvalidRCAField.selector, + "dataService" + ) + ); + vm.prank(operator); + agreementManager.offerAgreement(rca); + } + + // ==================== getIndexerAgreements ==================== + + function test_GetIndexerAgreements_Empty() public { + bytes16[] memory ids = agreementManager.getIndexerAgreements(indexer); + assertEq(ids.length, 0); + } + + function test_GetIndexerAgreements_SingleAgreement() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + bytes16[] memory ids = agreementManager.getIndexerAgreements(indexer); + assertEq(ids.length, 1); + assertEq(ids[0], agreementId); + } + + function test_GetIndexerAgreements_MultipleAgreements() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + bytes16[] memory ids = agreementManager.getIndexerAgreements(indexer); + assertEq(ids.length, 2); + // EnumerableSet maintains insertion order + assertEq(ids[0], id1); + assertEq(ids[1], id2); + } + + function test_GetIndexerAgreements_AfterRemoval() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + // Remove first agreement + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + bytes16[] memory ids = agreementManager.getIndexerAgreements(indexer); + assertEq(ids.length, 1); + assertEq(ids[0], id2); + } + + function test_GetIndexerAgreements_CrossIndexerIsolation() public { + address indexer2 = makeAddr("indexer2"); + + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.serviceProvider = indexer2; + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + bytes16[] memory indexer1Ids = agreementManager.getIndexerAgreements(indexer); + bytes16[] memory indexer2Ids = agreementManager.getIndexerAgreements(indexer2); + + assertEq(indexer1Ids.length, 1); + assertEq(indexer1Ids[0], id1); + assertEq(indexer2Ids.length, 1); + assertEq(indexer2Ids[0], id2); + } + + // ==================== Cancel Event Behavior ==================== + + function test_CancelAgreement_NoEvent_WhenAlreadyCanceled() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as already CanceledByServiceProvider + _setAgreementCanceledBySP(agreementId, rca); + + // Record logs to verify no AgreementCanceled event + vm.recordLogs(); + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + + // Check that no AgreementCanceled event was emitted + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 cancelEventSig = keccak256("AgreementCanceled(bytes16,address)"); + for (uint256 i = 0; i < entries.length; i++) { + assertTrue( + entries[i].topics[0] != cancelEventSig, + "AgreementCanceled should not be emitted on idempotent path" + ); + } + } + + function test_CancelAgreement_EmitsEvent_WhenAccepted() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.AgreementCanceled(agreementId, indexer); + + vm.prank(operator); + agreementManager.cancelAgreement(agreementId); + } + + // ==================== Multiple Pending Update Replacements ==================== + + function test_OfferUpdate_ThreeConsecutiveReplacements() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + + // Update 1 + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau1); + uint256 pending1 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pending1); + + // Update 2 replaces 1 + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = _makeRCAU( + agreementId, + 50 ether, + 0.5 ether, + 60, + 1800, + uint64(block.timestamp + 180 days), + 2 + ); + _offerAgreementUpdate(rcau2); + uint256 pending2 = 0.5 ether * 1800 + 50 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pending2); + + // Update 3 replaces 2 + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau3 = _makeRCAU( + agreementId, + 300 ether, + 3 ether, + 60, + 3600, + uint64(block.timestamp + 1095 days), + 3 + ); + _offerAgreementUpdate(rcau3); + uint256 pending3 = 3 ether * 3600 + 300 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pending3); + + // Only hash for update 3 should be authorized + bytes32 hash1 = recurringCollector.hashRCAU(rcau1); + bytes32 hash2 = recurringCollector.hashRCAU(rcau2); + bytes32 hash3 = recurringCollector.hashRCAU(rcau3); + + vm.expectRevert(); + agreementManager.isAuthorizedAgreement(hash1); + + vm.expectRevert(); + agreementManager.isAuthorizedAgreement(hash2); + + assertEq(agreementManager.isAuthorizedAgreement(hash3), IContractApprover.isAuthorizedAgreement.selector); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/fuzz.t.sol b/packages/issuance/test/unit/agreement-manager/fuzz.t.sol new file mode 100644 index 000000000..43d17a8e2 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/fuzz.t.sol @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerFuzzTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + // -- offerAgreement -- + + function testFuzz_Offer_MaxNextClaimCalculation( + uint128 maxInitialTokens, + uint128 maxOngoingTokensPerSecond, + uint32 maxSecondsPerCollection + ) public { + // Bound to avoid overflow: uint128 * uint32 fits in uint256 + vm.assume(0 < maxSecondsPerCollection); + + uint64 endsAt = uint64(block.timestamp + 365 days); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + maxInitialTokens, + maxOngoingTokensPerSecond, + 60, + maxSecondsPerCollection, + endsAt + ); + + bytes16 agreementId = _offerAgreement(rca); + + uint256 expectedMaxClaim = uint256(maxOngoingTokensPerSecond) * uint256(maxSecondsPerCollection) + + uint256(maxInitialTokens); + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), expectedMaxClaim); + assertEq(agreementManager.getRequiredEscrow(indexer), expectedMaxClaim); + } + + function testFuzz_Offer_EscrowFundedUpToAvailable( + uint128 maxInitialTokens, + uint128 maxOngoingTokensPerSecond, + uint32 maxSecondsPerCollection, + uint256 availableTokens + ) public { + vm.assume(0 < maxSecondsPerCollection); + availableTokens = bound(availableTokens, 0, 10_000_000 ether); + + uint64 endsAt = uint64(block.timestamp + 365 days); + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + maxInitialTokens, + maxOngoingTokensPerSecond, + 60, + maxSecondsPerCollection, + endsAt + ); + + // Fund with a specific amount instead of the default 1M ether + token.mint(address(agreementManager), availableTokens); + vm.prank(operator); + bytes16 agreementId = agreementManager.offerAgreement(rca); + + uint256 maxNextClaim = agreementManager.getAgreementMaxNextClaim(agreementId); + uint256 escrowBalance = paymentsEscrow.getBalance( + address(agreementManager), + address(recurringCollector), + indexer + ); + + // Escrow should be min(maxNextClaim, availableTokens) + if (availableTokens < maxNextClaim) { + assertEq(escrowBalance, availableTokens); + } else { + assertEq(escrowBalance, maxNextClaim); + } + } + + function testFuzz_Offer_RequiredEscrowIncrements( + uint64 maxInitial1, + uint64 maxOngoing1, + uint32 maxSec1, + uint64 maxInitial2, + uint64 maxOngoing2, + uint32 maxSec2 + ) public { + vm.assume(0 < maxSec1 && 0 < maxSec2); + + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + maxInitial1, + maxOngoing1, + 60, + maxSec1, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + maxInitial2, + maxOngoing2, + 60, + maxSec2, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + _offerAgreement(rca1); + uint256 required1 = agreementManager.getRequiredEscrow(indexer); + + _offerAgreement(rca2); + uint256 required2 = agreementManager.getRequiredEscrow(indexer); + + uint256 maxClaim1 = uint256(maxOngoing1) * uint256(maxSec1) + uint256(maxInitial1); + uint256 maxClaim2 = uint256(maxOngoing2) * uint256(maxSec2) + uint256(maxInitial2); + + assertEq(required1, maxClaim1); + assertEq(required2, maxClaim1 + maxClaim2); + } + + // -- revokeOffer / removeAgreement -- + + function testFuzz_RevokeOffer_RequiredEscrowDecrements(uint64 maxInitial, uint64 maxOngoing, uint32 maxSec) public { + vm.assume(0 < maxSec); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + maxInitial, + maxOngoing, + 60, + maxSec, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 requiredBefore = agreementManager.getRequiredEscrow(indexer); + assertTrue(0 < requiredBefore || (maxInitial == 0 && maxOngoing == 0)); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + } + + function testFuzz_Remove_AfterSPCancel_ClearsState(uint64 maxInitial, uint64 maxOngoing, uint32 maxSec) public { + vm.assume(0 < maxSec); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + maxInitial, + maxOngoing, + 60, + maxSec, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + _setAgreementCanceledBySP(agreementId, rca); + + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + } + + // -- reconcile -- + + function testFuzz_Reconcile_AfterCollection_UpdatesRequired( + uint64 maxInitial, + uint64 maxOngoing, + uint32 maxSec, + uint32 timeElapsed + ) public { + vm.assume(0 < maxSec); + vm.assume(0 < maxOngoing); + timeElapsed = uint32(bound(timeElapsed, 1, maxSec)); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + maxInitial, + maxOngoing, + 60, + maxSec, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 preAcceptRequired = agreementManager.getRequiredEscrow(indexer); + + // Simulate acceptance and a collection at block.timestamp + timeElapsed + uint64 acceptedAt = uint64(block.timestamp); + uint64 collectionAt = uint64(block.timestamp + timeElapsed); + _setAgreementCollected(agreementId, rca, acceptedAt, collectionAt); + + // Warp to collection time + vm.warp(collectionAt); + + agreementManager.reconcileAgreement(agreementId); + + uint256 postReconcileRequired = agreementManager.getRequiredEscrow(indexer); + + // After collection, the maxNextClaim should reflect remaining window (no initial tokens) + // and should be <= the pre-acceptance estimate + assertTrue(postReconcileRequired <= preAcceptRequired); + } + + // -- offerAgreementUpdate -- + + function testFuzz_OfferUpdate_DoubleFunding( + uint64 maxInitial, + uint64 maxOngoing, + uint32 maxSec, + uint64 updateMaxInitial, + uint64 updateMaxOngoing, + uint32 updateMaxSec + ) public { + vm.assume(0 < maxSec && 0 < updateMaxSec); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + maxInitial, + maxOngoing, + 60, + maxSec, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + uint256 originalMaxClaim = uint256(maxOngoing) * uint256(maxSec) + uint256(maxInitial); + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + updateMaxInitial, + updateMaxOngoing, + 60, + updateMaxSec, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + uint256 pendingMaxClaim = uint256(updateMaxOngoing) * uint256(updateMaxSec) + uint256(updateMaxInitial); + + // Both original and pending are funded simultaneously + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim); + } + + // -- removeAgreement deadline -- + + function testFuzz_Remove_ExpiredOffer_DeadlineBoundary(uint32 extraTime) public { + extraTime = uint32(bound(extraTime, 1, 365 days)); + + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Before deadline: should revert + uint256 storedMaxClaim = agreementManager.getAgreementMaxNextClaim(agreementId); + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementStillClaimable.selector, + agreementId, + storedMaxClaim + ) + ); + agreementManager.removeAgreement(agreementId); + + // Warp past deadline + vm.warp(rca.deadline + extraTime); + + // After deadline: should succeed + agreementManager.removeAgreement(agreementId); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + } + + // -- getDeficit -- + + function testFuzz_GetDeficit_MatchesShortfall(uint128 maxOngoing, uint32 maxSec, uint128 available) public { + vm.assume(0 < maxSec); + vm.assume(0 < maxOngoing); + + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 0, + maxOngoing, + 60, + maxSec, + uint64(block.timestamp + 365 days) + ); + + token.mint(address(agreementManager), available); + vm.prank(operator); + agreementManager.offerAgreement(rca); + + uint256 required = agreementManager.getRequiredEscrow(indexer); + uint256 balance = paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer); + uint256 deficit = agreementManager.getDeficit(indexer); + + if (balance < required) { + assertEq(deficit, required - balance); + } else { + assertEq(deficit, 0); + } + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/maintain.t.sol b/packages/issuance/test/unit/agreement-manager/maintain.t.sol new file mode 100644 index 000000000..82d6f6296 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/maintain.t.sol @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerMaintainTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_Maintain_ThawsWhenNoAgreements() public { + // Create agreement, fund escrow, then remove it + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // Verify escrow was funded + uint256 escrowBalance = paymentsEscrow.getBalance( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(escrowBalance, maxClaim); + + // SP cancels and we remove + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + + // Maintain should thaw the escrow balance + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.EscrowThawed(indexer, maxClaim); + + agreementManager.maintain(indexer); + + // getBalance should now return 0 (thawing) + escrowBalance = paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer); + assertEq(escrowBalance, 0); + } + + function test_Maintain_WithdrawsAfterThawComplete() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 maxClaim = 1 ether * 3600 + 100 ether; + + // SP cancels and remove + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + // First maintain: thaw + agreementManager.maintain(indexer); + + // Fast forward past thawing period (1 day in mock) + vm.warp(block.timestamp + 1 days + 1); + + uint256 agreementManagerBalanceBefore = token.balanceOf(address(agreementManager)); + + // Second maintain: withdraw + no more to thaw + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.EscrowWithdrawn(indexer); + + agreementManager.maintain(indexer); + + // Tokens should be back in IndexingAgreementManager + uint256 agreementManagerBalanceAfter = token.balanceOf(address(agreementManager)); + assertEq(agreementManagerBalanceAfter - agreementManagerBalanceBefore, maxClaim); + } + + function test_Maintain_NoopWhenNoBalance() public { + // No agreements, no balance — should succeed silently + agreementManager.maintain(indexer); + } + + function test_Maintain_ReturnsEarlyWhenStillThawing() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // SP cancels and remove + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + // First maintain: thaw + agreementManager.maintain(indexer); + + // Second maintain before thaw complete: should return early (no events) + agreementManager.maintain(indexer); + + // Balance should still be 0 (thawing in progress) + uint256 escrowBalance = paymentsEscrow.getBalance( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(escrowBalance, 0); + } + + function test_Maintain_Revert_WhenAgreementsExist() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + _offerAgreement(rca); + + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerStillHasAgreements.selector, + indexer + ) + ); + agreementManager.maintain(indexer); + } + + function test_Maintain_Permissionless() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + + // Anyone can call maintain + address anyone = makeAddr("anyone"); + vm.prank(anyone); + agreementManager.maintain(indexer); + } + + function test_Maintain_ThawIsolation_CrossIndexer() public { + address indexer2 = makeAddr("indexer2"); + + // Create agreements for two different indexers + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.serviceProvider = indexer2; + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // Verify both indexers have funded escrow + assertEq(paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer), maxClaim1); + assertEq( + paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer2), + maxClaim2 + ); + + // Cancel and remove only indexer1's agreement + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + // Maintain indexer1 — should thaw + agreementManager.maintain(indexer); + + // Indexer1 escrow should be thawing (balance = 0) + assertEq(paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer), 0); + + // Indexer2 escrow should be unaffected + assertEq( + paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer2), + maxClaim2 + ); + + // Maintain on indexer2 should revert (still has agreements) + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerStillHasAgreements.selector, + indexer2 + ) + ); + agreementManager.maintain(indexer2); + + // Cancel and remove indexer2's agreement + _setAgreementCanceledBySP(id2, rca2); + agreementManager.removeAgreement(id2); + + // Now indexer2 can be maintained independently + agreementManager.maintain(indexer2); + assertEq(paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer2), 0); + } + + function test_OfferAgreement_CancelsThaw() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // SP cancels, remove, and start thawing + _setAgreementCanceledBySP(agreementId, rca); + agreementManager.removeAgreement(agreementId); + agreementManager.maintain(indexer); + + // getBalance should be 0 (thawing) + uint256 escrowBalance = paymentsEscrow.getBalance( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(escrowBalance, 0); + + // Now offer a new agreement — should cancel the thaw + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 50 ether, + 0.5 ether, + 60, + 1800, + uint64(block.timestamp + 180 days) + ); + rca2.nonce = 2; + _offerAgreement(rca2); + + // Thaw should have been canceled — getBalance should be positive again + escrowBalance = paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer); + assertTrue(escrowBalance > 0, "Thaw should have been canceled"); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/mocks/MockGraphToken.sol b/packages/issuance/test/unit/agreement-manager/mocks/MockGraphToken.sol new file mode 100644 index 000000000..7828eee01 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockGraphToken.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Minimal ERC20 token for testing. Mints initial supply to deployer. +contract MockGraphToken is ERC20 { + constructor() ERC20("Graph Token", "GRT") { + _mint(msg.sender, 1_000_000_000 ether); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol b/packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol new file mode 100644 index 000000000..2c7e20415 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockPaymentsEscrow.sol @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IGraphPayments } from "@graphprotocol/interfaces/contracts/horizon/IGraphPayments.sol"; +import { IPaymentsEscrow } from "@graphprotocol/interfaces/contracts/horizon/IPaymentsEscrow.sol"; + +/// @notice Stateful mock of PaymentsEscrow for IndexingAgreementManager testing. +/// Tracks deposits per (payer, collector, receiver) and transfers tokens on deposit. +/// Supports thaw/withdraw lifecycle for maintain() testing. +contract MockPaymentsEscrow is IPaymentsEscrow { + IERC20 public token; + + struct Account { + uint256 balance; + uint256 tokensThawing; + uint256 thawEndTimestamp; + } + + // accounts[payer][collector][receiver] + mapping(address => mapping(address => mapping(address => Account))) public accounts; + + /// @notice Thawing period for testing (set to 1 day by default) + uint256 public constant THAWING_PERIOD = 1 days; + + constructor(address _token) { + token = IERC20(_token); + } + + function deposit(address collector, address receiver, uint256 tokens) external { + token.transferFrom(msg.sender, address(this), tokens); + accounts[msg.sender][collector][receiver].balance += tokens; + } + + function getBalance(address payer, address collector, address receiver) external view returns (uint256) { + Account storage account = accounts[payer][collector][receiver]; + return account.balance > account.tokensThawing ? account.balance - account.tokensThawing : 0; + } + + function thaw(address collector, address receiver, uint256 tokens) external { + Account storage account = accounts[msg.sender][collector][receiver]; + require(account.balance >= tokens, "insufficient balance"); + account.tokensThawing = tokens; + account.thawEndTimestamp = block.timestamp + THAWING_PERIOD; + } + + function cancelThaw(address collector, address receiver) external { + Account storage account = accounts[msg.sender][collector][receiver]; + account.tokensThawing = 0; + account.thawEndTimestamp = 0; + } + + function withdraw(address collector, address receiver) external { + Account storage account = accounts[msg.sender][collector][receiver]; + require(account.thawEndTimestamp != 0, "not thawing"); + require(account.thawEndTimestamp < block.timestamp, "still thawing"); + + uint256 tokens = account.tokensThawing > account.balance ? account.balance : account.tokensThawing; + account.balance -= tokens; + account.tokensThawing = 0; + account.thawEndTimestamp = 0; + token.transfer(msg.sender, tokens); + } + + // -- Stubs (not used by IndexingAgreementManager) -- + + function initialize() external {} + function depositTo(address, address, address, uint256) external {} + function collect(IGraphPayments.PaymentTypes, address, address, uint256, address, uint256, address) external {} + function MAX_WAIT_PERIOD() external pure returns (uint256) { + return 0; + } + function WITHDRAW_ESCROW_THAWING_PERIOD() external pure returns (uint256) { + return THAWING_PERIOD; + } +} diff --git a/packages/issuance/test/unit/agreement-manager/mocks/MockRecurringCollector.sol b/packages/issuance/test/unit/agreement-manager/mocks/MockRecurringCollector.sol new file mode 100644 index 000000000..7d68a5784 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockRecurringCollector.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +/// @notice Minimal mock of RecurringCollector for IndexingAgreementManager testing. +/// Stores agreement data set by tests, computes agreementId and hashRCA deterministically. +contract MockRecurringCollector { + mapping(bytes16 => IRecurringCollector.AgreementData) private _agreements; + mapping(bytes16 => bool) private _agreementExists; + + // -- Test helpers -- + + function setAgreement(bytes16 agreementId, IRecurringCollector.AgreementData memory data) external { + _agreements[agreementId] = data; + _agreementExists[agreementId] = true; + } + + // -- IRecurringCollector subset -- + + function getAgreement(bytes16 agreementId) external view returns (IRecurringCollector.AgreementData memory) { + return _agreements[agreementId]; + } + + function getMaxNextClaim(bytes16 agreementId) external view returns (uint256) { + IRecurringCollector.AgreementData memory a = _agreements[agreementId]; + // Mirror RecurringCollector._getMaxNextClaim logic + if (a.state == IRecurringCollector.AgreementState.CanceledByServiceProvider) return 0; + if ( + a.state != IRecurringCollector.AgreementState.Accepted && + a.state != IRecurringCollector.AgreementState.CanceledByPayer + ) return 0; + + uint256 collectionStart = 0 < a.lastCollectionAt ? a.lastCollectionAt : a.acceptedAt; + uint256 collectionEnd; + if (a.state == IRecurringCollector.AgreementState.CanceledByPayer) { + collectionEnd = a.canceledAt < a.endsAt ? a.canceledAt : a.endsAt; + } else { + collectionEnd = a.endsAt; + } + if (collectionEnd <= collectionStart) return 0; + + uint256 windowSeconds = collectionEnd - collectionStart; + uint256 maxSeconds = windowSeconds < a.maxSecondsPerCollection ? windowSeconds : a.maxSecondsPerCollection; + uint256 maxClaim = a.maxOngoingTokensPerSecond * maxSeconds; + if (a.lastCollectionAt == 0) maxClaim += a.maxInitialTokens; + return maxClaim; + } + + function generateAgreementId( + address payer, + address dataService, + address serviceProvider, + uint64 deadline, + uint256 nonce + ) external pure returns (bytes16) { + return bytes16(keccak256(abi.encode(payer, dataService, serviceProvider, deadline, nonce))); + } + + function hashRCA(IRecurringCollector.RecurringCollectionAgreement calldata rca) external pure returns (bytes32) { + return + keccak256( + abi.encode( + rca.deadline, + rca.endsAt, + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.maxInitialTokens, + rca.maxOngoingTokensPerSecond, + rca.minSecondsPerCollection, + rca.maxSecondsPerCollection, + rca.nonce, + rca.metadata + ) + ); + } + + function hashRCAU( + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau + ) external pure returns (bytes32) { + return + keccak256( + abi.encode( + rcau.agreementId, + rcau.deadline, + rcau.endsAt, + rcau.maxInitialTokens, + rcau.maxOngoingTokensPerSecond, + rcau.minSecondsPerCollection, + rcau.maxSecondsPerCollection, + rcau.nonce, + rcau.metadata + ) + ); + } +} diff --git a/packages/issuance/test/unit/agreement-manager/mocks/MockSubgraphService.sol b/packages/issuance/test/unit/agreement-manager/mocks/MockSubgraphService.sol new file mode 100644 index 000000000..8454452e0 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/mocks/MockSubgraphService.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +/// @notice Minimal mock of SubgraphService for IndexingAgreementManager cancelAgreement testing. +/// Records cancel calls and can be configured to revert. +contract MockSubgraphService { + mapping(bytes16 => bool) public canceled; + mapping(bytes16 => uint256) public cancelCallCount; + + bool public shouldRevert; + string public revertMessage; + + function cancelIndexingAgreementByPayer(bytes16 agreementId) external { + if (shouldRevert) { + revert(revertMessage); + } + canceled[agreementId] = true; + cancelCallCount[agreementId]++; + } + + // -- Test helpers -- + + function setRevert(bool _shouldRevert, string memory _message) external { + shouldRevert = _shouldRevert; + revertMessage = _message; + } +} diff --git a/packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol b/packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol new file mode 100644 index 000000000..8797dca19 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/multiIndexer.t.sol @@ -0,0 +1,441 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerMultiIndexerTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + address internal indexer2; + address internal indexer3; + + function setUp() public virtual override { + super.setUp(); + indexer2 = makeAddr("indexer2"); + indexer3 = makeAddr("indexer3"); + } + + // -- Helpers -- + + function _makeRCAForIndexer( + address sp, + uint256 maxInitial, + uint256 maxOngoing, + uint32 maxSec, + uint256 nonce + ) internal view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + maxInitial, + maxOngoing, + 60, + maxSec, + uint64(block.timestamp + 365 days) + ); + rca.serviceProvider = sp; + rca.nonce = nonce; + return rca; + } + + // -- Isolation: offer/requiredEscrow -- + + function test_MultiIndexer_OfferIsolation() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca3 = _makeRCAForIndexer( + indexer3, + 50 ether, + 0.5 ether, + 1800, + 3 + ); + + _offerAgreement(rca1); + _offerAgreement(rca2); + _offerAgreement(rca3); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + uint256 maxClaim3 = 0.5 ether * 1800 + 50 ether; + + // Each indexer has independent requiredEscrow + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim1); + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + assertEq(agreementManager.getRequiredEscrow(indexer3), maxClaim3); + + // Each has exactly 1 agreement + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + assertEq(agreementManager.getIndexerAgreementCount(indexer2), 1); + assertEq(agreementManager.getIndexerAgreementCount(indexer3), 1); + + // Each has independent escrow balance + assertEq(paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer), maxClaim1); + assertEq( + paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer2), + maxClaim2 + ); + assertEq( + paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer3), + maxClaim3 + ); + } + + // -- Isolation: revoke one indexer doesn't affect others -- + + function test_MultiIndexer_RevokeIsolation() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + + bytes16 id1 = _offerAgreement(rca1); + _offerAgreement(rca2); + + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // Revoke indexer1's agreement + vm.prank(operator); + agreementManager.revokeOffer(id1); + + // Indexer1 cleared + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + + // Indexer2 unaffected + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + assertEq(agreementManager.getIndexerAgreementCount(indexer2), 1); + } + + // -- Isolation: remove one indexer doesn't affect others -- + + function test_MultiIndexer_RemoveIsolation() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + + bytes16 id1 = _offerAgreement(rca1); + _offerAgreement(rca2); + + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // SP cancels indexer1, remove it + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + // Indexer1 cleared + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + + // Indexer2 unaffected + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + } + + // -- Isolation: reconcile one indexer doesn't affect others -- + + function test_MultiIndexer_ReconcileIsolation() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // Accept and cancel indexer1's agreement by SP + _setAgreementCanceledBySP(id1, rca1); + + // Reconcile only indexer1 + agreementManager.reconcileAgreement(id1); + + // Indexer1 required escrow drops to 0 (CanceledBySP -> maxNextClaim=0) + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + + // Indexer2 completely unaffected (still pre-offered estimate) + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + assertEq(agreementManager.getAgreementMaxNextClaim(id2), maxClaim2); + } + + // -- Multiple agreements per indexer -- + + function test_MultiIndexer_MultipleAgreementsPerIndexer() public { + // Two agreements for indexer, one for indexer2 + IRecurringCollector.RecurringCollectionAgreement memory rca1a = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca1b = _makeRCAForIndexer( + indexer, + 50 ether, + 0.5 ether, + 1800, + 2 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 3 + ); + + bytes16 id1a = _offerAgreement(rca1a); + _offerAgreement(rca1b); + _offerAgreement(rca2); + + uint256 maxClaim1a = 1 ether * 3600 + 100 ether; + uint256 maxClaim1b = 0.5 ether * 1800 + 50 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + assertEq(agreementManager.getIndexerAgreementCount(indexer), 2); + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim1a + maxClaim1b); + assertEq(agreementManager.getIndexerAgreementCount(indexer2), 1); + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + + // Remove one of indexer's agreements + _setAgreementCanceledBySP(id1a, rca1a); + agreementManager.removeAgreement(id1a); + + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim1b); + + // Indexer2 still unaffected + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + } + + // -- Cancel one indexer, reconcile another -- + + function test_MultiIndexer_CancelAndReconcileIndependently() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + // Accept both + _setAgreementAccepted(id1, rca1, uint64(block.timestamp)); + _setAgreementAccepted(id2, rca2, uint64(block.timestamp)); + + // Cancel indexer1's agreement via operator + vm.prank(operator); + agreementManager.cancelAgreement(id1); + + // Indexer1's required escrow updated by cancelAgreement's inline reconcile + // (still has maxNextClaim from RC since it's CanceledByPayer not CanceledBySP) + // But the mock just calls SubgraphService — the RC state doesn't change automatically. + // The cancelAgreement reconciles against whatever the mock RC says. + + // Reconcile indexer2 independently + agreementManager.reconcileAgreement(id2); + + // Both indexers tracked independently + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + assertEq(agreementManager.getIndexerAgreementCount(indexer2), 1); + } + + // -- Maintain isolation -- + + function test_MultiIndexer_MaintainOnlyAffectsTargetIndexer() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + + bytes16 id1 = _offerAgreement(rca1); + _offerAgreement(rca2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // Remove indexer1's agreement + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + // Maintain indexer1 + agreementManager.maintain(indexer); + + // Indexer1 escrow thawing + assertEq(paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer), 0); + + // Indexer2 escrow completely unaffected + assertEq( + paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer2), + maxClaim2 + ); + + // Maintain on indexer2 should revert (still has agreement) + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerStillHasAgreements.selector, + indexer2 + ) + ); + agreementManager.maintain(indexer2); + } + + // -- Full lifecycle across multiple indexers -- + + function test_MultiIndexer_FullLifecycle() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + + // 1. Offer both + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim1); + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + + // 2. Accept both + _setAgreementAccepted(id1, rca1, uint64(block.timestamp)); + _setAgreementAccepted(id2, rca2, uint64(block.timestamp)); + + // 3. Simulate collection on indexer1 (reduce remaining window) + uint64 collectionTime = uint64(block.timestamp + 1800); + _setAgreementCollected(id1, rca1, uint64(block.timestamp), collectionTime); + vm.warp(collectionTime); + + // 4. Reconcile indexer1 — required should decrease (no more initial tokens) + agreementManager.reconcileAgreement(id1); + assertTrue(agreementManager.getRequiredEscrow(indexer) < maxClaim1); + + // Indexer2 unaffected + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + + // 5. Cancel indexer2 by SP + _setAgreementCanceledBySP(id2, rca2); + agreementManager.reconcileAgreement(id2); + assertEq(agreementManager.getRequiredEscrow(indexer2), 0); + + // 6. Remove indexer2's agreement + agreementManager.removeAgreement(id2); + assertEq(agreementManager.getIndexerAgreementCount(indexer2), 0); + + // 7. Maintain indexer2 (thaw excess escrow) + agreementManager.maintain(indexer2); + assertEq(paymentsEscrow.getBalance(address(agreementManager), address(recurringCollector), indexer2), 0); + + // 8. Indexer1 still active + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + assertTrue(0 < agreementManager.getRequiredEscrow(indexer)); + } + + // -- getAgreementInfo across indexers -- + + function test_MultiIndexer_GetAgreementInfo() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCAForIndexer( + indexer, + 100 ether, + 1 ether, + 3600, + 1 + ); + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCAForIndexer( + indexer2, + 200 ether, + 2 ether, + 7200, + 2 + ); + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + IIndexingAgreementManager.AgreementInfo memory info1 = agreementManager.getAgreementInfo(id1); + IIndexingAgreementManager.AgreementInfo memory info2 = agreementManager.getAgreementInfo(id2); + + assertEq(info1.indexer, indexer); + assertEq(info2.indexer, indexer2); + assertTrue(info1.exists); + assertTrue(info2.exists); + assertEq(info1.maxNextClaim, 1 ether * 3600 + 100 ether); + assertEq(info2.maxNextClaim, 2 ether * 7200 + 200 ether); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol b/packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol new file mode 100644 index 000000000..443fcf8ee --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/offerUpdate.t.sol @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerOfferUpdateTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_OfferUpdate_SetsState() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + + _offerAgreementUpdate(rcau); + + // pendingMaxNextClaim = 2e18 * 7200 + 200e18 = 14600e18 + uint256 expectedPendingMaxClaim = 2 ether * 7200 + 200 ether; + // Original maxNextClaim = 1e18 * 3600 + 100e18 = 3700e18 + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + + // Required escrow should include both + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + expectedPendingMaxClaim); + // Original maxNextClaim unchanged + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), originalMaxClaim); + } + + function test_OfferUpdate_AuthorizesHash() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + + _offerAgreementUpdate(rcau); + + // The update hash should be authorized for the IContractApprover callback + bytes32 updateHash = recurringCollector.hashRCAU(rcau); + bytes4 result = agreementManager.isAuthorizedAgreement(updateHash); + assertEq(result, agreementManager.isAuthorizedAgreement.selector); + } + + function test_OfferUpdate_FundsEscrow() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + uint256 totalRequired = originalMaxClaim + pendingMaxClaim; + + // Fund and offer agreement + token.mint(address(agreementManager), totalRequired); + vm.prank(operator); + bytes16 agreementId = agreementManager.offerAgreement(rca); + + // Offer update (should fund the deficit) + token.mint(address(agreementManager), pendingMaxClaim); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + vm.prank(operator); + agreementManager.offerAgreementUpdate(rcau); + + // Verify escrow was funded for both + uint256 escrowBalance = paymentsEscrow.getBalance( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(escrowBalance, totalRequired); + } + + function test_OfferUpdate_ReplacesExistingPending() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + + // First pending update + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau1 = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau1); + + uint256 pendingMaxClaim1 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim1); + + // Second pending update (replaces first) + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau2 = _makeRCAU( + agreementId, + 50 ether, + 0.5 ether, + 60, + 1800, + uint64(block.timestamp + 180 days), + 2 + ); + _offerAgreementUpdate(rcau2); + + uint256 pendingMaxClaim2 = 0.5 ether * 1800 + 50 ether; + // Old pending removed, new pending added + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim2); + } + + function test_OfferUpdate_EmitsEvent() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.AgreementUpdateOffered(agreementId, pendingMaxClaim, 1); + + vm.prank(operator); + agreementManager.offerAgreementUpdate(rcau); + } + + function test_OfferUpdate_Revert_WhenNotOffered() public { + bytes16 fakeId = bytes16(keccak256("fake")); + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + fakeId, + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days), + 1 + ); + + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotOffered.selector, + fakeId + ) + ); + vm.prank(operator); + agreementManager.offerAgreementUpdate(rcau); + } + + function test_OfferUpdate_Revert_WhenNotOperator() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + + address nonOperator = makeAddr("nonOperator"); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOperator, OPERATOR_ROLE) + ); + vm.prank(nonOperator); + agreementManager.offerAgreementUpdate(rcau); + } + + function test_OfferUpdate_Revert_WhenPaused() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + + // Grant pause role and pause + vm.startPrank(governor); + agreementManager.grantRole(keccak256("PAUSE_ROLE"), governor); + agreementManager.pause(); + vm.stopPrank(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + vm.prank(operator); + agreementManager.offerAgreementUpdate(rcau); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/reconcile.t.sol b/packages/issuance/test/unit/agreement-manager/reconcile.t.sol new file mode 100644 index 000000000..3c7723c18 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/reconcile.t.sol @@ -0,0 +1,348 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { Vm } from "forge-std/Vm.sol"; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerReconcileTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_ReconcileAgreement_AfterFirstCollection() public { + // Offer: maxNextClaim = 1e18 * 3600 + 100e18 = 3700e18 + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 initialMaxClaim = agreementManager.getAgreementMaxNextClaim(agreementId); + assertEq(initialMaxClaim, 3700 ether); + + // Simulate: agreement accepted and first collection happened + uint64 acceptedAt = uint64(block.timestamp); + uint64 lastCollectionAt = uint64(block.timestamp + 1 hours); + _setAgreementCollected(agreementId, rca, acceptedAt, lastCollectionAt); + + // After first collection, maxInitialTokens no longer applies + // New max = maxOngoingTokensPerSecond * min(remaining, maxSecondsPerCollection) + // remaining = endsAt - lastCollectionAt (large), capped by maxSecondsPerCollection = 3600 + // New max = 1e18 * 3600 = 3600e18 + vm.warp(lastCollectionAt); + agreementManager.reconcileAgreement(agreementId); + + uint256 newMaxClaim = agreementManager.getAgreementMaxNextClaim(agreementId); + assertEq(newMaxClaim, 3600 ether); + assertEq(agreementManager.getRequiredEscrow(indexer), 3600 ether); + } + + function test_ReconcileAgreement_CanceledByServiceProvider() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 3700 ether); + + // SP cancels - immediately non-collectable + _setAgreementCanceledBySP(agreementId, rca); + + agreementManager.reconcileAgreement(agreementId); + + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + } + + function test_ReconcileAgreement_CanceledByPayer_WindowOpen() public { + uint64 startTime = uint64(block.timestamp); + + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(startTime + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Payer cancels 2 hours from now, never collected + uint64 acceptedAt = startTime; + uint64 canceledAt = uint64(startTime + 2 hours); + _setAgreementCanceledByPayer(agreementId, rca, acceptedAt, canceledAt, 0); + + agreementManager.reconcileAgreement(agreementId); + + // Window = canceledAt - acceptedAt = 7200s, capped by maxSecondsPerCollection = 3600s + // maxClaim = 1e18 * 3600 + 100e18 (never collected, so includes initial) + uint256 expectedMaxClaim = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), expectedMaxClaim); + } + + function test_ReconcileAgreement_CanceledByPayer_WindowExpired() public { + uint64 startTime = uint64(block.timestamp); + + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(startTime + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Payer cancels, and the collection already happened covering the full window + uint64 acceptedAt = startTime; + uint64 canceledAt = uint64(startTime + 2 hours); + // lastCollectionAt == canceledAt means window is empty + _setAgreementCanceledByPayer(agreementId, rca, acceptedAt, canceledAt, canceledAt); + + agreementManager.reconcileAgreement(agreementId); + + // collectionEnd = canceledAt, collectionStart = lastCollectionAt = canceledAt + // window is empty -> maxClaim = 0 + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + } + + function test_ReconcileAgreement_SkipsNotAccepted() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 originalMaxClaim = agreementManager.getAgreementMaxNextClaim(agreementId); + + // Mock returns NotAccepted (default state in mock - zero struct) + // reconcile should skip recalculation and preserve the original estimate + + agreementManager.reconcileAgreement(agreementId); + + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), originalMaxClaim); + } + + function test_ReconcileAgreement_EmitsEvent() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // SP cancels + _setAgreementCanceledBySP(agreementId, rca); + + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.AgreementReconciled(agreementId, 3700 ether, 0); + + agreementManager.reconcileAgreement(agreementId); + } + + function test_ReconcileAgreement_NoEmitWhenUnchanged() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as accepted with same parameters - should produce same maxNextClaim + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + // maxClaim should remain 3700e18 (never collected, window > maxSecondsPerCollection) + // No event should be emitted + vm.recordLogs(); + agreementManager.reconcileAgreement(agreementId); + + // Check no AgreementReconciled event was emitted + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 reconciledTopic = keccak256("AgreementReconciled(bytes16,uint256,uint256)"); + for (uint256 i = 0; i < logs.length; i++) { + assertTrue(logs[i].topics[0] != reconciledTopic, "Unexpected AgreementReconciled event"); + } + } + + function test_Reconcile_AllAgreementsForIndexer() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + // Cancel agreement 1 by SP + _setAgreementCanceledBySP(id1, rca1); + + // Accept agreement 2 (collected once) + uint64 lastCollectionAt = uint64(block.timestamp + 1 hours); + _setAgreementCollected(id2, rca2, uint64(block.timestamp), lastCollectionAt); + vm.warp(lastCollectionAt); + + // Fund for reconcile + token.mint(address(agreementManager), 1_000_000 ether); + + agreementManager.reconcile(indexer); + + // Agreement 1: CanceledBySP -> maxClaim = 0 + assertEq(agreementManager.getAgreementMaxNextClaim(id1), 0); + // Agreement 2: collected, remaining window large, capped at maxSecondsPerCollection = 7200 + // maxClaim = 2e18 * 7200 = 14400e18 (no initial since collected) + assertEq(agreementManager.getAgreementMaxNextClaim(id2), 14400 ether); + assertEq(agreementManager.getRequiredEscrow(indexer), 14400 ether); + } + + function test_ReconcileAgreement_Revert_WhenNotOffered() public { + bytes16 fakeId = bytes16(keccak256("fake")); + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotOffered.selector, + fakeId + ) + ); + agreementManager.reconcileAgreement(fakeId); + } + + function test_ReconcileAgreement_ExpiredAgreement() public { + uint64 endsAt = uint64(block.timestamp + 1 hours); + + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + endsAt + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as accepted, collected at endsAt (fully expired) + _setAgreementCollected(agreementId, rca, uint64(block.timestamp), endsAt); + vm.warp(endsAt); + + agreementManager.reconcileAgreement(agreementId); + + // collectionEnd = endsAt, collectionStart = lastCollectionAt = endsAt + // window empty -> maxClaim = 0 + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + } + + function test_ReconcileAgreement_ClearsPendingUpdate() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + + // Offer a pending update + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim); + + // Simulate: agreement accepted and update applied on-chain (updateNonce = 1) + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: uint64(block.timestamp), + lastCollectionAt: 0, + endsAt: rcau.endsAt, + maxInitialTokens: rcau.maxInitialTokens, + maxOngoingTokensPerSecond: rcau.maxOngoingTokensPerSecond, + minSecondsPerCollection: rcau.minSecondsPerCollection, + maxSecondsPerCollection: rcau.maxSecondsPerCollection, + updateNonce: 1, + canceledAt: 0, + state: IRecurringCollector.AgreementState.Accepted + }) + ); + + agreementManager.reconcileAgreement(agreementId); + + // Pending should be cleared, maxNextClaim recalculated from new terms + // newMaxClaim = 2e18 * 7200 + 200e18 = 14600e18 (never collected, window > maxSecondsPerCollection) + uint256 newMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), newMaxClaim); + // Required = only new maxClaim (pending cleared) + assertEq(agreementManager.getRequiredEscrow(indexer), newMaxClaim); + } + + function test_ReconcileAgreement_KeepsPendingUpdate_WhenNotYetApplied() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + + // Offer a pending update + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + + // Simulate: agreement accepted but update NOT yet applied (updateNonce = 0) + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + agreementManager.reconcileAgreement(agreementId); + + // maxNextClaim recalculated from original terms (same value since never collected) + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), originalMaxClaim); + // Pending still present + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/reconcileBatch.t.sol b/packages/issuance/test/unit/agreement-manager/reconcileBatch.t.sol new file mode 100644 index 000000000..61dfeb1a4 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/reconcileBatch.t.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerReconcileBatchTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_ReconcileBatch_BasicBatch() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim1 + maxClaim2); + + // Accept both and simulate CanceledBySP on agreement 1 + _setAgreementCanceledBySP(id1, rca1); + _setAgreementAccepted(id2, rca2, uint64(block.timestamp)); + + // Reconcile both in batch + bytes16[] memory ids = new bytes16[](2); + ids[0] = id1; + ids[1] = id2; + agreementManager.reconcileBatch(ids); + + // Agreement 1 canceled by SP -> maxNextClaim = 0 + assertEq(agreementManager.getAgreementMaxNextClaim(id1), 0); + // Agreement 2 accepted, never collected -> maxNextClaim = initial + ongoing + assertEq(agreementManager.getAgreementMaxNextClaim(id2), maxClaim2); + // Required should be just agreement 2 now + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim2); + } + + function test_ReconcileBatch_SkipsNonExistent() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 realId = _offerAgreement(rca); + bytes16 fakeId = bytes16(keccak256("nonexistent")); + + // Accept to enable reconciliation + _setAgreementAccepted(realId, rca, uint64(block.timestamp)); + + // Batch with a nonexistent id — should not revert + bytes16[] memory ids = new bytes16[](2); + ids[0] = fakeId; + ids[1] = realId; + agreementManager.reconcileBatch(ids); + + // Real agreement should still be tracked + uint256 maxClaim = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getAgreementMaxNextClaim(realId), maxClaim); + } + + function test_ReconcileBatch_Empty() public { + // Empty array — should succeed silently + bytes16[] memory ids = new bytes16[](0); + agreementManager.reconcileBatch(ids); + } + + function test_ReconcileBatch_CrossIndexer() public { + address indexer2 = makeAddr("indexer2"); + + // Agreement 1 for default indexer + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + // Agreement 2 for indexer2 + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.serviceProvider = indexer2; + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim1); + assertEq(agreementManager.getRequiredEscrow(indexer2), maxClaim2); + + // Cancel both by SP + _setAgreementCanceledBySP(id1, rca1); + _setAgreementCanceledBySP(id2, rca2); + + bytes16[] memory ids = new bytes16[](2); + ids[0] = id1; + ids[1] = id2; + agreementManager.reconcileBatch(ids); + + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(indexer2), 0); + } + + function test_ReconcileBatch_Permissionless() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + // Anyone can call + address anyone = makeAddr("anyone"); + bytes16[] memory ids = new bytes16[](1); + ids[0] = agreementId; + vm.prank(anyone); + agreementManager.reconcileBatch(ids); + } + + function test_ReconcileBatch_ClearsPendingUpdate() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Offer a pending update (nonce 1) + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim); + + // Simulate: accepted with the update already applied (updateNonce >= pending) + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: uint64(block.timestamp), + lastCollectionAt: 0, + endsAt: rcau.endsAt, + maxInitialTokens: rcau.maxInitialTokens, + maxOngoingTokensPerSecond: rcau.maxOngoingTokensPerSecond, + minSecondsPerCollection: rcau.minSecondsPerCollection, + maxSecondsPerCollection: rcau.maxSecondsPerCollection, + updateNonce: 1, // matches pending nonce, so update was applied + canceledAt: 0, + state: IRecurringCollector.AgreementState.Accepted + }) + ); + + bytes16[] memory ids = new bytes16[](1); + ids[0] = agreementId; + agreementManager.reconcileBatch(ids); + + // Pending should be cleared; required escrow should be based on new terms + uint256 newMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), newMaxClaim); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/register.t.sol b/packages/issuance/test/unit/agreement-manager/register.t.sol new file mode 100644 index 000000000..09008a7b8 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/register.t.sol @@ -0,0 +1,240 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerOfferTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_Offer_SetsAgreementState() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 expectedId) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + assertEq(agreementId, expectedId); + // maxNextClaim = maxOngoingTokensPerSecond * maxSecondsPerCollection + maxInitialTokens + // = 1e18 * 3600 + 100e18 = 3700e18 + uint256 expectedMaxClaim = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), expectedMaxClaim); + assertEq(agreementManager.getRequiredEscrow(indexer), expectedMaxClaim); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + } + + function test_Offer_FundsEscrow() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + uint256 expectedMaxClaim = 1 ether * 3600 + 100 ether; + + // Fund and register + token.mint(address(agreementManager), expectedMaxClaim); + vm.prank(operator); + agreementManager.offerAgreement(rca); + + // Verify escrow was funded + uint256 escrowBalance = paymentsEscrow.getBalance( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(escrowBalance, expectedMaxClaim); + } + + function test_Offer_PartialFunding_WhenInsufficientBalance() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + uint256 expectedMaxClaim = 1 ether * 3600 + 100 ether; + uint256 available = 500 ether; // Less than expectedMaxClaim + + // Fund with less than needed + token.mint(address(agreementManager), available); + vm.prank(operator); + agreementManager.offerAgreement(rca); + + // Escrow should have the available amount, not the full required + uint256 escrowBalance = paymentsEscrow.getBalance( + address(agreementManager), + address(recurringCollector), + indexer + ); + assertEq(escrowBalance, available); + // Deficit should be the remainder + assertEq(agreementManager.getDeficit(indexer), expectedMaxClaim - available); + } + + function test_Offer_EmitsEvent() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 expectedId = recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + uint256 expectedMaxClaim = 1 ether * 3600 + 100 ether; + + token.mint(address(agreementManager), expectedMaxClaim); + + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.AgreementOffered(expectedId, indexer, expectedMaxClaim); + + vm.prank(operator); + agreementManager.offerAgreement(rca); + } + + function test_Offer_AuthorizesHash() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + _offerAgreement(rca); + + // The agreement hash should be authorized for the IContractApprover callback + bytes32 agreementHash = recurringCollector.hashRCA(rca); + bytes4 result = agreementManager.isAuthorizedAgreement(agreementHash); + assertEq(result, agreementManager.isAuthorizedAgreement.selector); + } + + function test_Offer_MultipleAgreements_SameIndexer() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + assertTrue(id1 != id2); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim1 + maxClaim2); + } + + function test_Offer_Revert_WhenPayerMismatch() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca.payer = address(0xdead); // Wrong payer + + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerPayerMismatch.selector, + address(0xdead), + address(agreementManager) + ) + ); + vm.prank(operator); + agreementManager.offerAgreement(rca); + } + + function test_Offer_Revert_WhenAlreadyOffered() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementAlreadyOffered.selector, + agreementId + ) + ); + vm.prank(operator); + agreementManager.offerAgreement(rca); + } + + function test_Offer_Revert_WhenNotOperator() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + address nonOperator = makeAddr("nonOperator"); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOperator, OPERATOR_ROLE) + ); + vm.prank(nonOperator); + agreementManager.offerAgreement(rca); + } + + function test_Offer_Revert_WhenPaused() public { + IRecurringCollector.RecurringCollectionAgreement memory rca = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + + // Grant pause role and pause + vm.startPrank(governor); + agreementManager.grantRole(keccak256("PAUSE_ROLE"), governor); + agreementManager.pause(); + vm.stopPrank(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + vm.prank(operator); + agreementManager.offerAgreement(rca); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/remove.t.sol b/packages/issuance/test/unit/agreement-manager/remove.t.sol new file mode 100644 index 000000000..3086e0685 --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/remove.t.sol @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerRemoveTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_Remove_CanceledByServiceProvider() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + + // SP cancels - immediately removable + _setAgreementCanceledBySP(agreementId, rca); + + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.AgreementRemoved(agreementId, indexer); + + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + } + + function test_Remove_FullyExpiredAgreement() public { + uint64 endsAt = uint64(block.timestamp + 1 hours); + + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + endsAt + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as accepted, collected at endsAt (fully expired, window empty) + _setAgreementCollected(agreementId, rca, uint64(block.timestamp), endsAt); + vm.warp(endsAt); + + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + } + + function test_Remove_CanceledByPayer_WindowExpired() public { + uint64 startTime = uint64(block.timestamp); + + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(startTime + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Payer canceled, window fully consumed + uint64 canceledAt = uint64(startTime + 2 hours); + _setAgreementCanceledByPayer(agreementId, rca, startTime, canceledAt, canceledAt); + + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + } + + function test_Remove_ReducesRequiredEscrow_WithMultipleAgreements() public { + IRecurringCollector.RecurringCollectionAgreement memory rca1 = _makeRCA( + 100 ether, + 1 ether, + 60, + 3600, + uint64(block.timestamp + 365 days) + ); + rca1.nonce = 1; + + IRecurringCollector.RecurringCollectionAgreement memory rca2 = _makeRCA( + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 365 days) + ); + rca2.nonce = 2; + + bytes16 id1 = _offerAgreement(rca1); + bytes16 id2 = _offerAgreement(rca2); + + uint256 maxClaim1 = 1 ether * 3600 + 100 ether; // 3700e18 + uint256 maxClaim2 = 2 ether * 7200 + 200 ether; // 14600e18 + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim1 + maxClaim2); + + // Cancel agreement 1 by SP and remove it + _setAgreementCanceledBySP(id1, rca1); + agreementManager.removeAgreement(id1); + + // Only agreement 2's original maxClaim remains + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim2); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + + // Agreement 2 still tracked + assertEq(agreementManager.getAgreementMaxNextClaim(id2), maxClaim2); + } + + function test_Remove_Revert_WhenNotOffered() public { + bytes16 fakeId = bytes16(keccak256("fake")); + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotOffered.selector, + fakeId + ) + ); + agreementManager.removeAgreement(fakeId); + } + + function test_Remove_Revert_WhenStillClaimable_Accepted() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Set as accepted but never collected - still claimable + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + uint256 maxClaim = 1 ether * 3600 + 100 ether; + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementStillClaimable.selector, + agreementId, + maxClaim + ) + ); + agreementManager.removeAgreement(agreementId); + } + + function test_Remove_ExpiredOffer_NotAccepted() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Warp past the RCA deadline (default: block.timestamp + 1 hours in _makeRCA) + vm.warp(block.timestamp + 2 hours); + + // Agreement not accepted + past deadline — should be removable + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + } + + function test_Remove_Revert_WhenStillClaimable_NotAccepted() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Not accepted yet - stored maxNextClaim is used (can still be accepted and then claimed) + uint256 storedMaxClaim = agreementManager.getAgreementMaxNextClaim(agreementId); + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementStillClaimable.selector, + agreementId, + storedMaxClaim + ) + ); + agreementManager.removeAgreement(agreementId); + } + + function test_Remove_Revert_WhenCanceledByPayer_WindowStillOpen() public { + uint64 startTime = uint64(block.timestamp); + + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(startTime + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Payer canceled but window is still open (not yet collected) + uint64 canceledAt = uint64(startTime + 2 hours); + _setAgreementCanceledByPayer(agreementId, rca, startTime, canceledAt, 0); + + // Still claimable: window = canceledAt - acceptedAt = 7200s, capped at 3600s + // maxClaim = 1e18 * 3600 + 100e18 (never collected) + uint256 maxClaim = 1 ether * 3600 + 100 ether; + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementStillClaimable.selector, + agreementId, + maxClaim + ) + ); + agreementManager.removeAgreement(agreementId); + } + + function test_Remove_Permissionless() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // SP cancels + _setAgreementCanceledBySP(agreementId, rca); + + // Anyone can remove + address anyone = makeAddr("anyone"); + vm.prank(anyone); + agreementManager.removeAgreement(agreementId); + + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + } + + function test_Remove_ClearsPendingUpdate() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Offer a pending update + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim); + + // SP cancels - immediately removable + _setAgreementCanceledBySP(agreementId, rca); + + agreementManager.removeAgreement(agreementId); + + // Both original and pending should be cleared from requiredEscrow + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol b/packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol new file mode 100644 index 000000000..442e73ebf --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/revokeOffer.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { IIndexingAgreementManager } from "@graphprotocol/interfaces/contracts/issuance/allocate/IIndexingAgreementManager.sol"; +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol"; +import { PausableUpgradeable } from "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; + +import { IndexingAgreementManagerSharedTest } from "./shared.t.sol"; + +contract IndexingAgreementManagerRevokeOfferTest is IndexingAgreementManagerSharedTest { + /* solhint-disable graph/func-name-mixedcase */ + + function test_RevokeOffer_ClearsAgreement() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + assertEq(agreementManager.getIndexerAgreementCount(indexer), 1); + + uint256 maxClaim = 1 ether * 3600 + 100 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), maxClaim); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + assertEq(agreementManager.getIndexerAgreementCount(indexer), 0); + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + assertEq(agreementManager.getAgreementMaxNextClaim(agreementId), 0); + } + + function test_RevokeOffer_InvalidatesHash() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Hash is authorized before revoke + bytes32 rcaHash = recurringCollector.hashRCA(rca); + agreementManager.isAuthorizedAgreement(rcaHash); // should not revert + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + // Hash should be rejected after revoke (agreement no longer exists) + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotAuthorized.selector, + rcaHash + ) + ); + agreementManager.isAuthorizedAgreement(rcaHash); + } + + function test_RevokeOffer_ClearsPendingUpdate() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Offer a pending update + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau = _makeRCAU( + agreementId, + 200 ether, + 2 ether, + 60, + 7200, + uint64(block.timestamp + 730 days), + 1 + ); + _offerAgreementUpdate(rcau); + + uint256 originalMaxClaim = 1 ether * 3600 + 100 ether; + uint256 pendingMaxClaim = 2 ether * 7200 + 200 ether; + assertEq(agreementManager.getRequiredEscrow(indexer), originalMaxClaim + pendingMaxClaim); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + + // Both original and pending should be cleared + assertEq(agreementManager.getRequiredEscrow(indexer), 0); + } + + function test_RevokeOffer_EmitsEvent() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + vm.expectEmit(address(agreementManager)); + emit IIndexingAgreementManager.OfferRevoked(agreementId, indexer); + + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + } + + function test_RevokeOffer_Revert_WhenAlreadyAccepted() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + // Simulate acceptance in RC + _setAgreementAccepted(agreementId, rca, uint64(block.timestamp)); + + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementAlreadyAccepted.selector, + agreementId + ) + ); + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + } + + function test_RevokeOffer_Revert_WhenNotOffered() public { + bytes16 fakeId = bytes16(keccak256("fake")); + vm.expectRevert( + abi.encodeWithSelector( + IIndexingAgreementManager.IndexingAgreementManagerAgreementNotOffered.selector, + fakeId + ) + ); + vm.prank(operator); + agreementManager.revokeOffer(fakeId); + } + + function test_RevokeOffer_Revert_WhenNotOperator() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + address nonOperator = makeAddr("nonOperator"); + vm.expectRevert( + abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, nonOperator, OPERATOR_ROLE) + ); + vm.prank(nonOperator); + agreementManager.revokeOffer(agreementId); + } + + function test_RevokeOffer_Revert_WhenPaused() public { + (IRecurringCollector.RecurringCollectionAgreement memory rca, ) = _makeRCAWithId( + 100 ether, + 1 ether, + 3600, + uint64(block.timestamp + 365 days) + ); + + bytes16 agreementId = _offerAgreement(rca); + + vm.startPrank(governor); + agreementManager.grantRole(keccak256("PAUSE_ROLE"), governor); + agreementManager.pause(); + vm.stopPrank(); + + vm.expectRevert(PausableUpgradeable.EnforcedPause.selector); + vm.prank(operator); + agreementManager.revokeOffer(agreementId); + } + + /* solhint-enable graph/func-name-mixedcase */ +} diff --git a/packages/issuance/test/unit/agreement-manager/shared.t.sol b/packages/issuance/test/unit/agreement-manager/shared.t.sol new file mode 100644 index 000000000..c31a7a67e --- /dev/null +++ b/packages/issuance/test/unit/agreement-manager/shared.t.sol @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.33; + +import { Test } from "forge-std/Test.sol"; + +import { IRecurringCollector } from "@graphprotocol/interfaces/contracts/horizon/IRecurringCollector.sol"; +import { TransparentUpgradeableProxy } from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import { IndexingAgreementManager } from "../../../contracts/allocate/IndexingAgreementManager.sol"; +import { MockGraphToken } from "./mocks/MockGraphToken.sol"; +import { MockPaymentsEscrow } from "./mocks/MockPaymentsEscrow.sol"; +import { MockRecurringCollector } from "./mocks/MockRecurringCollector.sol"; +import { MockSubgraphService } from "./mocks/MockSubgraphService.sol"; + +/// @notice Shared test setup for IndexingAgreementManager tests. +contract IndexingAgreementManagerSharedTest is Test { + // -- Contracts -- + MockGraphToken internal token; + MockPaymentsEscrow internal paymentsEscrow; + MockRecurringCollector internal recurringCollector; + MockSubgraphService internal mockSubgraphService; + IndexingAgreementManager internal agreementManager; + + // -- Accounts -- + address internal governor; + address internal operator; + address internal indexer; + address internal dataService; + + // -- Constants -- + bytes32 internal constant GOVERNOR_ROLE = keccak256("GOVERNOR_ROLE"); + bytes32 internal constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE"); + + function setUp() public virtual { + governor = makeAddr("governor"); + operator = makeAddr("operator"); + indexer = makeAddr("indexer"); + + // Deploy mocks + token = new MockGraphToken(); + paymentsEscrow = new MockPaymentsEscrow(address(token)); + recurringCollector = new MockRecurringCollector(); + mockSubgraphService = new MockSubgraphService(); + dataService = address(mockSubgraphService); + + // Deploy IndexingAgreementManager behind proxy + IndexingAgreementManager impl = new IndexingAgreementManager( + address(token), + address(paymentsEscrow), + address(recurringCollector) + ); + bytes memory initData = abi.encodeCall(IndexingAgreementManager.initialize, (governor)); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(impl), + address(this), // proxy admin + initData + ); + agreementManager = IndexingAgreementManager(address(proxy)); + + // Grant operator role + vm.prank(governor); + agreementManager.grantRole(OPERATOR_ROLE, operator); + + // Label addresses for trace output + vm.label(address(token), "GraphToken"); + vm.label(address(paymentsEscrow), "PaymentsEscrow"); + vm.label(address(recurringCollector), "RecurringCollector"); + vm.label(address(agreementManager), "IndexingAgreementManager"); + vm.label(address(mockSubgraphService), "SubgraphService"); + } + + // -- Helpers -- + + /// @notice Create a standard RCA with IndexingAgreementManager as payer + function _makeRCA( + uint256 maxInitialTokens, + uint256 maxOngoingTokensPerSecond, + uint32 minSecondsPerCollection, + uint32 maxSecondsPerCollection, + uint64 endsAt + ) internal view returns (IRecurringCollector.RecurringCollectionAgreement memory) { + return + IRecurringCollector.RecurringCollectionAgreement({ + deadline: uint64(block.timestamp + 1 hours), + endsAt: endsAt, + payer: address(agreementManager), + dataService: dataService, + serviceProvider: indexer, + maxInitialTokens: maxInitialTokens, + maxOngoingTokensPerSecond: maxOngoingTokensPerSecond, + minSecondsPerCollection: minSecondsPerCollection, + maxSecondsPerCollection: maxSecondsPerCollection, + nonce: 1, + metadata: "" + }); + } + + /// @notice Create a standard RCA and compute its agreementId + function _makeRCAWithId( + uint256 maxInitialTokens, + uint256 maxOngoingTokensPerSecond, + uint32 maxSecondsPerCollection, + uint64 endsAt + ) internal view returns (IRecurringCollector.RecurringCollectionAgreement memory rca, bytes16 agreementId) { + rca = _makeRCA(maxInitialTokens, maxOngoingTokensPerSecond, 60, maxSecondsPerCollection, endsAt); + agreementId = recurringCollector.generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + } + + /// @notice Offer an RCA via the operator and return the agreementId + function _offerAgreement(IRecurringCollector.RecurringCollectionAgreement memory rca) internal returns (bytes16) { + // Fund IndexingAgreementManager with enough tokens + token.mint(address(agreementManager), 1_000_000 ether); + + vm.prank(operator); + return agreementManager.offerAgreement(rca); + } + + /// @notice Create a standard RCAU for an existing agreement + function _makeRCAU( + bytes16 agreementId, + uint256 maxInitialTokens, + uint256 maxOngoingTokensPerSecond, + uint32 minSecondsPerCollection, + uint32 maxSecondsPerCollection, + uint64 endsAt, + uint32 nonce + ) internal pure returns (IRecurringCollector.RecurringCollectionAgreementUpdate memory) { + return + IRecurringCollector.RecurringCollectionAgreementUpdate({ + agreementId: agreementId, + deadline: 0, // Not used for unsigned path + endsAt: endsAt, + maxInitialTokens: maxInitialTokens, + maxOngoingTokensPerSecond: maxOngoingTokensPerSecond, + minSecondsPerCollection: minSecondsPerCollection, + maxSecondsPerCollection: maxSecondsPerCollection, + nonce: nonce, + metadata: "" + }); + } + + /// @notice Offer an RCAU via the operator + function _offerAgreementUpdate( + IRecurringCollector.RecurringCollectionAgreementUpdate memory rcau + ) internal returns (bytes16) { + vm.prank(operator); + return agreementManager.offerAgreementUpdate(rcau); + } + + /// @notice Set up a mock agreement in RecurringCollector as Accepted + function _setAgreementAccepted( + bytes16 agreementId, + IRecurringCollector.RecurringCollectionAgreement memory rca, + uint64 acceptedAt + ) internal { + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: acceptedAt, + lastCollectionAt: 0, + endsAt: rca.endsAt, + maxInitialTokens: rca.maxInitialTokens, + maxOngoingTokensPerSecond: rca.maxOngoingTokensPerSecond, + minSecondsPerCollection: rca.minSecondsPerCollection, + maxSecondsPerCollection: rca.maxSecondsPerCollection, + updateNonce: 0, + canceledAt: 0, + state: IRecurringCollector.AgreementState.Accepted + }) + ); + } + + /// @notice Set up a mock agreement as CanceledByServiceProvider + function _setAgreementCanceledBySP( + bytes16 agreementId, + IRecurringCollector.RecurringCollectionAgreement memory rca + ) internal { + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: uint64(block.timestamp), + lastCollectionAt: 0, + endsAt: rca.endsAt, + maxInitialTokens: rca.maxInitialTokens, + maxOngoingTokensPerSecond: rca.maxOngoingTokensPerSecond, + minSecondsPerCollection: rca.minSecondsPerCollection, + maxSecondsPerCollection: rca.maxSecondsPerCollection, + updateNonce: 0, + canceledAt: uint64(block.timestamp), + state: IRecurringCollector.AgreementState.CanceledByServiceProvider + }) + ); + } + + /// @notice Set up a mock agreement as CanceledByPayer + function _setAgreementCanceledByPayer( + bytes16 agreementId, + IRecurringCollector.RecurringCollectionAgreement memory rca, + uint64 acceptedAt, + uint64 canceledAt, + uint64 lastCollectionAt + ) internal { + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: acceptedAt, + lastCollectionAt: lastCollectionAt, + endsAt: rca.endsAt, + maxInitialTokens: rca.maxInitialTokens, + maxOngoingTokensPerSecond: rca.maxOngoingTokensPerSecond, + minSecondsPerCollection: rca.minSecondsPerCollection, + maxSecondsPerCollection: rca.maxSecondsPerCollection, + updateNonce: 0, + canceledAt: canceledAt, + state: IRecurringCollector.AgreementState.CanceledByPayer + }) + ); + } + + /// @notice Set up a mock agreement as having been collected + function _setAgreementCollected( + bytes16 agreementId, + IRecurringCollector.RecurringCollectionAgreement memory rca, + uint64 acceptedAt, + uint64 lastCollectionAt + ) internal { + recurringCollector.setAgreement( + agreementId, + IRecurringCollector.AgreementData({ + dataService: rca.dataService, + payer: rca.payer, + serviceProvider: rca.serviceProvider, + acceptedAt: acceptedAt, + lastCollectionAt: lastCollectionAt, + endsAt: rca.endsAt, + maxInitialTokens: rca.maxInitialTokens, + maxOngoingTokensPerSecond: rca.maxOngoingTokensPerSecond, + minSecondsPerCollection: rca.minSecondsPerCollection, + maxSecondsPerCollection: rca.maxSecondsPerCollection, + updateNonce: 0, + canceledAt: 0, + state: IRecurringCollector.AgreementState.Accepted + }) + ); + } +} diff --git a/packages/subgraph-service/contracts/SubgraphService.sol b/packages/subgraph-service/contracts/SubgraphService.sol index 4626c4a05..5cb2f6037 100644 --- a/packages/subgraph-service/contracts/SubgraphService.sol +++ b/packages/subgraph-service/contracts/SubgraphService.sol @@ -434,6 +434,35 @@ contract SubgraphService is return IndexingAgreement._getStorageManager().accept(_allocations, allocationId, signedRCA); } + /** + * @inheritdoc ISubgraphService + * @notice Accept an indexing agreement where the payer is a contract. + * + * See {IndexingAgreement.acceptUnsigned}. + * + * Requirements: + * - The contract must not be paused + * - The indexer must be registered and have a valid provision + * - The payer must implement {IContractApprover} + * + * @param allocationId The id of the allocation + * @param rca The Recurring Collection Agreement + * @return agreementId The ID of the accepted indexing agreement + */ + function acceptUnsignedIndexingAgreement( + address allocationId, + IRecurringCollector.RecurringCollectionAgreement calldata rca + ) + external + whenNotPaused + onlyAuthorizedForProvision(rca.serviceProvider) + onlyValidProvision(rca.serviceProvider) + onlyRegisteredIndexer(rca.serviceProvider) + returns (bytes16) + { + return IndexingAgreement._getStorageManager().acceptUnsigned(_allocations, allocationId, rca); + } + /** * @inheritdoc ISubgraphService * @notice Update an indexing agreement. @@ -460,6 +489,33 @@ contract SubgraphService is IndexingAgreement._getStorageManager().update(indexer, signedRCAU); } + /** + * @inheritdoc ISubgraphService + * @notice Update an indexing agreement where the payer is a contract. + * + * See {IndexingAgreement.updateUnsigned}. + * + * Requirements: + * - The contract must not be paused + * - The indexer must be valid + * - The payer must implement {IContractApprover} + * + * @param indexer The indexer address + * @param rcau The Recurring Collection Agreement Update + */ + function updateUnsignedIndexingAgreement( + address indexer, + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau + ) + external + whenNotPaused + onlyAuthorizedForProvision(indexer) + onlyValidProvision(indexer) + onlyRegisteredIndexer(indexer) + { + IndexingAgreement._getStorageManager().updateUnsigned(indexer, rcau); + } + /** * @inheritdoc ISubgraphService * @notice Cancel an indexing agreement by indexer / operator. diff --git a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol index 7d7bce017..642a7ede1 100644 --- a/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol +++ b/packages/subgraph-service/contracts/libraries/IndexingAgreement.sol @@ -357,6 +357,89 @@ library IndexingAgreement { } /* solhint-enable function-max-lines */ + /* solhint-disable function-max-lines */ + /** + * @notice Accept an indexing agreement where the payer is a contract. + * + * Requirements: + * - Same as {accept}, but uses contract callback instead of ECDSA signature + * - The payer must implement {IContractApprover} + * + * Emits {IndexingAgreementAccepted} event + * + * @param self The indexing agreement storage manager + * @param allocations The mapping of allocation IDs to their states + * @param allocationId The id of the allocation + * @param rca The Recurring Collection Agreement + * @return The agreement ID assigned to the accepted indexing agreement + */ + function acceptUnsigned( + StorageManager storage self, + mapping(address allocationId => IAllocation.State allocation) storage allocations, + address allocationId, + IRecurringCollector.RecurringCollectionAgreement calldata rca + ) external returns (bytes16) { + IAllocation.State memory allocation = _requireValidAllocation(allocations, allocationId, rca.serviceProvider); + + require(rca.dataService == address(this), IndexingAgreementWrongDataService(address(this), rca.dataService)); + + AcceptIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAMetadata(rca.metadata); + + bytes16 agreementId = _directory().recurringCollector().generateAgreementId( + rca.payer, + rca.dataService, + rca.serviceProvider, + rca.deadline, + rca.nonce + ); + + IIndexingAgreement.State storage agreement = self.agreements[agreementId]; + + require(agreement.allocationId == address(0), IndexingAgreementAlreadyAccepted(agreementId)); + + require( + allocation.subgraphDeploymentId == metadata.subgraphDeploymentId, + IndexingAgreementDeploymentIdMismatch( + metadata.subgraphDeploymentId, + allocationId, + allocation.subgraphDeploymentId + ) + ); + + // Ensure that an allocation can only have one active indexing agreement + require( + self.allocationToActiveAgreementId[allocationId] == bytes16(0), + AllocationAlreadyHasIndexingAgreement(allocationId) + ); + self.allocationToActiveAgreementId[allocationId] = agreementId; + + agreement.version = metadata.version; + agreement.allocationId = allocationId; + + require( + metadata.version == IIndexingAgreement.IndexingAgreementVersion.V1, + IndexingAgreementInvalidVersion(metadata.version) + ); + _setTermsV1(self, agreementId, metadata.terms, rca.maxOngoingTokensPerSecond); + + emit IndexingAgreementAccepted( + rca.serviceProvider, + rca.payer, + agreementId, + allocationId, + metadata.subgraphDeploymentId, + metadata.version, + metadata.terms + ); + + require( + _directory().recurringCollector().acceptUnsigned(rca) == agreementId, + "internal: agreement ID mismatch" + ); + return agreementId; + } + /* solhint-enable function-max-lines */ + /** * @notice Update an indexing agreement. * @@ -415,6 +498,58 @@ library IndexingAgreement { _directory().recurringCollector().update(signedRCAU); } + /** + * @notice Update an indexing agreement where the payer is a contract. + * + * Requirements: + * - Agreement must be active + * - The indexer must be the service provider of the agreement + * - The payer must implement {IContractApprover} + * + * @dev rcau.metadata is an encoding of {IndexingAgreement.UpdateIndexingAgreementMetadata} + * + * Emits {IndexingAgreementUpdated} event + * + * @param self The indexing agreement storage manager + * @param indexer The indexer address + * @param rcau The Recurring Collection Agreement Update + */ + function updateUnsigned( + StorageManager storage self, + address indexer, + IRecurringCollector.RecurringCollectionAgreementUpdate calldata rcau + ) external { + IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, rcau.agreementId); + require(_isActive(wrapper), IndexingAgreementNotActive(rcau.agreementId)); + require( + wrapper.collectorAgreement.serviceProvider == indexer, + IndexingAgreementNotAuthorized(rcau.agreementId, indexer) + ); + + UpdateIndexingAgreementMetadata memory metadata = IndexingAgreementDecoder.decodeRCAUMetadata(rcau.metadata); + + require( + wrapper.agreement.version == IIndexingAgreement.IndexingAgreementVersion.V1, + "internal: invalid version" + ); + require( + metadata.version == IIndexingAgreement.IndexingAgreementVersion.V1, + IndexingAgreementInvalidVersion(metadata.version) + ); + _setTermsV1(self, rcau.agreementId, metadata.terms, wrapper.collectorAgreement.maxOngoingTokensPerSecond); + + emit IndexingAgreementUpdated({ + indexer: wrapper.collectorAgreement.serviceProvider, + payer: wrapper.collectorAgreement.payer, + agreementId: rcau.agreementId, + allocationId: wrapper.agreement.allocationId, + version: metadata.version, + versionTerms: metadata.terms + }); + + _directory().recurringCollector().updateUnsigned(rcau); + } + /** * @notice Cancel an indexing agreement. * @@ -502,7 +637,8 @@ library IndexingAgreement { IIndexingAgreement.AgreementWrapper memory wrapper = _get(self, agreementId); require(_isActive(wrapper), IndexingAgreementNotActive(agreementId)); require( - _directory().recurringCollector().isAuthorized(wrapper.collectorAgreement.payer, msg.sender), + msg.sender == wrapper.collectorAgreement.payer || + _directory().recurringCollector().isAuthorized(wrapper.collectorAgreement.payer, msg.sender), IndexingAgreementNonCancelableBy(wrapper.collectorAgreement.payer, msg.sender) ); _cancel( diff --git a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol index c11c5549d..0275354f0 100644 --- a/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol +++ b/packages/subgraph-service/test/unit/subgraphService/indexing-agreement/cancel.t.sol @@ -33,6 +33,8 @@ contract SubgraphServiceIndexingAgreementCancelTest is SubgraphServiceIndexingAg address rando ) public withSafeIndexerOrOperator(rando) { Context storage ctx = _newCtx(seed); + vm.assume(rando != seed.rca.payer); + vm.assume(rando != ctx.payer.signer); (IRecurringCollector.SignedRCA memory accepted, bytes16 agreementId) = _withAcceptedIndexingAgreement( ctx, _withIndexer(ctx) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52438ce5e..15c7183a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1134,6 +1134,9 @@ importers: ethers: specifier: 'catalog:' version: 6.16.0(bufferutil@4.0.9)(utf-8-validate@5.0.10) + forge-std: + specifier: 'catalog:' + version: https://github.com/foundry-rs/forge-std/tarball/v1.14.0 glob: specifier: 'catalog:' version: 11.0.3